diff --git a/CLAUDE.md b/CLAUDE.md index 67b422f..c8f3291 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -38,7 +38,7 @@ DATABASE_URL=postgres://... go test -tags integration ./internal/master/store/ - `internal/master/hub` — agent connections: auth, per-connection writer, routing, job reconcile on hello - `internal/master/registry` — HEAD-only digest lookup and tag listing against a registry (go-containerregistry, `DOCKER_CONFIG` credentials) - `internal/master/tagver` — which tag is a newer version of a followed tag (pure functions) -- `internal/master/poller` — every `POLL_INTERVAL`: one lookup per followed `repo:tag`, sets the available version, starts auto jobs; one tag list per repo, sets the newer tag suggestion +- `internal/master/poller` — every `POLL_INTERVAL`: one lookup per followed `repo:tag`, sets the available version, starts auto jobs; one tag list per repo, sets the newer tag suggestion; `PollService` does the same for one service (UI Check now) - `internal/master/jobs` — decides whether a service may be updated and starts a job on the right agent - `internal/master/webhook` — `POST /update`: records the available version, starts jobs for auto services, waits for them - `internal/master/web/icons` — embedded Simple Icons path data; slug derived from the image ref; regenerate with `go generate ./internal/master/web/icons` diff --git a/README.md b/README.md index f122ddb..a891758 100644 --- a/README.md +++ b/README.md @@ -156,6 +156,9 @@ multi-arch image the digest is the index digest, the same one `docker inspect` s - **What is not polled.** Services that follow no tag (`repo@digest`), `build:` services, and services without a registry digest (locally built images). The service page says which. Plain containers are polled, so their badge works, but jobs for them are refused. +- **Check now.** The service page's *Check now* button runs the same check immediately for that + service only (digest, auto job, newer tag); other services that follow the same tag are + updated at the next cycle. - **Errors.** A failed lookup is stored for the services that follow that tag and shown as a "poll failed" badge on the overview and in the "Polling" row of the service page; other tags continue. A later success clears it. diff --git a/cmd/master/main.go b/cmd/master/main.go index 36bfe90..3a12da4 100644 --- a/cmd/master/main.go +++ b/cmd/master/main.go @@ -115,7 +115,17 @@ func run() error { } auth := webauth.New(provider, st, cfg.AllowedGroup, strings.HasPrefix(cfg.PublicURL, "https://"), logger) - ui, err := web.New(st, agentHub, jobService, broker, web.Config{ + credDir, credFound, credReadErr := registryCredentialsDir() + logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound, + "config_json_readable", credFound && credReadErr == nil) + if credReadErr != nil { + logger.Warn("registry_credentials_unreadable", "docker_config", credDir, "error", credReadErr) + } + registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version, logger), broker, poller.Config{ + Interval: cfg.PollInterval, FirstDelay: pollFirstDelay, Workers: pollWorkers, + }, logger) + + ui, err := web.New(st, agentHub, jobService, broker, registryPoller, web.Config{ AgentURL: cfg.AgentURL, MasterKey: fingerprint, AgentImage: cfg.AgentImage, PollInterval: cfg.PollInterval, Version: version, }, logger) @@ -164,17 +174,8 @@ func run() error { BaseContext: baseContext, } - credDir, credFound, credReadErr := registryCredentialsDir() - logger.Info("registry_credentials", "docker_config", credDir, "config_json_found", credFound, - "config_json_readable", credFound && credReadErr == nil) - if credReadErr != nil { - logger.Warn("registry_credentials_unreadable", "docker_config", credDir, "error", credReadErr) - } pollCtx, stopPolling := context.WithCancel(ctx) defer stopPolling() - registryPoller := poller.New(st, jobService, registry.New("package-updater-master/"+version, logger), broker, poller.Config{ - Interval: cfg.PollInterval, FirstDelay: pollFirstDelay, Workers: pollWorkers, - }, logger) pollDone := make(chan struct{}) go func() { defer close(pollDone) diff --git a/internal/master/poller/poller.go b/internal/master/poller/poller.go index d79c127..fb914eb 100644 --- a/internal/master/poller/poller.go +++ b/internal/master/poller/poller.go @@ -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 diff --git a/internal/master/poller/poller_test.go b/internal/master/poller/poller_test.go index 96c4825..0598825 100644 --- a/internal/master/poller/poller_test.go +++ b/internal/master/poller/poller_test.go @@ -938,3 +938,151 @@ 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()) +} + +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) +} diff --git a/internal/master/web/drift_test.go b/internal/master/web/drift_test.go index fa3b7c0..81b90c5 100644 --- a/internal/master/web/drift_test.go +++ b/internal/master/web/drift_test.go @@ -281,7 +281,7 @@ func TestServiceDetail_DriftAndMoveData(t *testing.T) { store := &fakeStore{services: []model.Service{live, gone, swarm, container, self}, moves: []model.Move{move}, sources: sources} jobs := &fakeJobs{restorable: tt.restorable} - s, err := New(store, &fakeHub{}, jobs, events.NewBroker(), Config{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + s, err := New(store, &fakeHub{}, jobs, events.NewBroker(), &fakePoller{}, Config{}, slog.New(slog.NewTextHandler(io.Discard, nil))) require.NoError(t, err) req := httptest.NewRequest(http.MethodGet, "/services/"+strconv.FormatInt(tt.id, 10), nil) diff --git a/internal/master/web/poll_test.go b/internal/master/web/poll_test.go new file mode 100644 index 0000000..29b9210 --- /dev/null +++ b/internal/master/web/poll_test.go @@ -0,0 +1,86 @@ +package web + +import ( + "context" + "io" + "net/http" + "net/url" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/master/poller" +) + +// fakePoller records the services it was asked to check and answers err. +type fakePoller struct { + mu sync.Mutex + err error + calls []int64 +} + +func (f *fakePoller) PollService(_ context.Context, id int64) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, id) + return f.err +} + +func (f *fakePoller) called() []int64 { + f.mu.Lock() + defer f.mu.Unlock() + return append([]int64(nil), f.calls...) +} + +func TestPollService(t *testing.T) { + tests := []struct { + name string + id string + err error + wantStatus int + wantLocation string + wantBody string + wantCalls []int64 + }{ + {name: "redirects to the service page", id: "1", wantStatus: http.StatusSeeOther, wantLocation: "/services/1", wantCalls: []int64{1}}, + {name: "unknown service is 404", id: "1", err: model.ErrNotFound, wantStatus: http.StatusNotFound, wantCalls: []int64{1}}, + {name: "invalid id is 400", id: "abc", wantStatus: http.StatusBadRequest}, + {name: "not polled is 409 with the reason", id: "1", err: &poller.NotPolledError{Reason: model.ReasonNoTag}, + wantStatus: http.StatusConflict, wantBody: "Cannot check: follows no tag.", wantCalls: []int64{1}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fp := &fakePoller{err: tt.err} + srv, _, _, _, _ := newTestEnvFull(t, func(*Config) {}, fp) + + resp, err := noRedirectClient().PostForm(srv.URL+"/services/"+tt.id+"/poll", url.Values{"csrf_token": {"csrf"}}) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + assert.Equal(t, tt.wantStatus, resp.StatusCode) + assert.Equal(t, tt.wantLocation, resp.Header.Get("Location")) + assert.Contains(t, string(body), tt.wantBody) + assert.Equal(t, tt.wantCalls, fp.called()) + }) + } +} + +func TestServicePage_CheckNowForm(t *testing.T) { + srv, store, _, _, _ := newTestEnv(t) + store.services[0].ActualDigest = "sha256:" + "a" + _, body := get(t, srv, "/services/1") + assert.Contains(t, body, `action="/services/1/poll"`) + assert.Contains(t, body, ``) + assert.NotContains(t, body, "Check unavailable") +} + +func TestServicePage_CheckNowDisabledWhenNotPolled(t *testing.T) { + srv, _, _, _, _ := newTestEnv(t) + _, body := get(t, srv, "/services/2") // follows no tag + assert.Contains(t, body, ``) + assert.Contains(t, body, "Check unavailable: follows no tag.") +} diff --git a/internal/master/web/server.go b/internal/master/web/server.go index 0fa0eb6..841dcc7 100644 --- a/internal/master/web/server.go +++ b/internal/master/web/server.go @@ -18,6 +18,7 @@ import ( "github.com/shcizo/package-updater/internal/agenttoken" "github.com/shcizo/package-updater/internal/master/events" "github.com/shcizo/package-updater/internal/master/model" + "github.com/shcizo/package-updater/internal/master/poller" "github.com/shcizo/package-updater/internal/master/web/icons" "github.com/shcizo/package-updater/internal/master/webauth" "github.com/shcizo/package-updater/internal/protocol" @@ -85,6 +86,13 @@ type Jobs interface { RestoreDesired(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error) } +// Poller checks the registry for one service. *poller.Poller satisfies it. +type Poller interface { + // PollService returns *poller.NotPolledError when the service is not + // polled and model.ErrNotFound for an unknown or removed one. + PollService(ctx context.Context, id int64) error +} + // Broker delivers change signals to open streams and accepts signals from // UI actions. *events.Broker satisfies it. type Broker interface { @@ -108,6 +116,7 @@ type Server struct { hub Hub jobs Jobs broker Broker + poller Poller cfg Config log *slog.Logger now func() time.Time @@ -116,7 +125,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) { +func New(store Store, hub Hub, jobs Jobs, broker Broker, poller Poller, cfg Config, log *slog.Logger) (*Server, error) { funcs := template.FuncMap{"shortRef": shortRef, "duration": jobDuration, "followsNoTag": followsNoTag, "middle": middle} pages := map[string]*template.Template{} for _, page := range []string{"overview.html", "agents.html", "service.html", "jobs.html", "job.html"} { @@ -126,7 +135,7 @@ func New(store Store, hub Hub, jobs Jobs, broker Broker, cfg Config, log *slog.L } pages[page] = t } - return &Server{store: store, hub: hub, jobs: jobs, broker: broker, cfg: cfg, log: log, now: time.Now, pages: pages}, nil + return &Server{store: store, hub: hub, jobs: jobs, broker: broker, poller: poller, cfg: cfg, log: log, now: time.Now, pages: pages}, nil } // Register adds the UI routes to mux. protect wraps every route except @@ -140,6 +149,7 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment) route("POST /services/{id}/update", s.updateService) route("POST /services/{id}/mode", s.setMode) + route("POST /services/{id}/poll", s.pollService) route("POST /services/{id}/restore", s.restoreDesired) route("POST /services/{id}/accept", s.acceptRunning) route("POST /services/{id}/merge", s.mergeService) @@ -204,6 +214,7 @@ 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 + PollBlocked string // why Service cannot be checked against the registry; "" when it can Versions []versionRow NewerTag string // Service's newer tag while it still holds; "" otherwise Polling string // what the service page says about registry polling @@ -380,6 +391,7 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) { return data, err } data.AutoBlocked = s.autoBlocked(svc) + data.PollBlocked = pollBlocked(svc) if err := s.jobs.Restorable(svc); errors.As(err, &nu) { data.RestoreBlocked = nu.Reason } else if err != nil { @@ -605,6 +617,32 @@ func (s *Server) renderServiceError(w http.ResponseWriter, r *http.Request, id i s.page(w, r, status, "service.html", data) } +// pollService checks the registry for one service now and waits for the +// answer. A registry error is not a failure of the request: it shows on the +// service page as the last check failing. The poller publishes the change +// itself when it writes. +func (s *Server) pollService(w http.ResponseWriter, r *http.Request) { + id, ok := pathID(w, r) + if !ok { + return + } + err := s.poller.PollService(r.Context(), id) + var np *poller.NotPolledError + switch { + case errors.As(err, &np): + s.renderServiceError(w, r, id, http.StatusConflict, "Cannot check: "+np.Reason+".") + return + case r.Context().Err() != nil: + return // the client is gone + case err != nil: + s.failLookup(w, err) + return + } + sess, _ := webauth.SessionFrom(r.Context()) + s.log.Info("poll_requested", "service_id", id, "subject", sess.Subject) + http.Redirect(w, r, "/services/"+strconv.FormatInt(id, 10), http.StatusSeeOther) +} + // setMode switches a service between manual and auto. Manual is always // allowed; auto only when jobs.AutoAllowed says so. func (s *Server) setMode(w http.ResponseWriter, r *http.Request) { diff --git a/internal/master/web/server_test.go b/internal/master/web/server_test.go index 94128cf..bfaf67d 100644 --- a/internal/master/web/server_test.go +++ b/internal/master/web/server_test.go @@ -232,6 +232,12 @@ func newTestEnvVersion(t *testing.T, version string) (*httptest.Server, *fakeSto // newTestEnvConfig is newTestEnv with edit applied to the UI's Config. func newTestEnvConfig(t *testing.T, edit func(*Config)) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) { + t.Helper() + return newTestEnvFull(t, edit, &fakePoller{}) +} + +// newTestEnvFull is newTestEnvConfig with p as the poller. +func newTestEnvFull(t *testing.T, edit func(*Config), p Poller) (*httptest.Server, *fakeStore, *fakeHub, *events.Broker, *fakeJobs) { t.Helper() agentID, offlineAgentID := int64(2), int64(3) store := &fakeStore{ @@ -260,7 +266,7 @@ func newTestEnvConfig(t *testing.T, edit func(*Config)) (*httptest.Server, *fake AgentURL: "wss://10.0.0.5:8090/agent", MasterKey: "sha256:abc", AgentImage: "gitea.shcizo.se/shcizo/package-updater:latest", } edit(&cfg) - s, err := New(store, hub, jobs, broker, cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) + s, err := New(store, hub, jobs, broker, p, cfg, slog.New(slog.NewTextHandler(io.Discard, nil))) require.NoError(t, err) mux := http.NewServeMux() s.Register(mux, func(h http.Handler) http.Handler { diff --git a/internal/master/web/templates/service.html b/internal/master/web/templates/service.html index 43681c1..da80576 100644 --- a/internal/master/web/templates/service.html +++ b/internal/master/web/templates/service.html @@ -23,6 +23,11 @@
Pulls the current tag again and redeploys the service.
{{end}} +