feat(master): POST /update webhook with auto-mode updates (#8)
This commit is contained in:
@@ -68,9 +68,34 @@ func target(actualRef string) (repo, tag string, ok bool) {
|
||||
return ref.Repo, "", false
|
||||
}
|
||||
|
||||
// FollowedRef returns "repo:tag" for the tag a service with this actual ref
|
||||
// follows (a bare image follows "latest"), or "" when it follows no tag.
|
||||
func FollowedRef(actualRef string) string {
|
||||
repo, tag, ok := target(actualRef)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return repo + ":" + tag
|
||||
}
|
||||
|
||||
// Updatable returns nil or a *model.NotUpdatableError. It looks only at the
|
||||
// service row, so the UI can call it for every page render.
|
||||
// service row, so the UI can call it for every page render. It answers for an
|
||||
// update to the service's own followed tag (the Update button).
|
||||
func (s *Service) Updatable(svc model.Service) error {
|
||||
return s.updatable(svc, false)
|
||||
}
|
||||
|
||||
// AutoAllowed returns nil or a *model.NotUpdatableError saying why the
|
||||
// service can never be updated automatically. Unlike Updatable it does not
|
||||
// refuse a service that follows no tag: a webhook that names a tag can still
|
||||
// update it.
|
||||
func (s *Service) AutoAllowed(svc model.Service) error {
|
||||
return s.updatable(svc, true)
|
||||
}
|
||||
|
||||
// updatable checks the service row. explicitRef means the caller supplies the
|
||||
// target ref, so a service that follows no tag is fine.
|
||||
func (s *Service) updatable(svc model.Service, explicitRef bool) error {
|
||||
switch {
|
||||
case svc.RemovedAt != nil:
|
||||
return &model.NotUpdatableError{Reason: model.ReasonRemoved}
|
||||
@@ -81,32 +106,49 @@ func (s *Service) Updatable(svc model.Service) error {
|
||||
case svc.Kind == protocol.KindContainer:
|
||||
return &model.NotUpdatableError{Reason: model.ReasonContainer}
|
||||
}
|
||||
if _, _, ok := target(svc.ActualRef); !ok {
|
||||
if _, _, ok := target(svc.ActualRef); !ok && !explicitRef {
|
||||
return &model.NotUpdatableError{Reason: model.ReasonNoTag}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartRequest says who asks for an update and what to deploy.
|
||||
type StartRequest struct {
|
||||
Trigger model.JobTrigger
|
||||
RequestedBy string // OIDC subject for manual jobs; "" otherwise
|
||||
// Ref is the ref to deploy ("repo:tag"). Empty means the service's
|
||||
// followed tag, derived from its actual ref.
|
||||
Ref string
|
||||
}
|
||||
|
||||
// Trigger starts a manual update of the service to its current tag. It
|
||||
// returns the job, which may already be failed ("agent offline", "agent too
|
||||
// old for updates"); a *model.NotUpdatableError when the service cannot be
|
||||
// updated (no job created); the active job and model.ErrJobActive when one
|
||||
// is queued or running; model.ErrNotFound for an unknown service.
|
||||
// returns what Start returns, and model.ErrNotFound for an unknown service.
|
||||
func (s *Service) Trigger(ctx context.Context, serviceID int64, requestedBy string) (model.Job, error) {
|
||||
svc, err := s.store.GetService(ctx, serviceID)
|
||||
if err != nil {
|
||||
return model.Job{}, err
|
||||
}
|
||||
if err := s.Updatable(svc); err != nil {
|
||||
return s.Start(ctx, svc, StartRequest{Trigger: model.TriggerManual, RequestedBy: requestedBy})
|
||||
}
|
||||
|
||||
// Start starts an update of svc. It returns the job, which may already be
|
||||
// failed ("agent offline", "agent too old for updates"); a
|
||||
// *model.NotUpdatableError when the service cannot be updated (no job
|
||||
// created); the active job and model.ErrJobActive when one is queued or
|
||||
// running.
|
||||
func (s *Service) Start(ctx context.Context, svc model.Service, req StartRequest) (model.Job, error) {
|
||||
if err := s.updatable(svc, req.Ref != ""); err != nil {
|
||||
return model.Job{}, err
|
||||
}
|
||||
repo, tag, _ := target(svc.ActualRef) // Updatable checked the tag
|
||||
ref := repo + ":" + tag
|
||||
ref := req.Ref
|
||||
if ref == "" {
|
||||
ref = FollowedRef(svc.ActualRef) // updatable checked the tag
|
||||
}
|
||||
|
||||
r, routeErr := s.hub.Route(svc)
|
||||
nj := model.NewJob{
|
||||
ServiceID: svc.ID, FromRef: svc.ActualRef, ToRef: ref,
|
||||
Trigger: model.TriggerManual, RequestedBy: requestedBy, CreatedAt: s.now(),
|
||||
Trigger: req.Trigger, RequestedBy: req.RequestedBy, CreatedAt: s.now(),
|
||||
}
|
||||
if routeErr == nil {
|
||||
nj.AgentID = &r.AgentID
|
||||
|
||||
@@ -242,3 +242,102 @@ func TestTrigger_NotUpdatableCreatesNoJob(t *testing.T) {
|
||||
assert.Empty(t, h.starts)
|
||||
assert.Empty(t, pub.topics)
|
||||
}
|
||||
|
||||
func TestStart_WebhookTriggerWithExplicitRef(t *testing.T) {
|
||||
st := &fakeStore{services: map[int64]model.Service{1: composeSvc}, jobs: map[int64]model.Job{}}
|
||||
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
||||
s, _ := newService(st, h)
|
||||
|
||||
job, err := s.Start(context.Background(), composeSvc, StartRequest{
|
||||
Trigger: model.TriggerWebhook, Ref: "gitea.shcizo.se/shcizo/slusk:2.0.0"})
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, model.JobQueued, job.Status)
|
||||
require.Len(t, st.created, 1)
|
||||
assert.Equal(t, model.TriggerWebhook, st.created[0].Trigger)
|
||||
assert.Empty(t, st.created[0].RequestedBy)
|
||||
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:2.0.0", st.created[0].ToRef)
|
||||
require.Len(t, h.starts, 1)
|
||||
assert.Equal(t, "gitea.shcizo.se/shcizo/slusk:2.0.0", h.starts[0].start.Ref)
|
||||
}
|
||||
|
||||
func TestStart_ExplicitRefBypassesOnlyTheNoTagRefusal(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svc model.Service
|
||||
reason string // "" means the job starts
|
||||
}{
|
||||
{"digest only", model.Service{Kind: protocol.KindCompose, ActualRef: "redis@sha256:abc"}, ""},
|
||||
{"removed", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", RemovedAt: ptr(time.Now())}, model.ReasonRemoved},
|
||||
{"self", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Self: true}, model.ReasonSelf},
|
||||
{"built", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Built: true}, model.ReasonBuilt},
|
||||
{"container", model.Service{Kind: protocol.KindContainer, ActualRef: "redis:7"}, model.ReasonContainer},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tt.svc.ID = 1
|
||||
st := &fakeStore{services: map[int64]model.Service{1: tt.svc}, jobs: map[int64]model.Job{}}
|
||||
h := &fakeHub{route: model.Route{AgentID: 7, ConnID: 70}}
|
||||
s, _ := newService(st, h)
|
||||
|
||||
_, err := s.Start(context.Background(), tt.svc, StartRequest{Trigger: model.TriggerWebhook, Ref: "x/y:2"})
|
||||
|
||||
if tt.reason == "" {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, st.created, 1)
|
||||
return
|
||||
}
|
||||
var nu *model.NotUpdatableError
|
||||
require.True(t, errors.As(err, &nu))
|
||||
assert.Equal(t, tt.reason, nu.Reason)
|
||||
assert.Empty(t, st.created)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStart_WithoutRefStillRefusesNoTag(t *testing.T) {
|
||||
svc := model.Service{ID: 1, Kind: protocol.KindCompose, ActualRef: "redis@sha256:abc"}
|
||||
s, _ := newService(&fakeStore{jobs: map[int64]model.Job{}}, &fakeHub{})
|
||||
|
||||
_, err := s.Start(context.Background(), svc, StartRequest{Trigger: model.TriggerWebhook})
|
||||
|
||||
var nu *model.NotUpdatableError
|
||||
require.True(t, errors.As(err, &nu))
|
||||
assert.Equal(t, model.ReasonNoTag, nu.Reason)
|
||||
}
|
||||
|
||||
func TestAutoAllowed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
svc model.Service
|
||||
reason string
|
||||
}{
|
||||
{"removed", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", RemovedAt: ptr(time.Now())}, model.ReasonRemoved},
|
||||
{"self", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Self: true}, model.ReasonSelf},
|
||||
{"agent image repo", model.Service{Kind: protocol.KindCompose, ActualRef: selfImage}, model.ReasonSelf},
|
||||
{"built", model.Service{Kind: protocol.KindCompose, ActualRef: "a:1", Built: true}, model.ReasonBuilt},
|
||||
{"container", model.Service{Kind: protocol.KindContainer, ActualRef: "redis:7"}, model.ReasonContainer},
|
||||
{"digest without tag is allowed", model.Service{Kind: protocol.KindCompose, ActualRef: "redis@sha256:abc"}, ""},
|
||||
{"tag", model.Service{Kind: protocol.KindCompose, ActualRef: "postgres:16"}, ""},
|
||||
}
|
||||
s, _ := newService(&fakeStore{}, &fakeHub{})
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := s.AutoAllowed(tt.svc)
|
||||
if tt.reason == "" {
|
||||
assert.NoError(t, err)
|
||||
return
|
||||
}
|
||||
var nu *model.NotUpdatableError
|
||||
require.True(t, errors.As(err, &nu))
|
||||
assert.Equal(t, tt.reason, nu.Reason)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFollowedRef(t *testing.T) {
|
||||
assert.Equal(t, "postgres:16", FollowedRef("postgres:16@sha256:abc"))
|
||||
assert.Equal(t, "postgres:latest", FollowedRef("postgres"))
|
||||
assert.Equal(t, "localhost:5000/foo:latest", FollowedRef("localhost:5000/foo"))
|
||||
assert.Empty(t, FollowedRef("redis@sha256:abc"))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user