From 2390b6151e46bfad2ced70fa2080c20c8ab82d9d Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Tue, 13 Oct 2026 08:14:43 +0200 Subject: [PATCH] feat(master): POLL_INTERVAL, poller wiring and version picker in the UI (#9) --- cmd/master/main.go | 48 ++++- internal/master/config/config.go | 14 ++ internal/master/config/config_test.go | 22 +- internal/master/web/server.go | 42 +++- internal/master/web/server_test.go | 29 ++- internal/master/web/static/app.css | 1 + internal/master/web/templates/overview.html | 2 +- internal/master/web/templates/service.html | 23 ++ internal/master/web/versions_test.go | 221 ++++++++++++++++++++ internal/master/web/views.go | 47 +++++ 10 files changed, 438 insertions(+), 11 deletions(-) create mode 100644 internal/master/web/versions_test.go diff --git a/cmd/master/main.go b/cmd/master/main.go index 15cde34..83ae845 100644 --- a/cmd/master/main.go +++ b/cmd/master/main.go @@ -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 } diff --git a/internal/master/config/config.go b/internal/master/config/config.go index 77acd6f..50ce6bc 100644 --- a/internal/master/config/config.go +++ b/internal/master/config/config.go @@ -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 } diff --git a/internal/master/config/config_test.go b/internal/master/config/config_test.go index 977ee67..9b5a942 100644 --- a/internal/master/config/config_test.go +++ b/internal/master/config/config_test.go @@ -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) { diff --git a/internal/master/web/server.go b/internal/master/web/server.go index 58434c1..4bf6a39 100644 --- a/internal/master/web/server.go +++ b/internal/master/web/server.go @@ -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 " 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): diff --git a/internal/master/web/server_test.go b/internal/master/web/server_test.go index 52e40b4..68645c1 100644 --- a/internal/master/web/server_test.go +++ b/internal/master/web/server_test.go @@ -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() diff --git a/internal/master/web/static/app.css b/internal/master/web/static/app.css index e50510a..28cf749 100644 --- a/internal/master/web/static/app.css +++ b/internal/master/web/static/app.css @@ -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; } diff --git a/internal/master/web/templates/overview.html b/internal/master/web/templates/overview.html index 3ff03b4..78ce293 100644 --- a/internal/master/web/templates/overview.html +++ b/internal/master/web/templates/overview.html @@ -14,7 +14,7 @@ {{range .Services}} - {{.DisplayName}}{{if .Self}} managed outside{{end}}{{if .AvailableRef}} update available{{end}} + {{.DisplayName}}{{if .Self}} managed outside{{end}}{{if .AvailableRef}} update available{{end}}{{if followsNoTag .Service}} follows no tag{{end}}{{if .LastPollError}} poll failed{{end}} {{shortRef .ActualRef}} {{.Status}} {{.UpdateMode}} diff --git a/internal/master/web/templates/service.html b/internal/master/web/templates/service.html index 68c0082..8709bb1 100644 --- a/internal/master/web/templates/service.html +++ b/internal/master/web/templates/service.html @@ -10,6 +10,7 @@ {{if .Service.DesiredRef}}Desired image{{shortRef .Service.DesiredRef}}{{end}} {{if .Service.AvailableRef}}Available{{.Service.AvailableRef}} via {{.Service.AvailableSource}}{{with .Service.AvailableAt}}, {{.Format "2006-01-02 15:04"}}{{end}}{{end}} Mode{{.Service.UpdateMode}} + Polling{{.Polling}} {{if .ConfigFiles}}Config files{{range .ConfigFiles}}{{.}}
{{end}}{{end}} {{if .Reason}} @@ -31,6 +32,28 @@

{{if .AutoBlocked}}Auto unavailable: {{.AutoBlocked}}.{{else}}The webhook will update this service as soon as CI pushes a new image.{{end}}

{{end}} +

Versions

+{{if eq .Service.UpdateMode "auto"}}

This service is in auto mode: a version picked here is replaced again when a newer version is announced or found by polling.

{{end}} + + + + {{range .Versions}} + + + + + + + {{else}} + + {{end}} + +
VersionSeen viaFirst seen
{{shortRef .Ref}}{{if .Running}} running{{end}}{{if not .Tag}} the service will follow no tag afterwards{{end}}{{.Source}}{{.FirstSeenAt.Format "2006-01-02 15:04"}}{{if not $.AutoBlocked}} +
+ + + +
{{end}}
No versions seen yet.

Update history

{{template "history" .}} diff --git a/internal/master/web/versions_test.go b/internal/master/web/versions_test.go new file mode 100644 index 0000000..838ce5c --- /dev/null +++ b/internal/master/web/versions_test.go @@ -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, ``, 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, `running`), "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, "

Versions

") + 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, ``, "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 " }, "last check failed: head x: 401 <b>", 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, "Polling`+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"