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:
@@ -11,6 +11,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -65,6 +66,33 @@ func (m *memStore) GetService(_ context.Context, id int64) (model.Service, error
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
// ServicesByRepo returns the seeded services whose image repo equals repo,
|
||||
// ordered by ID.
|
||||
func (m *memStore) ServicesByRepo(_ context.Context, repo string) ([]model.Service, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []model.Service
|
||||
for _, svc := range m.svcRows {
|
||||
if svc.ImageRepo == repo && svc.RemovedAt == nil {
|
||||
out = append(out, svc)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (m *memStore) SetAvailable(_ context.Context, id int64, a model.Available) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
svc, ok := m.svcRows[id]
|
||||
if !ok {
|
||||
return model.ErrNotFound
|
||||
}
|
||||
svc.AvailableRef, svc.AvailableSource, svc.AvailableAt = a.Ref, a.Source, &a.At
|
||||
m.svcRows[id] = svc
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *memStore) CreateJob(_ context.Context, nj model.NewJob) (model.Job, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
@@ -146,6 +174,7 @@ func (m *memStore) FinishJob(_ context.Context, agentID, jobID int64, f model.Jo
|
||||
return false, nil
|
||||
}
|
||||
j.Status, j.Error, j.Output, j.OutputTruncated, j.FinishedAt = f.Status, f.Error, f.Output, f.OutputTruncated, &f.FinishedAt
|
||||
requestedRef := j.ToRef
|
||||
if f.Ref != "" {
|
||||
j.ToRef = f.Ref
|
||||
}
|
||||
@@ -155,6 +184,11 @@ func (m *memStore) FinishJob(_ context.Context, agentID, jobID int64, f model.Jo
|
||||
if f.Status == model.JobSucceeded {
|
||||
svc := m.svcRows[j.ServiceID]
|
||||
svc.DesiredRef = f.Ref
|
||||
// Same rule as the SQL: the job was created for the suggested ref after it was reported.
|
||||
requested, _, _ := strings.Cut(requestedRef, "@")
|
||||
if svc.AvailableAt != nil && svc.AvailableRef == requested && !svc.AvailableAt.After(j.CreatedAt) {
|
||||
svc.AvailableRef, svc.AvailableSource, svc.AvailableAt = "", "", nil
|
||||
}
|
||||
m.svcRows[j.ServiceID] = svc
|
||||
}
|
||||
m.finishApplied++
|
||||
|
||||
@@ -102,7 +102,7 @@ func newJobEnv(t *testing.T) *jobEnv {
|
||||
t.Helper()
|
||||
store := &memStore{agents: map[string]model.Agent{agenttoken.Hash("pua_ok"): {ID: agentID, Name: "node-2"}}}
|
||||
store.seedService(model.Service{ID: 1, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web",
|
||||
AgentID: ptr(agentID), ActualRef: slusk + ":1.4.2@sha256:old"})
|
||||
AgentID: ptr(agentID), ImageRepo: slusk, ActualRef: slusk + ":1.4.2@sha256:old"})
|
||||
m := startMaster(t, store)
|
||||
exec := newGateExecutor()
|
||||
env := &jobEnv{store: store, m: m, exec: exec,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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/webhook"
|
||||
)
|
||||
|
||||
// webhookServer mounts the webhook handler on a test server, wired to the
|
||||
// job environment's store, jobs service and broker.
|
||||
func (e *jobEnv) webhookServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
h := webhook.New(e.store, e.jobs, e.m.broker, webhook.Config{
|
||||
Token: "ci-key", Timeout: 5 * time.Second, PublicURL: "https://updater.example.com",
|
||||
}, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
srv := httptest.NewServer(h)
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func (e *jobEnv) setAuto(id int64) {
|
||||
e.store.mu.Lock()
|
||||
defer e.store.mu.Unlock()
|
||||
svc := e.store.svcRows[id]
|
||||
svc.UpdateMode = model.ModeAuto
|
||||
e.store.svcRows[id] = svc
|
||||
}
|
||||
|
||||
type webhookResponse struct {
|
||||
Matched int `json:"matched"`
|
||||
Results []struct {
|
||||
Status string `json:"status"`
|
||||
JobID int64 `json:"job_id"`
|
||||
ToRef string `json:"to_ref"`
|
||||
Error string `json:"error"`
|
||||
} `json:"results"`
|
||||
}
|
||||
|
||||
func callWebhook(t *testing.T, srv *httptest.Server, body string) (int, webhookResponse) {
|
||||
t.Helper()
|
||||
req, err := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader(body))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer ci-key")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
var out webhookResponse
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&out))
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
|
||||
func TestWebhookWaitsForAutoJob(t *testing.T) {
|
||||
e := newJobEnv(t)
|
||||
e.setAuto(1)
|
||||
srv := e.webhookServer(t)
|
||||
|
||||
type reply struct {
|
||||
code int
|
||||
body webhookResponse
|
||||
}
|
||||
done := make(chan reply, 1)
|
||||
go func() {
|
||||
code, body := callWebhook(t, srv, `{"image":"`+slusk+`:ignored","tag":"2.0.0"}`)
|
||||
done <- reply{code, body}
|
||||
}()
|
||||
|
||||
e.waitStarted(t)
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("the webhook answered before the job finished")
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
close(e.exec.release)
|
||||
|
||||
var r reply
|
||||
select {
|
||||
case r = <-done:
|
||||
case <-time.After(waitFor):
|
||||
t.Fatal("the webhook never answered")
|
||||
}
|
||||
require.Equal(t, http.StatusOK, r.code)
|
||||
require.Len(t, r.body.Results, 1)
|
||||
assert.Equal(t, "succeeded", r.body.Results[0].Status)
|
||||
assert.Equal(t, slusk+":2.0.0", r.body.Results[0].ToRef)
|
||||
|
||||
job := e.job(t, r.body.Results[0].JobID)
|
||||
assert.Equal(t, model.TriggerWebhook, job.Trigger)
|
||||
assert.Equal(t, model.JobSucceeded, job.Status)
|
||||
assert.Equal(t, slusk+":2.0.0@sha256:e2e", job.ToRef)
|
||||
svc, err := e.store.GetService(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, svc.AvailableRef, "the job deployed the suggestion, so it is cleared")
|
||||
}
|
||||
|
||||
func TestWebhookFailedAutoJobIs500(t *testing.T) {
|
||||
e := newJobEnv(t)
|
||||
e.setAuto(1)
|
||||
srv := e.webhookServer(t)
|
||||
e.agent.stopConn() // no agent connection: the job fails at once with "agent offline"
|
||||
require.Eventually(t, func() bool {
|
||||
svc, _ := e.store.GetService(context.Background(), 1)
|
||||
_, err := e.m.hub.Route(svc)
|
||||
return err != nil
|
||||
}, waitFor, pollEvery)
|
||||
|
||||
code, body := callWebhook(t, srv, `{"image":"`+slusk+`","tag":"2.0.0"}`)
|
||||
|
||||
assert.Equal(t, http.StatusInternalServerError, code)
|
||||
require.Len(t, body.Results, 1)
|
||||
assert.Equal(t, "failed", body.Results[0].Status)
|
||||
assert.Equal(t, "agent offline", body.Results[0].Error)
|
||||
assert.Equal(t, model.JobFailed, e.job(t, body.Results[0].JobID).Status)
|
||||
svc, err := e.store.GetService(context.Background(), 1)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, slusk+":2.0.0", svc.AvailableRef, "a failed job keeps the suggestion")
|
||||
}
|
||||
Reference in New Issue
Block a user