feat(master): POST /update webhook with auto-mode updates (#8)

This commit is contained in:
2026-10-12 20:40:38 +02:00
parent cfc6ea6713
commit 5d95a35b3b
10 changed files with 1174 additions and 14 deletions
+32
View File
@@ -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()
@@ -155,6 +183,10 @@ 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: a job created after the suggestion deployed it.
if svc.AvailableAt != nil && !svc.AvailableAt.After(j.CreatedAt) {
svc.AvailableRef, svc.AvailableSource, svc.AvailableAt = "", "", nil
}
m.svcRows[j.ServiceID] = svc
}
m.finishApplied++
+1 -1
View File
@@ -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,
+128
View File
@@ -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")
}