fix: review fixes for webhook and auto mode (#8)
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.
This commit is contained in:
@@ -33,10 +33,17 @@ const maxBodyBytes = 64 << 10
|
||||
// no broker signal arrives (signals to a slow subscriber can be dropped).
|
||||
const defaultPollEvery = 5 * time.Second
|
||||
|
||||
// finalReadTimeout bounds the last read of pending jobs after the wait ends.
|
||||
const finalReadTimeout = 2 * time.Second
|
||||
|
||||
// tagPattern is Docker's tag grammar. It keeps spaces, colons and "@" out of
|
||||
// the ref the agent is asked to deploy.
|
||||
var tagPattern = regexp.MustCompile(`^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$`)
|
||||
|
||||
// requestIDPattern limits a caller-supplied X-Request-ID to what is safe to
|
||||
// echo in a header and write to the log.
|
||||
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)
|
||||
|
||||
// Result statuses.
|
||||
const (
|
||||
statusSucceeded = "succeeded"
|
||||
@@ -121,7 +128,7 @@ type response struct {
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
started := h.now()
|
||||
requestID := r.Header.Get("X-Request-ID")
|
||||
if requestID == "" {
|
||||
if !requestIDPattern.MatchString(requestID) {
|
||||
requestID = newRequestID()
|
||||
}
|
||||
w.Header().Set("X-Request-ID", requestID)
|
||||
@@ -175,7 +182,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
resp := response{RequestID: requestID, Image: req.Image, Tag: req.Tag, Matched: len(services)}
|
||||
pending := map[int]int64{} // index into resp.Results -> job ID
|
||||
for _, svc := range services {
|
||||
res, job := h.handle(work, svc, req.Tag, started)
|
||||
res, job := h.handle(work, svc, req.Tag, started, log)
|
||||
resp.Results = append(resp.Results, res)
|
||||
if job != nil {
|
||||
pending[len(resp.Results)-1] = job.ID
|
||||
@@ -201,7 +208,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// handle records the available ref on svc and, in auto mode, starts an update.
|
||||
// It returns the result and, if the job is still active, that job.
|
||||
func (h *Handler) handle(ctx context.Context, svc model.Service, tag string, started time.Time) (result, *model.Job) {
|
||||
func (h *Handler) handle(ctx context.Context, svc model.Service, tag string, started time.Time, log *slog.Logger) (result, *model.Job) {
|
||||
res := result{ServiceID: svc.ID, Service: svc.DisplayName(), Kind: string(svc.Kind), Mode: svc.UpdateMode}
|
||||
|
||||
// The ref is built from the service's own repo, never from the request
|
||||
@@ -212,13 +219,14 @@ func (h *Handler) handle(ctx context.Context, svc model.Service, tag string, sta
|
||||
} else {
|
||||
ref = jobs.FollowedRef(svc.ActualRef)
|
||||
}
|
||||
if ref == "" {
|
||||
return h.finish(res, statusRefused, "no tag given and the service follows no tag", started), nil
|
||||
}
|
||||
res.ToRef = ref
|
||||
if ref != "" {
|
||||
err := h.store.SetAvailable(ctx, svc.ID, model.Available{Ref: ref, Source: model.SourceWebhook, At: h.now()})
|
||||
if err != nil {
|
||||
h.log.Error("webhook_set_available_failed", "service_id", svc.ID, "error", err)
|
||||
return h.finish(res, statusFailed, "could not record available version: "+err.Error(), started), nil
|
||||
}
|
||||
err := h.store.SetAvailable(ctx, svc.ID, model.Available{Ref: ref, Source: model.SourceWebhook, At: h.now()})
|
||||
if err != nil {
|
||||
log.Error("webhook_set_available_failed", "service_id", svc.ID, "error", err)
|
||||
return h.finish(res, statusFailed, "could not record available version", started), nil
|
||||
}
|
||||
if svc.UpdateMode != model.ModeAuto {
|
||||
return h.finish(res, statusMarked, "", started), nil
|
||||
@@ -233,8 +241,8 @@ func (h *Handler) handle(ctx context.Context, svc model.Service, tag string, sta
|
||||
h.setJob(&res, job)
|
||||
return h.finish(res, statusBusy, err.Error(), started), nil
|
||||
case err != nil:
|
||||
h.log.Error("webhook_start_failed", "service_id", svc.ID, "error", err)
|
||||
return h.finish(res, statusFailed, err.Error(), started), nil
|
||||
log.Error("webhook_start_failed", "service_id", svc.ID, "error", err)
|
||||
return h.finish(res, statusFailed, "could not start job", started), nil
|
||||
}
|
||||
h.setJob(&res, job)
|
||||
if job.Active() {
|
||||
@@ -280,6 +288,11 @@ func (h *Handler) wait(parent context.Context, sub <-chan events.Topic, resp *re
|
||||
for len(pending) > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// A job may have finished while the timer fired: look once more,
|
||||
// on a context that outlives the request.
|
||||
final, stop := context.WithTimeout(context.WithoutCancel(parent), finalReadTimeout)
|
||||
h.readPending(final, resp, pending, started, log)
|
||||
stop()
|
||||
for i := range pending {
|
||||
resp.Results[i] = h.finish(resp.Results[i], statusTimeout, "still running when the request timed out", started)
|
||||
}
|
||||
@@ -290,16 +303,22 @@ func (h *Handler) wait(parent context.Context, sub <-chan events.Topic, resp *re
|
||||
}
|
||||
case <-ticker.C:
|
||||
}
|
||||
for i, id := range pending {
|
||||
job, err := h.store.GetJob(ctx, id)
|
||||
if err != nil {
|
||||
log.Warn("webhook_get_job_failed", "job_id", id, "error", err)
|
||||
continue
|
||||
}
|
||||
if !job.Active() {
|
||||
h.resolveJob(&resp.Results[i], job, started)
|
||||
delete(pending, i)
|
||||
}
|
||||
h.readPending(ctx, resp, pending, started, log)
|
||||
}
|
||||
}
|
||||
|
||||
// readPending re-reads every pending job and resolves the ones that are done.
|
||||
func (h *Handler) readPending(ctx context.Context, resp *response, pending map[int]int64,
|
||||
started time.Time, log *slog.Logger) {
|
||||
for i, id := range pending {
|
||||
job, err := h.store.GetJob(ctx, id)
|
||||
if err != nil {
|
||||
log.Warn("webhook_get_job_failed", "job_id", id, "error", err)
|
||||
continue
|
||||
}
|
||||
if !job.Active() {
|
||||
h.resolveJob(&resp.Results[i], job, started)
|
||||
delete(pending, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -289,10 +289,14 @@ func TestAvailableRefWithoutTag(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)
|
||||
})
|
||||
@@ -302,18 +306,56 @@ func TestAvailableRefWithoutTag(t *testing.T) {
|
||||
func TestAutoDigestOnlyServiceWithoutTagIsRefused(t *testing.T) {
|
||||
svc := compose(1, model.ModeAuto, slusk+"@sha256:old")
|
||||
e := newEnv(t, svc)
|
||||
e.jobs.errFor[1] = &model.NotUpdatableError{Reason: model.ReasonNoTag}
|
||||
|
||||
rec := e.post(`{"image":"` + slusk + `"}`)
|
||||
|
||||
assert.Equal(t, 200, rec.Code)
|
||||
assert.Empty(t, e.store.avail)
|
||||
calls := e.jobs.startCalls()
|
||||
require.Len(t, calls, 1)
|
||||
assert.Empty(t, calls[0].req.Ref, "no request tag and nothing followed: the job layer decides")
|
||||
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, model.ReasonNoTag, r.Error)
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user