533 lines
18 KiB
Go
533 lines
18 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.ReasonContainer}
|
|
|
|
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.ReasonContainer, 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 TestAgentsOwnServiceIsStartedLast(t *testing.T) {
|
|
own := compose(1, model.ModeAuto, slusk+":1")
|
|
own.Self = true
|
|
e := newEnv(t, own, compose(2, model.ModeAuto, slusk+":1"))
|
|
e.jobs.jobFor[1] = model.Job{Status: model.JobSucceeded}
|
|
e.jobs.jobFor[2] = model.Job{Status: model.JobSucceeded}
|
|
|
|
e.post(`{"image":"` + slusk + `","tag":"2"}`)
|
|
|
|
calls := e.jobs.startCalls()
|
|
require.Len(t, calls, 2)
|
|
assert.Equal(t, int64(2), calls[0].svc.ID)
|
|
assert.Equal(t, int64(1), calls[1].svc.ID)
|
|
}
|
|
|
|
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")
|
|
}
|