Files
portainer/api/stacks/stackbuilders/director_test.go
T

273 lines
9.4 KiB
Go

package stackbuilders
import (
"context"
"errors"
"net/http"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Stubs
type stubFileContentBuilder struct {
FileContentMethodStackBuildProcess
store *datastore.Store
savedStack *portainer.Stack
saveErr error
deployErr error
}
func (s *stubFileContentBuilder) SetGeneralInfo(_ *StackPayload, _ *portainer.Endpoint) FileContentMethodStackBuildProcess {
s.savedStack.Status = portainer.StackStatusDeploying
s.savedStack.DeploymentStatus = []portainer.StackDeploymentStatus{
{Status: portainer.StackStatusDeploying, Time: time.Now().Unix()},
}
return s
}
func (s *stubFileContentBuilder) SetUniqueInfo(_ *StackPayload) FileContentMethodStackBuildProcess {
return s
}
func (s *stubFileContentBuilder) SetFileContent(_ *StackPayload) FileContentMethodStackBuildProcess {
return s
}
func (s *stubFileContentBuilder) SaveStack() (*portainer.Stack, error) {
if s.saveErr != nil {
return nil, s.saveErr
}
return s.savedStack, s.store.Stack().Create(s.savedStack)
}
func (s *stubFileContentBuilder) Deploy(_ context.Context, _ *StackPayload, _ *portainer.Endpoint) FileContentMethodStackBuildProcess {
return s
}
func (s *stubFileContentBuilder) Error() error { return s.deployErr }
type stubFileUploadBuilder struct {
FileUploadMethodStackBuildProcess
store *datastore.Store
savedStack *portainer.Stack
saveErr error
deployErr error
}
func (s *stubFileUploadBuilder) SetGeneralInfo(_ *StackPayload, _ *portainer.Endpoint) FileUploadMethodStackBuildProcess {
s.savedStack.Status = portainer.StackStatusDeploying
s.savedStack.DeploymentStatus = []portainer.StackDeploymentStatus{
{Status: portainer.StackStatusDeploying, Time: time.Now().Unix()},
}
return s
}
func (s *stubFileUploadBuilder) SetUniqueInfo(_ *StackPayload) FileUploadMethodStackBuildProcess {
return s
}
func (s *stubFileUploadBuilder) SetUploadedFile(_ *StackPayload) FileUploadMethodStackBuildProcess {
return s
}
func (s *stubFileUploadBuilder) SaveStack() (*portainer.Stack, error) {
if s.saveErr != nil {
return nil, s.saveErr
}
return s.savedStack, s.store.Stack().Create(s.savedStack)
}
func (s *stubFileUploadBuilder) Deploy(_ context.Context, _ *StackPayload, _ *portainer.Endpoint) FileUploadMethodStackBuildProcess {
return s
}
func (s *stubFileUploadBuilder) Error() error { return s.deployErr }
type stubUrlBuilder struct {
UrlMethodStackBuildProcess
store *datastore.Store
savedStack *portainer.Stack
saveErr error
deployErr error
}
func (s *stubUrlBuilder) SetGeneralInfo(_ *StackPayload, _ *portainer.Endpoint) UrlMethodStackBuildProcess {
s.savedStack.Status = portainer.StackStatusDeploying
s.savedStack.DeploymentStatus = []portainer.StackDeploymentStatus{
{Status: portainer.StackStatusDeploying, Time: time.Now().Unix()},
}
return s
}
func (s *stubUrlBuilder) SetUniqueInfo(_ *StackPayload) UrlMethodStackBuildProcess { return s }
func (s *stubUrlBuilder) SetURL(_ *StackPayload) UrlMethodStackBuildProcess { return s }
func (s *stubUrlBuilder) SaveStack() (*portainer.Stack, error) {
if s.saveErr != nil {
return nil, s.saveErr
}
return s.savedStack, s.store.Stack().Create(s.savedStack)
}
func (s *stubUrlBuilder) Deploy(_ context.Context, _ *StackPayload, _ *portainer.Endpoint) UrlMethodStackBuildProcess {
return s
}
func (s *stubUrlBuilder) Error() error { return s.deployErr }
type stubGitBuilder struct {
GitMethodStackBuildProcess
store *datastore.Store
savedStack *portainer.Stack
saveErr error
deployErr error
hookCalled bool
}
func (s *stubGitBuilder) SetGeneralInfo(_ *StackPayload, _ *portainer.Endpoint) GitMethodStackBuildProcess {
return s
}
func (s *stubGitBuilder) SetUniqueInfo(_ *StackPayload) GitMethodStackBuildProcess { return s }
func (s *stubGitBuilder) SetGitRepository(_ context.Context, _ *StackPayload) GitMethodStackBuildProcess {
return s
}
func (s *stubGitBuilder) SaveStack() (*portainer.Stack, error) {
if s.saveErr != nil {
return nil, s.saveErr
}
return s.savedStack, s.store.Stack().Create(s.savedStack)
}
func (s *stubGitBuilder) Deploy(_ context.Context, _ *StackPayload, _ *portainer.Endpoint) GitMethodStackBuildProcess {
return s
}
func (s *stubGitBuilder) Error() error { return s.deployErr }
func (s *stubGitBuilder) EnableAutoUpdate(_ context.Context, _ *portainer.Stack) error {
s.hookCalled = true
return nil
}
// Helpers
func waitForStackStatus(t *testing.T, store *datastore.Store, id portainer.StackID, wantStatus portainer.StackStatus) *portainer.Stack {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
stack, err := store.Stack().Read(id)
if err == nil && stack.Status == wantStatus {
return stack
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("timed out waiting for stack %d to reach status %d", id, wantStatus)
return nil
}
// Tests
func TestDirector_Build_UnknownBuilder_ReturnsBadRequest(t *testing.T) {
director := NewStackBuilderDirector(nil, "not a builder")
_, herr := director.Build(t.Context(), &StackPayload{}, &portainer.Endpoint{})
require.NotNil(t, herr)
assert.Equal(t, http.StatusBadRequest, herr.StatusCode)
}
func TestDirector_Build_GitMethod_UnauthorizedCredential_ReturnsForbidden(t *testing.T) {
director := NewStackBuilderDirector(nil, &stubGitBuilder{saveErr: httperrors.ErrUnauthorized})
_, herr := director.Build(t.Context(), &StackPayload{}, &portainer.Endpoint{})
require.NotNil(t, herr)
assert.Equal(t, http.StatusForbidden, herr.StatusCode)
}
func TestDirector_Build_GitMethod_SaveError_ReturnsInternalServerError(t *testing.T) {
director := NewStackBuilderDirector(nil, &stubGitBuilder{saveErr: errors.New("db error")})
_, herr := director.Build(context.TODO(), &StackPayload{}, &portainer.Endpoint{})
require.NotNil(t, herr)
assert.Equal(t, http.StatusInternalServerError, herr.StatusCode)
}
func TestDirector_SpawnAsync_DeploySuccess_UpdatesStackStatusToActive(t *testing.T) {
tc := []struct {
name string
builder func(store *datastore.Store, stack *portainer.Stack) any
}{
{
name: "file content builder",
builder: func(store *datastore.Store, stack *portainer.Stack) any {
return &stubFileContentBuilder{store: store, savedStack: stack}
},
},
{
name: "file upload builder",
builder: func(store *datastore.Store, stack *portainer.Stack) any {
return &stubFileUploadBuilder{store: store, savedStack: stack}
},
},
{
name: "url builder",
builder: func(store *datastore.Store, stack *portainer.Stack) any {
return &stubUrlBuilder{store: store, savedStack: stack}
},
},
}
for _, tt := range tc {
t.Run(tt.name, func(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
stack := &portainer.Stack{ID: 1}
director := NewStackBuilderDirector(store, tt.builder(store, stack))
_, herr := director.Build(context.TODO(), &StackPayload{}, &portainer.Endpoint{})
require.Nil(t, herr)
updated := waitForStackStatus(t, store, stack.ID, portainer.StackStatusActive)
assert.Equal(t, portainer.StackStatusActive, updated.Status)
require.Len(t, updated.DeploymentStatus, 2)
assert.Equal(t, portainer.StackStatusDeploying, updated.DeploymentStatus[0].Status)
assert.Equal(t, portainer.StackStatusActive, updated.DeploymentStatus[1].Status)
})
}
}
func TestDirector_SpawnAsync_DeployFailure_UpdatesStackStatusToError(t *testing.T) {
deployErr := errors.New("failed to pull image nginx:999")
_, store := datastore.MustNewTestStore(t, true, false)
stack := &portainer.Stack{ID: 1}
director := NewStackBuilderDirector(store, &stubFileContentBuilder{store: store, savedStack: stack, deployErr: deployErr})
_, herr := director.Build(context.TODO(), &StackPayload{}, &portainer.Endpoint{})
require.Nil(t, herr)
updated := waitForStackStatus(t, store, stack.ID, portainer.StackStatusError)
assert.Equal(t, portainer.StackStatusError, updated.Status)
require.Len(t, updated.DeploymentStatus, 2)
assert.Equal(t, portainer.StackStatusDeploying, updated.DeploymentStatus[0].Status)
lastEntry := updated.DeploymentStatus[1]
assert.Equal(t, portainer.StackStatusError, lastEntry.Status)
assert.Equal(t, deployErr.Error(), lastEntry.Message)
}
func TestDirector_SpawnAsync_PostDeployHook_CalledOnSuccess(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
stack := &portainer.Stack{ID: 1}
builder := &stubGitBuilder{store: store, savedStack: stack}
director := NewStackBuilderDirector(store, builder)
_, herr := director.Build(context.TODO(), &StackPayload{}, &portainer.Endpoint{})
require.Nil(t, herr)
waitForStackStatus(t, store, stack.ID, portainer.StackStatusActive)
assert.True(t, builder.hookCalled, "post-deploy hook should be called after a successful deployment")
}
func TestDirector_SpawnAsync_PostDeployHook_NotCalledOnDeployFailure(t *testing.T) {
_, store := datastore.MustNewTestStore(t, true, false)
stack := &portainer.Stack{ID: 1}
builder := &stubGitBuilder{store: store, savedStack: stack, deployErr: errors.New("failed to deploy")}
director := NewStackBuilderDirector(store, builder)
_, herr := director.Build(context.TODO(), &StackPayload{}, &portainer.Endpoint{})
require.Nil(t, herr)
waitForStackStatus(t, store, stack.ID, portainer.StackStatusError)
assert.False(t, builder.hookCalled, "post-deploy hook should not be called after a failed deployment")
}