diff --git a/internal/master/web/server.go b/internal/master/web/server.go index c1cef5b..58434c1 100644 --- a/internal/master/web/server.go +++ b/internal/master/web/server.go @@ -32,6 +32,8 @@ var agentNamePattern = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,62}$`) type Store interface { ListServices(ctx context.Context) ([]model.Service, error) GetService(ctx context.Context, id int64) (model.Service, 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) ListJobs(ctx context.Context, f model.JobFilter) ([]model.Job, error) ListAgents(ctx context.Context) ([]model.Agent, error) @@ -46,10 +48,11 @@ type Hub interface { Disconnect(agentID int64) } -// Jobs starts updates and explains why a service cannot be updated. -// *jobs.Service satisfies it. +// Jobs starts updates and explains why a service cannot be updated or set +// to auto mode. *jobs.Service satisfies it. type Jobs interface { Updatable(svc model.Service) error + AutoAllowed(svc model.Service) error Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error) } @@ -104,6 +107,7 @@ func (s *Server) Register(mux *http.ServeMux, protect func(http.Handler) http.Ha route("GET /services/{id}", s.servicePage) route("GET /fragments/services/{id}/jobs", s.serviceJobsFragment) route("POST /services/{id}/update", s.updateService) + route("POST /services/{id}/mode", s.setMode) route("GET /jobs", s.jobsPage) route("GET /fragments/jobs", s.jobsFragment) route("GET /jobs/{id}", s.jobPage) @@ -127,6 +131,7 @@ type pageData struct { Service model.Service ConfigFiles []string Reason string // why Service cannot be updated; "" when it can + AutoBlocked string // why Service cannot be set to auto; "" when it can Jobs []model.Job Job model.Job } @@ -150,7 +155,7 @@ func (s *Server) serviceData(r *http.Request) (pageData, error) { if err != nil { return data, err } - data.Groups = groupServices(services) + data.Groups = groupServices(services, s.autoBlocked) return data, nil } @@ -172,6 +177,15 @@ func (s *Server) servicesFragment(w http.ResponseWriter, r *http.Request) { s.render(w, http.StatusOK, "overview.html", "services", data) } +// autoBlocked returns the reason svc cannot be set to auto mode, or "". +func (s *Server) autoBlocked(svc model.Service) string { + var nu *model.NotUpdatableError + if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) { + return nu.Reason + } + return "" +} + // pathID parses the {id} path value. On failure it has already answered 400. func pathID(w http.ResponseWriter, r *http.Request) (int64, bool) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) @@ -207,6 +221,7 @@ func (s *Server) serviceDetail(r *http.Request, id int64) (pageData, error) { } else if err != nil { return data, err } + data.AutoBlocked = s.autoBlocked(svc) data.Jobs, err = s.store.ListJobs(r.Context(), model.JobFilter{ServiceID: id, Limit: serviceJobsLimit}) return data, err } @@ -263,6 +278,54 @@ func (s *Server) updateService(w http.ResponseWriter, r *http.Request) { } } +// 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) { + id, ok := pathID(w, r) + if !ok { + return + } + mode := r.PostFormValue("mode") + if mode != model.ModeManual && mode != model.ModeAuto { + http.Error(w, "invalid mode", http.StatusBadRequest) + return + } + if mode == model.ModeAuto { + svc, err := s.store.GetService(r.Context(), id) + if err == nil && svc.RemovedAt != nil { + err = model.ErrNotFound + } + if err != nil { + s.failLookup(w, err) + return + } + var nu *model.NotUpdatableError + if err := s.jobs.AutoAllowed(svc); errors.As(err, &nu) { + data, derr := s.serviceDetail(r, id) + if derr != nil { + s.failLookup(w, derr) + return + } + data.Error = "Cannot enable auto: " + nu.Reason + "." + s.render(w, http.StatusConflict, "service.html", "layout", data) + return + } else if err != nil { + s.fail(w, err) + return + } + } + if err := s.store.SetUpdateMode(r.Context(), id, mode); err != nil { + s.failLookup(w, err) + return + } + s.broker.Publish(events.Services) + if r.PostFormValue("return") == "overview" { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + http.Redirect(w, r, "/services/"+strconv.FormatInt(id, 10), http.StatusSeeOther) +} + func (s *Server) jobsData(r *http.Request) (pageData, error) { data := s.base(r, "Jobs") var err error diff --git a/internal/master/web/server_test.go b/internal/master/web/server_test.go index 43b11e8..4341f5c 100644 --- a/internal/master/web/server_test.go +++ b/internal/master/web/server_test.go @@ -46,6 +46,29 @@ func (f *fakeStore) GetService(_ context.Context, id int64) (model.Service, erro return model.Service{}, model.ErrNotFound } +func (f *fakeStore) SetUpdateMode(_ context.Context, id int64, mode string) error { + f.mu.Lock() + defer f.mu.Unlock() + for i := range f.services { + if f.services[i].ID == id && f.services[i].RemovedAt == nil { + f.services[i].UpdateMode = mode + return nil + } + } + return model.ErrNotFound +} + +func (f *fakeStore) modeOf(id int64) string { + f.mu.Lock() + defer f.mu.Unlock() + for _, s := range f.services { + if s.ID == id { + return s.UpdateMode + } + } + return "" +} + func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) { for _, j := range f.jobs { if j.ID == id { @@ -99,6 +122,7 @@ func (f *fakeStore) revoked() int64 { // fakeJobs returns scripted results and records who asked for the update. type fakeJobs struct { updatable error + autoErr error job model.Job err error @@ -108,6 +132,8 @@ type fakeJobs struct { func (f *fakeJobs) Updatable(model.Service) error { return f.updatable } +func (f *fakeJobs) AutoAllowed(model.Service) error { return f.autoErr } + func (f *fakeJobs) Trigger(_ context.Context, _ int64, by string) (model.Job, error) { f.mu.Lock() defer f.mu.Unlock() @@ -504,3 +530,114 @@ func TestJob_NotFound(t *testing.T) { assert.Equal(t, http.StatusNotFound, code) } } + +func postMode(t *testing.T, srv *httptest.Server, id string, form url.Values) (*http.Response, string) { + t.Helper() + form.Set("csrf_token", "csrf") + resp, err := noRedirectClient().PostForm(srv.URL+"/services/"+id+"/mode", form) + require.NoError(t, err) + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + return resp, string(body) +} + +func TestSetMode_AutoRedirectsToServiceAndPublishes(t *testing.T) { + srv, store, _, broker, _ := newTestEnv(t) + sub, cancel := broker.Subscribe() + defer cancel() + + resp, _ := postMode(t, srv, "1", url.Values{"mode": {"auto"}}) + + assert.Equal(t, http.StatusSeeOther, resp.StatusCode) + assert.Equal(t, "/services/1", resp.Header.Get("Location")) + assert.Equal(t, "auto", store.modeOf(1)) + assert.Equal(t, events.Services, <-sub) +} + +func TestSetMode_ReturnToOverview(t *testing.T) { + srv, store, _, _, _ := newTestEnv(t) + resp, _ := postMode(t, srv, "1", url.Values{"mode": {"auto"}, "return": {"overview"}}) + assert.Equal(t, http.StatusSeeOther, resp.StatusCode) + assert.Equal(t, "/", resp.Header.Get("Location")) + assert.Equal(t, "auto", store.modeOf(1)) +} + +func TestSetMode_AutoRefusedShows409(t *testing.T) { + srv, store, _, _, jobs := newTestEnv(t) + jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt} + + resp, body := postMode(t, srv, "1", url.Values{"mode": {"auto"}}) + + assert.Equal(t, http.StatusConflict, resp.StatusCode) + assert.Contains(t, body, "Cannot enable auto: "+model.ReasonBuilt+".") + assert.Equal(t, "manual", store.modeOf(1)) +} + +func TestSetMode_ManualAlwaysAllowed(t *testing.T) { + srv, store, _, _, jobs := newTestEnv(t) + store.services[0].UpdateMode = "auto" + jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonBuilt} + + resp, _ := postMode(t, srv, "1", url.Values{"mode": {"manual"}}) + + assert.Equal(t, http.StatusSeeOther, resp.StatusCode) + assert.Equal(t, "manual", store.modeOf(1)) +} + +func TestSetMode_InvalidModeAndUnknownService(t *testing.T) { + srv, store, _, _, _ := newTestEnv(t) + store.services = append(store.services, model.Service{ID: 5, Kind: protocol.KindCompose, UpdateMode: "manual", RemovedAt: ptr(time.Now())}) + + resp, _ := postMode(t, srv, "1", url.Values{"mode": {"yolo"}}) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + resp, _ = postMode(t, srv, "1", url.Values{}) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + resp, _ = postMode(t, srv, "99", url.Values{"mode": {"auto"}}) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + resp, _ = postMode(t, srv, "99", url.Values{"mode": {"manual"}}) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + resp, _ = postMode(t, srv, "5", url.Values{"mode": {"auto"}}) + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + resp, _ = postMode(t, srv, "abc", url.Values{"mode": {"auto"}}) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, "manual", store.modeOf(1)) +} + +func TestOverview_ShowsModeToggleAndAvailableBadge(t *testing.T) { + srv, store, _, _, jobs := newTestEnv(t) + store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0" + store.services[1].UpdateMode = "auto" + jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonSelf} + + for _, path := range []string{"/", "/fragments/services"} { + _, body := get(t, srv, path) + assert.Contains(t, body, `action="/services/1/mode"`, path) + assert.Contains(t, body, `name="csrf_token" value="csrf"`, path) + assert.Contains(t, body, `name="return" value="overview"`, path) + assert.Contains(t, body, `class="badge badge-update" title="gitea.shcizo.se/shcizo/slusk:1.5.0"`, path) + assert.Contains(t, body, ``, "auto services toggle to manual: "+path) + assert.Contains(t, body, `disabled title="`+model.ReasonSelf+`"`, "blocked manual services get a disabled button: "+path) + } +} + +func TestServicePage_ShowsAvailableAndModeForm(t *testing.T) { + srv, store, _, _, _ := newTestEnv(t) + at := time.Date(2026, 10, 12, 12, 30, 0, 0, time.UTC) + store.services[0].AvailableRef = "gitea.shcizo.se/shcizo/slusk:1.5.0" + store.services[0].AvailableSource = "webhook" + store.services[0].AvailableAt = &at + + _, body := get(t, srv, "/services/1") + + assert.Contains(t, body, "gitea.shcizo.se/shcizo/slusk:1.5.0") + assert.Contains(t, body, "via webhook, 2026-10-12 12:30") + assert.Contains(t, body, `action="/services/1/mode"`) + assert.Contains(t, body, ``) +} + +func TestServicePage_AutoBlockedDisablesToggle(t *testing.T) { + srv, _, _, _, jobs := newTestEnv(t) + jobs.autoErr = &model.NotUpdatableError{Reason: model.ReasonContainer} + _, body := get(t, srv, "/services/1") + assert.Contains(t, body, "Auto unavailable: "+model.ReasonContainer+".") +} diff --git a/internal/master/web/static/app.css b/internal/master/web/static/app.css index c8d8b8c..e50510a 100644 --- a/internal/master/web/static/app.css +++ b/internal/master/web/static/app.css @@ -28,3 +28,6 @@ form.inline { display: flex; gap: 8px; margin-bottom: 16px; } 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); } +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 8ae6cc4..ed03317 100644 --- a/internal/master/web/templates/overview.html +++ b/internal/master/web/templates/overview.html @@ -14,10 +14,19 @@
{{range .Services}}{{shortRef .ActualRef}}{{.Service.ActualDigest}}{{shortRef .Service.DesiredRef}}{{.Service.AvailableRef}} via {{.Service.AvailableSource}}{{with .Service.AvailableAt}}, {{.Format "2006-01-02 15:04"}}{{end}}{{.}}Pulls the current tag again and redeploys the service.
{{end}} +