feat: master/agent phase 3 — webhook and auto mode (#8) (#22)
Docker Build / docker (push) Successful in 38s
Docker Build / docker (push) Successful in 38s
## Summary
- `POST /update` on the master (outside OIDC, bearer `UPDATER_API_KEY`; unset → 503). Matches `image` against `image_repo` on all agents, marks `available_*` on every match, starts webhook-triggered jobs for `auto` services targeting `<image_repo>:<tag>` (followed tag when no tag is given), and waits for them up to `UPDATE_TIMEOUT` (default 5m). 404 on no match, 500 if any auto job failed or timed out, otherwise 200.
- Manual/auto toggle per service on the overview and service page (`POST /services/{id}/mode`, CSRF); auto is blocked for self, `build:` and container services with the reason shown. "Update available" badge and an "Available" row.
- `FinishJob` clears `available_*` only when the successful job was created for that exact ref, after it was reported. Migration `0003_webhook.sql`, phase-3 plan doc, README / gitea-action README / CLAUDE.md / deploy example updated. `action.yml` unchanged.
Closes #8
## Behaviour changes for CI
- Mixed results now return 500 (the old 207 is gone); no match returns 404 instead of 200.
- With a `tag`, Compose services get pinned to `<image_repo>:<tag>` via `compose.override.yaml`; the tag must be a pushed tag.
- `busy` (job already active) and `refused` (not updatable) are reported but return 200.
- The reverse proxy read timeout must exceed `UPDATE_TIMEOUT`.
## Test plan
- [x] `go test ./...` and `go test -race ./internal/agent/... ./internal/master/... ./internal/e2e/`
- [x] `go test -tags integration ./internal/master/store/` against throwaway Postgres
- [x] UI verified in browser (mode toggle, disabled reason, badge via SSE, Available row)
- [ ] Manual: webhook with manual service → `marked` + badge
- [ ] Manual: webhook with auto service → waits, `succeeded`, badge cleared, override pinned
- [ ] Manual: wrong token 401, unknown image 404, offline agent 500
- [ ] Gitea action pointed at the master's `/update` in a real CI run
Reviewed-on: #22
This commit was merged in pull request #22.
This commit is contained in:
@@ -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, `<input type="hidden" name="mode" value="manual">`, "auto services toggle to manual: "+path)
|
||||
assert.Contains(t, body, `disabled>Make auto</button> <span class="muted">`+model.ReasonSelf+`</span>`, "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, `<input type="hidden" name="mode" value="auto">`)
|
||||
}
|
||||
|
||||
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+".")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user