Clear available_* only when the deployed ref equals it, drop the unused poll source, stop leaking internal errors to CI, use the request-scoped logger, validate X-Request-ID, re-read jobs once after the timeout, report refused when no target ref exists, and tidy UI, deploy and docs.
518 lines
17 KiB
Go
518 lines
17 KiB
Go
package webhook
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/shcizo/package-updater/internal/master/events"
|
|
"github.com/shcizo/package-updater/internal/master/jobs"
|
|
"github.com/shcizo/package-updater/internal/master/model"
|
|
"github.com/shcizo/package-updater/internal/protocol"
|
|
)
|
|
|
|
const token = "s3cret"
|
|
|
|
type availCall struct {
|
|
id int64
|
|
a model.Available
|
|
}
|
|
|
|
type fakeStore struct {
|
|
mu sync.Mutex
|
|
services []model.Service // returned for any repo that equals their ImageRepo
|
|
jobs map[int64]model.Job
|
|
avail []availCall
|
|
repos []string
|
|
}
|
|
|
|
func (f *fakeStore) ServicesByRepo(_ context.Context, repo string) ([]model.Service, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.repos = append(f.repos, repo)
|
|
var out []model.Service
|
|
for _, s := range f.services {
|
|
if s.ImageRepo == repo {
|
|
out = append(out, s)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (f *fakeStore) SetAvailable(_ context.Context, id int64, a model.Available) error {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.avail = append(f.avail, availCall{id, a})
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeStore) GetJob(_ context.Context, id int64) (model.Job, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return f.jobs[id], nil
|
|
}
|
|
|
|
func (f *fakeStore) setJob(j model.Job) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.jobs[j.ID] = j
|
|
}
|
|
|
|
type startCall struct {
|
|
svc model.Service
|
|
req jobs.StartRequest
|
|
ctx context.Context
|
|
}
|
|
|
|
// fakeJobs hands out job IDs and applies per-service behaviour.
|
|
type fakeJobs struct {
|
|
mu sync.Mutex
|
|
store *fakeStore
|
|
calls []startCall
|
|
errFor map[int64]error // returned together with errJob[id] if set
|
|
jobFor map[int64]model.Job // status of the job returned by Start
|
|
next int64
|
|
}
|
|
|
|
func (f *fakeJobs) Start(ctx context.Context, svc model.Service, req jobs.StartRequest) (model.Job, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
f.calls = append(f.calls, startCall{svc, req, ctx})
|
|
if err, ok := f.errFor[svc.ID]; ok {
|
|
return f.jobFor[svc.ID], err
|
|
}
|
|
f.next++
|
|
j := model.Job{ID: f.next, ServiceID: svc.ID, Status: model.JobQueued, Trigger: req.Trigger, ToRef: req.Ref}
|
|
if o, ok := f.jobFor[svc.ID]; ok {
|
|
o.ID = j.ID
|
|
j = o
|
|
}
|
|
f.store.mu.Lock()
|
|
f.store.jobs[j.ID] = j
|
|
f.store.mu.Unlock()
|
|
return j, nil
|
|
}
|
|
|
|
func (f *fakeJobs) startCalls() []startCall {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
return append([]startCall(nil), f.calls...)
|
|
}
|
|
|
|
type env struct {
|
|
store *fakeStore
|
|
jobs *fakeJobs
|
|
broker *events.Broker
|
|
h *Handler
|
|
}
|
|
|
|
func newEnv(t *testing.T, services ...model.Service) *env {
|
|
t.Helper()
|
|
st := &fakeStore{services: services, jobs: map[int64]model.Job{}}
|
|
fj := &fakeJobs{store: st, errFor: map[int64]error{}, jobFor: map[int64]model.Job{}}
|
|
b := events.NewBroker()
|
|
h := New(st, fj, b, Config{Token: token, Timeout: 2 * time.Second, PublicURL: "https://updater.example.com"},
|
|
slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
h.now = func() time.Time { return time.Date(2026, 10, 12, 12, 0, 0, 0, time.UTC) }
|
|
return &env{store: st, jobs: fj, broker: b, h: h}
|
|
}
|
|
|
|
func (e *env) post(body string, headers ...string) *httptest.ResponseRecorder {
|
|
return e.postCtx(context.Background(), body, headers...)
|
|
}
|
|
|
|
func (e *env) postCtx(ctx context.Context, body string, headers ...string) *httptest.ResponseRecorder {
|
|
req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(body)).WithContext(ctx)
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
for i := 0; i+1 < len(headers); i += 2 {
|
|
req.Header.Set(headers[i], headers[i+1])
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
return rec
|
|
}
|
|
|
|
func decode(t *testing.T, rec *httptest.ResponseRecorder) response {
|
|
t.Helper()
|
|
var r response
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &r), rec.Body.String())
|
|
return r
|
|
}
|
|
|
|
func compose(id int64, mode, actualRef string) model.Service {
|
|
return model.Service{ID: id, Kind: protocol.KindCompose, ComposeProject: "slusk", ComposeService: "web" + string(rune('a'+id)),
|
|
ImageRepo: "gitea.shcizo.se/shcizo/slusk", ActualRef: actualRef, UpdateMode: mode}
|
|
}
|
|
|
|
const slusk = "gitea.shcizo.se/shcizo/slusk"
|
|
|
|
func TestAuth(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
serverKey string
|
|
header string
|
|
wantStatus int
|
|
wantBody string
|
|
}{
|
|
{"missing header", token, "", 401, `{"error":"unauthorized"}`},
|
|
{"wrong scheme", token, "Basic " + token, 401, `{"error":"unauthorized"}`},
|
|
{"wrong token", token, "Bearer nope", 401, `{"error":"unauthorized"}`},
|
|
{"empty bearer", token, "Bearer ", 401, `{"error":"unauthorized"}`},
|
|
{"key unset", "", "Bearer " + token, 503, `{"error":"webhook disabled: UPDATER_API_KEY not set"}`},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
|
e.h.cfg.Token = tt.serverKey
|
|
req := httptest.NewRequest(http.MethodPost, "/update", strings.NewReader(`{"image":"`+slusk+`"}`))
|
|
if tt.header != "" {
|
|
req.Header.Set("Authorization", tt.header)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
e.h.ServeHTTP(rec, req)
|
|
assert.Equal(t, tt.wantStatus, rec.Code)
|
|
assert.JSONEq(t, tt.wantBody, rec.Body.String())
|
|
assert.Empty(t, e.store.repos, "nothing may be looked up before auth")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestBadRequest(t *testing.T) {
|
|
tests := []struct {
|
|
name, body, wantError string
|
|
}{
|
|
{"invalid JSON", `{`, "invalid JSON body"},
|
|
{"empty body", ``, "invalid JSON body"},
|
|
{"empty image", `{"image":""}`, "image is required"},
|
|
{"missing image", `{"tag":"v1"}`, "image is required"},
|
|
{"oversized", `{"image":"` + strings.Repeat("a", 70<<10) + `"}`, "invalid JSON body"},
|
|
{"tag with digest", `{"image":"` + slusk + `","tag":"v1@sha256:abc"}`, "invalid tag"},
|
|
{"tag with space", `{"image":"` + slusk + `","tag":"v 1"}`, "invalid tag"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1"))
|
|
rec := e.post(tt.body)
|
|
assert.Equal(t, 400, rec.Code)
|
|
assert.JSONEq(t, `{"error":"`+tt.wantError+`"}`, rec.Body.String())
|
|
assert.Empty(t, e.jobs.startCalls())
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNoMatch(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1"))
|
|
for _, image := range []string{"gitea.shcizo.se/shcizo/other", "gitea.shcizo.se/shcizo/Slusk"} {
|
|
rec := e.post(`{"image":"` + image + `"}`)
|
|
assert.Equal(t, 404, rec.Code, image)
|
|
assert.Contains(t, rec.Body.String(), "no service uses image "+image)
|
|
}
|
|
assert.Empty(t, e.jobs.startCalls())
|
|
assert.Empty(t, e.store.avail)
|
|
}
|
|
|
|
func TestImageIsNormalisedBeforeMatching(t *testing.T) {
|
|
svc := compose(1, model.ModeManual, slusk+":1")
|
|
local := compose(2, model.ModeManual, "localhost:5000/foo:v1")
|
|
local.ImageRepo = "localhost:5000/foo"
|
|
for _, tt := range []struct{ image, wantRepo string }{
|
|
{slusk, slusk},
|
|
{slusk + ":2.0", slusk},
|
|
{slusk + "@sha256:abc", slusk},
|
|
{slusk + ":2.0@sha256:abc", slusk},
|
|
{"localhost:5000/foo:v1", "localhost:5000/foo"},
|
|
} {
|
|
e := newEnv(t, svc, local)
|
|
rec := e.post(`{"image":"` + tt.image + `"}`)
|
|
assert.Equal(t, 200, rec.Code, tt.image)
|
|
assert.Equal(t, []string{tt.wantRepo}, e.store.repos, tt.image)
|
|
assert.Equal(t, 1, decode(t, rec).Matched, tt.image)
|
|
}
|
|
}
|
|
|
|
func TestManualServiceIsMarkedNotUpdated(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1.4.2@sha256:old"))
|
|
sub, cancel := e.broker.Subscribe()
|
|
defer cancel()
|
|
|
|
rec := e.post(`{"image":"` + slusk + `:ignored-in-ref","tag":"1.5.0"}`)
|
|
|
|
assert.Equal(t, 200, rec.Code)
|
|
assert.Empty(t, e.jobs.startCalls())
|
|
require.Len(t, e.store.avail, 1)
|
|
assert.Equal(t, int64(1), e.store.avail[0].id)
|
|
assert.Equal(t, model.Available{Ref: slusk + ":1.5.0", Source: model.SourceWebhook, At: e.h.now()}, e.store.avail[0].a)
|
|
resp := decode(t, rec)
|
|
assert.Equal(t, 1, resp.Matched)
|
|
require.Len(t, resp.Results, 1)
|
|
r := resp.Results[0]
|
|
assert.Equal(t, statusMarked, r.Status)
|
|
assert.Equal(t, "slusk/webb", r.Service)
|
|
assert.Equal(t, "compose", r.Kind)
|
|
assert.Equal(t, "manual", r.Mode)
|
|
assert.Equal(t, slusk+":1.5.0", r.ToRef)
|
|
assert.Zero(t, r.JobID)
|
|
assert.NotEmpty(t, resp.RequestID)
|
|
assert.Equal(t, resp.RequestID, rec.Header().Get("X-Request-ID"))
|
|
assert.Equal(t, events.Services, <-sub)
|
|
}
|
|
|
|
func TestRequestIDIsEchoed(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
|
rec := e.post(`{"image":"`+slusk+`"}`, "X-Request-ID", "ci-123")
|
|
assert.Equal(t, "ci-123", decode(t, rec).RequestID)
|
|
assert.Equal(t, "ci-123", rec.Header().Get("X-Request-ID"))
|
|
}
|
|
|
|
func TestAvailableRefWithoutTag(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
actualRef string
|
|
wantRef string // "" means SetAvailable must not be called
|
|
}{
|
|
{"followed tag", slusk + ":1.4.2@sha256:old", slusk + ":1.4.2"},
|
|
{"bare image follows latest", slusk, slusk + ":latest"},
|
|
{"digest only", slusk + "@sha256:old", ""},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeManual, tt.actualRef))
|
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
|
assert.Equal(t, 200, rec.Code)
|
|
r := decode(t, rec).Results[0]
|
|
if tt.wantRef == "" {
|
|
assert.Empty(t, e.store.avail)
|
|
assert.Equal(t, statusRefused, r.Status)
|
|
assert.Equal(t, "no tag given and the service follows no tag", r.Error)
|
|
return
|
|
}
|
|
assert.Equal(t, statusMarked, r.Status)
|
|
require.Len(t, e.store.avail, 1)
|
|
assert.Equal(t, tt.wantRef, e.store.avail[0].a.Ref)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAutoDigestOnlyServiceWithoutTagIsRefused(t *testing.T) {
|
|
svc := compose(1, model.ModeAuto, slusk+"@sha256:old")
|
|
e := newEnv(t, svc)
|
|
|
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
|
|
|
assert.Equal(t, 200, rec.Code)
|
|
assert.Empty(t, e.store.avail)
|
|
assert.Empty(t, e.jobs.startCalls(), "no target ref: nothing to start")
|
|
r := decode(t, rec).Results[0]
|
|
assert.Equal(t, statusRefused, r.Status)
|
|
assert.Equal(t, "no tag given and the service follows no tag", r.Error)
|
|
}
|
|
|
|
func TestValidTags(t *testing.T) {
|
|
for _, tag := range []string{"latest", "1.2.3-rc.1", "v1_2", strings.Repeat("a", 128)} {
|
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
|
rec := e.post(`{"image":"` + slusk + `","tag":"` + tag + `"}`)
|
|
assert.Equal(t, 200, rec.Code, tag)
|
|
require.Len(t, e.store.avail, 1, tag)
|
|
assert.Equal(t, slusk+":"+tag, e.store.avail[0].a.Ref)
|
|
}
|
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
|
rec := e.post(`{"image":"` + slusk + `","tag":"` + strings.Repeat("a", 129) + `"}`)
|
|
assert.Equal(t, 400, rec.Code)
|
|
}
|
|
|
|
func TestUnsafeRequestIDIsReplaced(t *testing.T) {
|
|
for _, id := range []string{"has space", "bad/slash", strings.Repeat("a", 129)} {
|
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
|
rec := e.post(`{"image":"`+slusk+`"}`, "X-Request-ID", id)
|
|
got := rec.Header().Get("X-Request-ID")
|
|
assert.NotEqual(t, id, got)
|
|
assert.Regexp(t, `^[0-9a-f]{32}$`, got)
|
|
}
|
|
e := newEnv(t, compose(1, model.ModeManual, slusk+":1"))
|
|
rec := e.post(`{"image":"`+slusk+`"}`, "X-Request-ID", strings.Repeat("a", 128))
|
|
assert.Equal(t, strings.Repeat("a", 128), rec.Header().Get("X-Request-ID"))
|
|
}
|
|
|
|
func TestJobFinishedJustBeforeTimeoutIsNotReportedAsTimeout(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
|
e.h.cfg.Timeout = 200 * time.Millisecond
|
|
e.h.pollEvery = time.Hour // only the final read after the deadline can see the result
|
|
go func() {
|
|
assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond)
|
|
e.store.setJob(model.Job{ID: 1, Status: model.JobSucceeded}) // no Publish
|
|
}()
|
|
|
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
|
|
|
assert.Equal(t, 200, rec.Code)
|
|
assert.Equal(t, statusSucceeded, decode(t, rec).Results[0].Status)
|
|
}
|
|
|
|
func TestAutoJobTargetIsBuiltFromServiceRepo(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2@sha256:old"))
|
|
e.jobs.jobFor[1] = model.Job{Status: model.JobSucceeded}
|
|
|
|
rec := e.post(`{"image":"` + slusk + `:other","tag":"2.0.0"}`)
|
|
|
|
assert.Equal(t, 200, rec.Code)
|
|
calls := e.jobs.startCalls()
|
|
require.Len(t, calls, 1)
|
|
assert.Equal(t, slusk+":2.0.0", calls[0].req.Ref)
|
|
assert.Equal(t, model.TriggerWebhook, calls[0].req.Trigger)
|
|
assert.Empty(t, calls[0].req.RequestedBy)
|
|
require.Len(t, e.store.avail, 1)
|
|
assert.Equal(t, slusk+":2.0.0", e.store.avail[0].a.Ref)
|
|
}
|
|
|
|
func TestAutoJobSucceeded(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
|
|
|
go func() {
|
|
assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond)
|
|
e.store.setJob(model.Job{ID: 1, Status: model.JobSucceeded})
|
|
e.broker.Publish(events.JobTopic(1)) // progress topic is ignored
|
|
e.broker.Publish(events.Jobs)
|
|
}()
|
|
rec := e.post(`{"image":"` + slusk + `","tag":"1.5.0"}`)
|
|
|
|
assert.Equal(t, 200, rec.Code)
|
|
r := decode(t, rec).Results[0]
|
|
assert.Equal(t, statusSucceeded, r.Status)
|
|
assert.Equal(t, int64(1), r.JobID)
|
|
assert.Equal(t, "https://updater.example.com/jobs/1", r.JobURL)
|
|
assert.Equal(t, "auto", r.Mode)
|
|
}
|
|
|
|
func TestAutoJobFailedIs500(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
|
go func() {
|
|
assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond)
|
|
e.store.setJob(model.Job{ID: 1, Status: model.JobFailed, Error: "pull access denied"})
|
|
e.broker.Publish(events.Jobs)
|
|
}()
|
|
|
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
|
|
|
assert.Equal(t, 500, rec.Code)
|
|
r := decode(t, rec).Results[0]
|
|
assert.Equal(t, statusFailed, r.Status)
|
|
assert.Equal(t, "pull access denied", r.Error)
|
|
}
|
|
|
|
func TestJobFailedAtStartIsNotWaitedFor(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
|
e.jobs.jobFor[1] = model.Job{Status: model.JobFailed, Error: "agent offline"}
|
|
e.h.cfg.Timeout = time.Minute
|
|
|
|
begin := time.Now()
|
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
|
|
|
assert.Less(t, time.Since(begin), 5*time.Second)
|
|
assert.Equal(t, 500, rec.Code)
|
|
r := decode(t, rec).Results[0]
|
|
assert.Equal(t, statusFailed, r.Status)
|
|
assert.Equal(t, "agent offline", r.Error)
|
|
assert.NotZero(t, r.JobID)
|
|
}
|
|
|
|
func TestTimeout(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
|
e.h.cfg.Timeout = 50 * time.Millisecond
|
|
|
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
|
|
|
assert.Equal(t, 500, rec.Code)
|
|
r := decode(t, rec).Results[0]
|
|
assert.Equal(t, statusTimeout, r.Status)
|
|
assert.Equal(t, int64(1), r.JobID)
|
|
assert.Equal(t, model.JobQueued, e.store.jobs[1].Status, "the master must not touch a timed-out job")
|
|
}
|
|
|
|
func TestDroppedSignalIsRecoveredByPolling(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
|
e.h.pollEvery = 5 * time.Millisecond
|
|
go func() {
|
|
assert.Eventually(t, func() bool { return len(e.jobs.startCalls()) == 1 }, time.Second, time.Millisecond)
|
|
e.store.setJob(model.Job{ID: 1, Status: model.JobSucceeded}) // no Publish
|
|
}()
|
|
|
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
|
|
|
assert.Equal(t, 200, rec.Code)
|
|
assert.Equal(t, statusSucceeded, decode(t, rec).Results[0].Status)
|
|
}
|
|
|
|
func TestBusyIsNotAFailure(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
|
e.jobs.errFor[1] = model.ErrJobActive
|
|
e.jobs.jobFor[1] = model.Job{ID: 42, Status: model.JobRunning}
|
|
e.h.cfg.Timeout = time.Minute
|
|
|
|
rec := e.post(`{"image":"` + slusk + `"}`)
|
|
|
|
assert.Equal(t, 200, rec.Code)
|
|
r := decode(t, rec).Results[0]
|
|
assert.Equal(t, statusBusy, r.Status)
|
|
assert.Equal(t, int64(42), r.JobID)
|
|
assert.Equal(t, "https://updater.example.com/jobs/42", r.JobURL)
|
|
assert.NotEmpty(t, e.store.avail, "the available ref is still recorded")
|
|
}
|
|
|
|
func TestRefusedIsNotAFailure(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
|
e.jobs.errFor[1] = &model.NotUpdatableError{Reason: model.ReasonSelf}
|
|
|
|
rec := e.post(`{"image":"` + slusk + `","tag":"2"}`)
|
|
|
|
assert.Equal(t, 200, rec.Code)
|
|
r := decode(t, rec).Results[0]
|
|
assert.Equal(t, statusRefused, r.Status)
|
|
assert.Equal(t, model.ReasonSelf, r.Error)
|
|
assert.Zero(t, r.JobID)
|
|
assert.NotEmpty(t, e.store.avail)
|
|
}
|
|
|
|
func TestMixedAutoAndManual(t *testing.T) {
|
|
e := newEnv(t,
|
|
compose(1, model.ModeManual, slusk+":1"),
|
|
compose(2, model.ModeAuto, slusk+":1"),
|
|
compose(3, model.ModeAuto, slusk+":1"))
|
|
e.jobs.jobFor[2] = model.Job{Status: model.JobSucceeded}
|
|
e.jobs.jobFor[3] = model.Job{Status: model.JobFailed, Error: "boom"}
|
|
|
|
rec := e.post(`{"image":"` + slusk + `","tag":"2"}`)
|
|
|
|
assert.Equal(t, 500, rec.Code)
|
|
resp := decode(t, rec)
|
|
assert.Equal(t, 3, resp.Matched)
|
|
got := []string{resp.Results[0].Status, resp.Results[1].Status, resp.Results[2].Status}
|
|
assert.Equal(t, []string{statusMarked, statusSucceeded, statusFailed}, got)
|
|
assert.Len(t, e.store.avail, 3)
|
|
}
|
|
|
|
func TestClientCancelStopsWaitingButNotStarting(t *testing.T) {
|
|
e := newEnv(t, compose(1, model.ModeAuto, slusk+":1.4.2"))
|
|
e.h.cfg.Timeout = time.Minute
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel() // the client is already gone when the handler runs
|
|
|
|
begin := time.Now()
|
|
rec := e.postCtx(ctx, `{"image":"`+slusk+`"}`)
|
|
|
|
assert.Less(t, time.Since(begin), 5*time.Second)
|
|
assert.Equal(t, statusTimeout, decode(t, rec).Results[0].Status)
|
|
calls := e.jobs.startCalls()
|
|
require.Len(t, calls, 1, "the job must still be started")
|
|
assert.NoError(t, calls[0].ctx.Err(), "Start must run on a context the client cannot cancel")
|
|
}
|