chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
)
|
||||
|
||||
type ComposeStackFileBuilder struct {
|
||||
StackBuilder
|
||||
SecurityContext *security.RestrictedRequestContext
|
||||
}
|
||||
|
||||
// CreateComposeStackFileBuilder creates a builder for compose stacks deployed from a file (either uploaded or provided as text content).
|
||||
func CreateComposeStackFileBuilder(securityContext *security.RestrictedRequestContext,
|
||||
dataStore dataservices.DataStore,
|
||||
fileService portainer.FileService,
|
||||
stackDeployer deployments.StackDeployer) *ComposeStackFileBuilder {
|
||||
|
||||
return &ComposeStackFileBuilder{
|
||||
StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer),
|
||||
SecurityContext: securityContext,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ComposeStackFileBuilder) prepare(_ context.Context, payload *StackPayload, userID portainer.UserID) error {
|
||||
b.stack.Name = payload.Name
|
||||
b.stack.Type = portainer.DockerComposeStack
|
||||
b.stack.EntryPoint = filesystem.ComposeFileDefaultName
|
||||
b.stack.Env = payload.Env
|
||||
b.stack.FromAppTemplate = payload.FromAppTemplate
|
||||
|
||||
if err := b.initCreatedBy(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.storeStackFile(payload.StackFileContent)
|
||||
}
|
||||
|
||||
func (b *ComposeStackFileBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error {
|
||||
if err := b.initComposeDeployment(b.SecurityContext, endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.deploymentConfiger.Deploy(ctx)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
)
|
||||
|
||||
type ComposeStackGitBuilder struct {
|
||||
GitMethodStackBuilder
|
||||
SecurityContext *security.RestrictedRequestContext
|
||||
}
|
||||
|
||||
// CreateComposeStackGitBuilder creates a builder for the compose stack (docker standalone) that will be deployed by git repository method
|
||||
func CreateComposeStackGitBuilder(securityContext *security.RestrictedRequestContext,
|
||||
dataStore dataservices.DataStore,
|
||||
fileService portainer.FileService,
|
||||
gitService portainer.GitService,
|
||||
scheduler *scheduler.Scheduler,
|
||||
stackDeployer deployments.StackDeployer) *ComposeStackGitBuilder {
|
||||
|
||||
return &ComposeStackGitBuilder{
|
||||
GitMethodStackBuilder: GitMethodStackBuilder{
|
||||
StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer),
|
||||
gitService: gitService,
|
||||
scheduler: scheduler,
|
||||
},
|
||||
SecurityContext: securityContext,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ComposeStackGitBuilder) prepare(ctx context.Context, payload *StackPayload, userID portainer.UserID) error {
|
||||
b.stack.Name = payload.Name
|
||||
b.stack.Type = portainer.DockerComposeStack
|
||||
b.stack.EntryPoint = payload.ComposeFile
|
||||
b.stack.FromAppTemplate = payload.FromAppTemplate
|
||||
b.stack.Env = payload.Env
|
||||
|
||||
return b.GitMethodStackBuilder.prepare(ctx, payload, userID)
|
||||
}
|
||||
|
||||
func (b *ComposeStackGitBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error {
|
||||
if err := b.initComposeDeployment(b.SecurityContext, endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.deploymentConfiger.Deploy(ctx)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// stackBuildProcess is the common interface shared by all stack build methods.
|
||||
type stackBuildProcess interface {
|
||||
setGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint)
|
||||
// prepare handles all pre-save steps: sets type-specific metadata, stores
|
||||
// files on disk, or clones the git repository.
|
||||
prepare(ctx context.Context, payload *StackPayload, userID portainer.UserID) error
|
||||
saveStack() (*portainer.Stack, error)
|
||||
deploy(ctx context.Context, endpoint *portainer.Endpoint) error
|
||||
// postDeploy runs after a successful deployment: for git builders it enables
|
||||
// auto-update; for other builders it is a no-op.
|
||||
postDeploy(ctx context.Context, stack *portainer.Stack) error
|
||||
}
|
||||
|
||||
// Build executes the stack build process. It returns the created stack and any
|
||||
// error encountered during the process. The returned error is of type
|
||||
// *httperror.HandlerError, which could be an InternalServerError depending on
|
||||
// the error encountered during the stack build process.
|
||||
//
|
||||
// The stack is saved to DB with Status=Deploying and returned immediately.
|
||||
// Deployment runs in a background goroutine. The caller must poll
|
||||
// GET /stacks/{id} to track completion.
|
||||
func Build(ctx context.Context, dataStore dataservices.DataStore, builder stackBuildProcess, payload *StackPayload, endpoint *portainer.Endpoint, userID portainer.UserID) (*portainer.Stack, *httperror.HandlerError) {
|
||||
builder.setGeneralInfo(payload, endpoint)
|
||||
|
||||
if err := builder.prepare(ctx, payload, userID); err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to prepare stack", err)
|
||||
}
|
||||
|
||||
stack, err := builder.saveStack()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Failed to save stack", err)
|
||||
}
|
||||
|
||||
go deploy(dataStore, builder, stack.ID, endpoint)
|
||||
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
func deploy(dataStore dataservices.DataStore, builder stackBuildProcess, stackID portainer.StackID, endpoint *portainer.Endpoint) {
|
||||
backgroundCtx := context.Background()
|
||||
ctx, cancel := context.WithTimeout(backgroundCtx, 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
deployErr := builder.deploy(ctx, endpoint)
|
||||
|
||||
var stack *portainer.Stack
|
||||
|
||||
if err := dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
|
||||
stack, err = tx.Stack().Read(stackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stackutils.UpdateStackStatusFromDeploymentResult(stack, deployErr)
|
||||
|
||||
return tx.Stack().Update(stack.ID, stack)
|
||||
}); err != nil {
|
||||
log.Error().Err(err).
|
||||
AnErr("deploy_error", deployErr).
|
||||
Int("stack_id", int(stackID)).
|
||||
Str("context", "deploy").
|
||||
Msg("Failed to update stack status after async deployment")
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if deployErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if err := builder.postDeploy(backgroundCtx, stack); err != nil {
|
||||
log.Error().Err(err).
|
||||
Int("stack_id", int(stackID)).
|
||||
Str("context", "deploy").
|
||||
Msg("Failed to run post-deployment hook")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"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 stubBuilder struct {
|
||||
store *datastore.Store
|
||||
savedStack *portainer.Stack
|
||||
saveErr error
|
||||
deployErr error
|
||||
hookCalled atomic.Bool
|
||||
}
|
||||
|
||||
func (s *stubBuilder) setGeneralInfo(_ *StackPayload, _ *portainer.Endpoint) {
|
||||
if s.savedStack == nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().Unix()
|
||||
s.savedStack.Status = portainer.StackStatusDeploying
|
||||
s.savedStack.DeploymentStatus = []portainer.StackDeploymentStatus{
|
||||
{Status: portainer.StackStatusDeploying, Time: now},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *stubBuilder) prepare(_ context.Context, _ *StackPayload, _ portainer.UserID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubBuilder) saveStack() (*portainer.Stack, error) {
|
||||
if s.saveErr != nil {
|
||||
return nil, s.saveErr
|
||||
}
|
||||
|
||||
return s.savedStack, s.store.Stack().Create(s.savedStack)
|
||||
}
|
||||
|
||||
func (s *stubBuilder) deploy(_ context.Context, _ *portainer.Endpoint) error {
|
||||
return s.deployErr
|
||||
}
|
||||
|
||||
func (s *stubBuilder) postDeploy(_ context.Context, _ *portainer.Stack) error {
|
||||
s.hookCalled.Store(true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helpers
|
||||
|
||||
func waitForStackStatus(t *testing.T, store *datastore.Store, id portainer.StackID, wantStatus portainer.StackStatus) *portainer.Stack {
|
||||
t.Helper()
|
||||
|
||||
var stack *portainer.Stack
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
var err error
|
||||
stack, err = store.Stack().Read(id)
|
||||
|
||||
return err == nil && stack.Status == wantStatus
|
||||
}, 5*time.Second, 10*time.Millisecond, "stack did not reach status %d in time", wantStatus)
|
||||
|
||||
return stack
|
||||
}
|
||||
|
||||
// Tests
|
||||
|
||||
func TestBuild_SaveError_ErrUnauthorized_ReturnsInternalServerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := &stubBuilder{saveErr: httperrors.ErrUnauthorized}
|
||||
|
||||
_, herr := Build(t.Context(), nil, builder, &StackPayload{}, &portainer.Endpoint{}, 0)
|
||||
|
||||
require.NotNil(t, herr)
|
||||
assert.Equal(t, http.StatusInternalServerError, herr.StatusCode)
|
||||
}
|
||||
|
||||
func TestBuild_SaveError_ReturnsInternalServerError(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := &stubBuilder{saveErr: errors.New("db error")}
|
||||
|
||||
_, herr := Build(t.Context(), nil, builder, &StackPayload{}, &portainer.Endpoint{}, 0)
|
||||
|
||||
require.NotNil(t, herr)
|
||||
assert.Equal(t, http.StatusInternalServerError, herr.StatusCode)
|
||||
}
|
||||
|
||||
func TestBuild_SpawnAsync_DeploySuccess_UpdatesStackStatusToActive(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
stack := &portainer.Stack{ID: 1}
|
||||
builder := &stubBuilder{store: store, savedStack: stack}
|
||||
|
||||
_, herr := Build(t.Context(), store, builder, &StackPayload{}, &portainer.Endpoint{}, 0)
|
||||
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 TestBuild_SpawnAsync_DeployFailure_UpdatesStackStatusToError(t *testing.T) {
|
||||
t.Parallel()
|
||||
deployErr := errors.New("failed to pull image nginx:999")
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
stack := &portainer.Stack{ID: 1}
|
||||
builder := &stubBuilder{store: store, savedStack: stack, deployErr: deployErr}
|
||||
|
||||
_, herr := Build(t.Context(), store, builder, &StackPayload{}, &portainer.Endpoint{}, 0)
|
||||
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 TestBuild_SpawnAsync_PostDeployHook_CalledOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
stack := &portainer.Stack{ID: 1}
|
||||
builder := &stubBuilder{store: store, savedStack: stack}
|
||||
|
||||
_, herr := Build(t.Context(), store, builder, &StackPayload{}, &portainer.Endpoint{}, 0)
|
||||
require.Nil(t, herr)
|
||||
|
||||
waitForStackStatus(t, store, stack.ID, portainer.StackStatusActive)
|
||||
|
||||
require.Eventually(t, builder.hookCalled.Load, 5*time.Second, 10*time.Millisecond, "post-deploy hook should be called after a successful deployment")
|
||||
}
|
||||
|
||||
func TestBuild_SpawnAsync_PostDeployHook_NotCalledOnDeployFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
stack := &portainer.Stack{ID: 1}
|
||||
builder := &stubBuilder{store: store, savedStack: stack, deployErr: errors.New("failed to deploy")}
|
||||
|
||||
_, herr := Build(t.Context(), store, builder, &StackPayload{}, &portainer.Endpoint{}, 0)
|
||||
require.Nil(t, herr)
|
||||
|
||||
waitForStackStatus(t, store, stack.ID, portainer.StackStatusError)
|
||||
|
||||
require.False(t, builder.hookCalled.Load(), "post-deploy hook should not be called after a failed deployment")
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
)
|
||||
|
||||
type KubernetesStackGitBuilder struct {
|
||||
GitMethodStackBuilder
|
||||
kubernetesDeployer portainer.KubernetesDeployer
|
||||
user *portainer.User
|
||||
}
|
||||
|
||||
// CreateKubernetesStackGitBuilder creates a builder for the Kubernetes stack that will be deployed by git repository method
|
||||
func CreateKubernetesStackGitBuilder(dataStore dataservices.DataStore,
|
||||
fileService portainer.FileService,
|
||||
gitService portainer.GitService,
|
||||
scheduler *scheduler.Scheduler,
|
||||
stackDeployer deployments.StackDeployer,
|
||||
kubernetesDeployer portainer.KubernetesDeployer,
|
||||
user *portainer.User) *KubernetesStackGitBuilder {
|
||||
|
||||
return &KubernetesStackGitBuilder{
|
||||
GitMethodStackBuilder: GitMethodStackBuilder{
|
||||
StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer),
|
||||
gitService: gitService,
|
||||
scheduler: scheduler,
|
||||
},
|
||||
kubernetesDeployer: kubernetesDeployer,
|
||||
user: user,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *KubernetesStackGitBuilder) prepare(ctx context.Context, payload *StackPayload, userID portainer.UserID) error {
|
||||
b.stack.Type = portainer.KubernetesStack
|
||||
b.stack.Namespace = payload.Namespace
|
||||
b.stack.Name = payload.StackName
|
||||
b.stack.EntryPoint = payload.ManifestFile
|
||||
if err := b.GitMethodStackBuilder.prepare(ctx, payload, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.deploymentConfiger = newKubernetesDeploymentConfig(b.stack, b.kubernetesDeployer, "git", b.user, b.endpoint)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *KubernetesStackGitBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error {
|
||||
return b.deploymentConfiger.Deploy(ctx)
|
||||
}
|
||||
|
||||
func (b *KubernetesStackGitBuilder) GetResponse() string {
|
||||
return b.deploymentConfiger.GetResponse()
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/client"
|
||||
k "github.com/portainer/portainer/api/kubernetes"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
)
|
||||
|
||||
type KubernetesStackBuilder struct {
|
||||
StackBuilder
|
||||
kubernetesDeployer portainer.KubernetesDeployer
|
||||
user *portainer.User
|
||||
kind string
|
||||
contentFn func(*StackPayload) ([]byte, error)
|
||||
}
|
||||
|
||||
// CreateK8sStackFileContentBuilder creates a builder for the Kubernetes stack deployed from file content.
|
||||
func CreateK8sStackFileContentBuilder(dataStore dataservices.DataStore,
|
||||
fileService portainer.FileService,
|
||||
stackDeployer deployments.StackDeployer,
|
||||
kubernetesDeployer portainer.KubernetesDeployer,
|
||||
user *portainer.User) *KubernetesStackBuilder {
|
||||
|
||||
return &KubernetesStackBuilder{
|
||||
StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer),
|
||||
kubernetesDeployer: kubernetesDeployer,
|
||||
user: user,
|
||||
kind: "content",
|
||||
contentFn: func(p *StackPayload) ([]byte, error) {
|
||||
return p.StackFileContent, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CreateKubernetesStackUrlBuilder creates a builder for the Kubernetes stack deployed from a URL.
|
||||
func CreateKubernetesStackUrlBuilder(dataStore dataservices.DataStore,
|
||||
fileService portainer.FileService,
|
||||
stackDeployer deployments.StackDeployer,
|
||||
kubernetesDeployer portainer.KubernetesDeployer,
|
||||
user *portainer.User) *KubernetesStackBuilder {
|
||||
|
||||
return &KubernetesStackBuilder{
|
||||
StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer),
|
||||
kubernetesDeployer: kubernetesDeployer,
|
||||
user: user,
|
||||
kind: "url",
|
||||
contentFn: func(p *StackPayload) ([]byte, error) {
|
||||
return client.Get(p.ManifestURL, 30)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (b *KubernetesStackBuilder) prepare(_ context.Context, payload *StackPayload, userID portainer.UserID) error {
|
||||
b.stack.Name = payload.StackName
|
||||
b.stack.Type = portainer.KubernetesStack
|
||||
b.stack.EntryPoint = filesystem.ManifestFileDefaultName
|
||||
b.stack.Namespace = payload.Namespace
|
||||
b.stack.FromAppTemplate = payload.FromAppTemplate
|
||||
|
||||
if err := b.initCreatedBy(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
content, err := b.contentFn(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to retrieve manifest content: %w", err)
|
||||
}
|
||||
|
||||
if err := b.storeStackFile(content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.deploymentConfiger = newKubernetesDeploymentConfig(b.stack, b.kubernetesDeployer, b.kind, b.user, b.endpoint)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *KubernetesStackBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error {
|
||||
return b.deploymentConfiger.Deploy(ctx)
|
||||
}
|
||||
|
||||
func (b *KubernetesStackBuilder) GetResponse() string {
|
||||
return b.deploymentConfiger.GetResponse()
|
||||
}
|
||||
|
||||
func newKubernetesDeploymentConfig(stack *portainer.Stack, deployer portainer.KubernetesDeployer, kind string, user *portainer.User, endpoint *portainer.Endpoint) deployments.StackDeploymentConfiger {
|
||||
k8sAppLabel := k.KubeAppLabels{
|
||||
StackID: int(stack.ID),
|
||||
StackName: stack.Name,
|
||||
Owner: stackutils.SanitizeLabel(stack.CreatedBy),
|
||||
Kind: kind,
|
||||
}
|
||||
|
||||
return deployments.CreateKubernetesStackDeploymentConfig(stack, deployer, k8sAppLabel, user, endpoint)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
type StackBuilder struct {
|
||||
stack *portainer.Stack
|
||||
endpoint *portainer.Endpoint
|
||||
dataStore dataservices.DataStore
|
||||
fileService portainer.FileService
|
||||
stackDeployer deployments.StackDeployer
|
||||
deploymentConfiger deployments.StackDeploymentConfiger
|
||||
doCleanUp bool
|
||||
}
|
||||
|
||||
func CreateStackBuilder(dataStore dataservices.DataStore, fileService portainer.FileService, deployer deployments.StackDeployer) StackBuilder {
|
||||
return StackBuilder{
|
||||
stack: &portainer.Stack{},
|
||||
dataStore: dataStore,
|
||||
fileService: fileService,
|
||||
stackDeployer: deployer,
|
||||
doCleanUp: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *StackBuilder) setGeneralInfo(_ *StackPayload, endpoint *portainer.Endpoint) {
|
||||
b.endpoint = endpoint
|
||||
stackID := b.dataStore.Stack().GetNextIdentifier()
|
||||
b.stack.ID = portainer.StackID(stackID)
|
||||
b.stack.EndpointID = endpoint.ID
|
||||
now := time.Now().Unix()
|
||||
b.stack.CreationDate = now
|
||||
stackutils.PrepareStackStatusForDeployment(b.stack)
|
||||
}
|
||||
|
||||
func (b *StackBuilder) deploy(ctx context.Context, _ *portainer.Endpoint) error {
|
||||
return b.deploymentConfiger.Deploy(ctx)
|
||||
}
|
||||
|
||||
func (b *StackBuilder) postDeploy(_ context.Context, _ *portainer.Stack) error { return nil }
|
||||
|
||||
func (b *StackBuilder) saveStack() (*portainer.Stack, error) {
|
||||
defer func() { _ = b.cleanUp() }()
|
||||
|
||||
if err := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
if err := tx.Stack().Create(b.stack); err != nil {
|
||||
return fmt.Errorf("Unable to persist the stack inside the database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
b.doCleanUp = false
|
||||
|
||||
return b.stack, nil
|
||||
}
|
||||
|
||||
func (b *StackBuilder) cleanUp() error {
|
||||
if !b.doCleanUp {
|
||||
return nil
|
||||
}
|
||||
|
||||
if b.stack.WorkflowID != 0 {
|
||||
if err := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
err := tx.Workflow().Delete(b.stack.WorkflowID)
|
||||
if tx.IsErrObjectNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return err
|
||||
}); err != nil {
|
||||
log.Error().Err(err).Msg("unable to cleanup orphan workflow records after failed stack creation")
|
||||
}
|
||||
}
|
||||
|
||||
if err := b.fileService.RemoveDirectory(b.stack.ProjectPath); err != nil {
|
||||
log.Error().Err(err).Msg("unable to cleanup stack creation")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *StackBuilder) initCreatedBy(userID portainer.UserID) error {
|
||||
return b.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
user, err := tx.User().Read(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find stack author: %w", err)
|
||||
}
|
||||
b.stack.CreatedBy = user.Username
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (b *StackBuilder) storeStackFile(content []byte) error {
|
||||
stackFolder := strconv.Itoa(int(b.stack.ID))
|
||||
projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, content)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.stack.ProjectPath = projectPath
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *StackBuilder) initComposeDeployment(secCtx *security.RestrictedRequestContext, endpoint *portainer.Endpoint) error {
|
||||
config, err := deployments.CreateComposeStackDeploymentConfigTx(b.dataStore, secCtx, b.stack, endpoint, b.fileService, b.stackDeployer, false, false, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create compose deployment config: %w", err)
|
||||
}
|
||||
|
||||
b.deploymentConfiger = config
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *StackBuilder) initSwarmDeployment(secCtx *security.RestrictedRequestContext, endpoint *portainer.Endpoint) error {
|
||||
config, err := deployments.CreateSwarmStackDeploymentConfigTx(b.dataStore, secCtx, b.stack, endpoint, b.fileService, b.stackDeployer, false, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create swarm deployment config: %w", err)
|
||||
}
|
||||
|
||||
b.deploymentConfiger = config
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/gitops/workflows"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
)
|
||||
|
||||
type GitMethodStackBuilder struct {
|
||||
StackBuilder
|
||||
gitService portainer.GitService
|
||||
scheduler *scheduler.Scheduler
|
||||
}
|
||||
|
||||
func (b *GitMethodStackBuilder) prepare(ctx context.Context, payload *StackPayload, userID portainer.UserID) error {
|
||||
b.stack.AdditionalFiles = payload.AdditionalFiles
|
||||
b.stack.AutoUpdate = payload.AutoUpdate
|
||||
|
||||
if err := b.initCreatedBy(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var userContext source.UserContext
|
||||
if err := b.dataStore.ViewTx(func(tx dataservices.DataStoreTx) error {
|
||||
user, err := tx.User().Read(userID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to read user", err)
|
||||
}
|
||||
memberships, err := tx.TeamMembership().TeamMembershipsByUserID(userID)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to read user team memberships", err)
|
||||
}
|
||||
|
||||
userContext = source.NewUserContext(user, memberships)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var repoConfig gittypes.RepoConfig
|
||||
var sourceID portainer.SourceID
|
||||
|
||||
if payload.SourceID != 0 {
|
||||
src, err := b.dataStore.Source().Read(userContext, payload.SourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read source: %w", err)
|
||||
}
|
||||
if src.Git == nil {
|
||||
return fmt.Errorf("source %d has no git configuration", payload.SourceID)
|
||||
}
|
||||
|
||||
repoConfig.URL = src.Git.URL
|
||||
repoConfig.Authentication = src.Git.Authentication
|
||||
repoConfig.TLSSkipVerify = src.Git.TLSSkipVerify
|
||||
repoConfig.ReferenceName = payload.ReferenceName
|
||||
sourceID = src.ID
|
||||
} else {
|
||||
if payload.Authentication {
|
||||
repoConfig.Authentication = &gittypes.GitAuthentication{
|
||||
Username: payload.Username,
|
||||
Password: payload.Password,
|
||||
}
|
||||
}
|
||||
|
||||
repoConfig.URL = payload.URL
|
||||
repoConfig.ReferenceName = payload.ReferenceName
|
||||
repoConfig.TLSSkipVerify = payload.TLSSkipVerify
|
||||
}
|
||||
|
||||
repoConfig.ConfigFilePath = payload.ComposeFile
|
||||
if payload.ComposeFile == "" {
|
||||
repoConfig.ConfigFilePath = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
|
||||
// If a manifest file is specified (for kube git apps), then use it instead of the default compose file name
|
||||
if payload.ManifestFile != "" {
|
||||
repoConfig.ConfigFilePath = payload.ManifestFile
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(b.stack.ID))
|
||||
// Set the project path on the disk
|
||||
b.stack.ProjectPath = b.fileService.GetStackProjectPath(stackFolder)
|
||||
|
||||
getProjectPath := func() string {
|
||||
stackFolder := fmt.Sprintf("%d", b.stack.ID)
|
||||
return b.fileService.GetStackProjectPath(stackFolder)
|
||||
}
|
||||
|
||||
if err := ssrf.CheckURL(ctx, repoConfig.URL); err != nil {
|
||||
return fmt.Errorf("repository URL blocked by SSRF policy: %w", err)
|
||||
}
|
||||
|
||||
commitHash, err := stackutils.DownloadGitRepository(ctx, repoConfig, b.gitService, getProjectPath)
|
||||
if err != nil {
|
||||
if txErr := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return workflows.SaveSourceStatus(tx, userContext, sourceID, err)
|
||||
}); txErr != nil {
|
||||
return fmt.Errorf("failed to download git repository: %w (and failed to persist status: %w)", err, txErr)
|
||||
}
|
||||
|
||||
return fmt.Errorf("failed to download git repository: %w", err)
|
||||
}
|
||||
|
||||
// Update the latest commit id
|
||||
repoConfig.ConfigHash = commitHash
|
||||
|
||||
var workflowID portainer.WorkflowID
|
||||
|
||||
if err := b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
file := portainer.ArtifactFile{
|
||||
Path: repoConfig.ConfigFilePath,
|
||||
Ref: repoConfig.ReferenceName,
|
||||
Hash: repoConfig.ConfigHash,
|
||||
RefStatus: portainer.SourceStatusHealthy,
|
||||
PathStatus: portainer.SourceStatusHealthy,
|
||||
}
|
||||
|
||||
if sourceID != 0 {
|
||||
file.SourceID = sourceID
|
||||
} else {
|
||||
repoConfig.URL = gittypes.SanitizeURL(repoConfig.URL)
|
||||
|
||||
src, err := workflows.FindOrCreateGitSource(tx, userContext, &portainer.Source{
|
||||
Name: gittypes.RepoName(repoConfig.URL),
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.GitSource{
|
||||
URL: repoConfig.URL,
|
||||
Authentication: repoConfig.Authentication,
|
||||
TLSSkipVerify: repoConfig.TLSSkipVerify,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to find or create source: %w", err)
|
||||
}
|
||||
|
||||
file.SourceID = src.ID
|
||||
}
|
||||
|
||||
if err := workflows.SaveSourceStatus(tx, userContext, file.SourceID, nil); err != nil {
|
||||
return fmt.Errorf("failed to persist source sync status: %w", err)
|
||||
}
|
||||
|
||||
wf := &portainer.Workflow{
|
||||
Name: b.stack.Name,
|
||||
Artifacts: []portainer.Artifact{{
|
||||
StackID: b.stack.ID,
|
||||
Files: []portainer.ArtifactFile{file},
|
||||
}},
|
||||
}
|
||||
if err := tx.Workflow().Create(wf); err != nil {
|
||||
return fmt.Errorf("failed to create workflow: %w", err)
|
||||
}
|
||||
|
||||
workflowID = wf.ID
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b.stack.WorkflowID = workflowID
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// postDeploy enables the auto-update scheduler job for the stack if configured,
|
||||
// and persists the resulting job ID back to the database.
|
||||
func (b *GitMethodStackBuilder) postDeploy(ctx context.Context, stack *portainer.Stack) error {
|
||||
if stack.AutoUpdate == nil || stack.AutoUpdate.Interval == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
jobID, err := deployments.StartAutoupdate(ctx, stack.ID,
|
||||
stack.AutoUpdate.Interval,
|
||||
b.scheduler,
|
||||
b.stackDeployer,
|
||||
b.dataStore,
|
||||
b.gitService)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.dataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
s, err := tx.Stack().Read(stack.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Unable to retrieve the stack from the database: %w", err)
|
||||
}
|
||||
|
||||
s.AutoUpdate.JobID = jobID
|
||||
|
||||
if err := tx.Stack().Update(s.ID, s); err != nil {
|
||||
return fmt.Errorf("Unable to update the stack inside the database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/gitops/workflows"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var adminUserContext = source.InsecureNewAdminContext()
|
||||
|
||||
// stubFileService satisfies portainer.FileService for git builder tests.
|
||||
type stubFileService struct {
|
||||
portainer.FileService
|
||||
}
|
||||
|
||||
func (s *stubFileService) GetStackProjectPath(stackIdentifier string) string {
|
||||
return "/data/compose/" + stackIdentifier
|
||||
}
|
||||
|
||||
func newGitMethodBuilder(t *testing.T, commitHash string) *GitMethodStackBuilder {
|
||||
t.Helper()
|
||||
_, store := datastore.MustNewTestStore(t, false, false)
|
||||
require.NoError(t, store.User().Create(&portainer.User{ID: 1, Username: "testuser", Role: portainer.AdministratorRole}))
|
||||
return &GitMethodStackBuilder{
|
||||
StackBuilder: StackBuilder{
|
||||
stack: &portainer.Stack{},
|
||||
fileService: &stubFileService{},
|
||||
dataStore: store,
|
||||
},
|
||||
gitService: testhelpers.NewGitService(nil, commitHash),
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitMethodStackBuilder_WithSourceID_ReferencesExistingSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := newGitMethodBuilder(t, "abc123")
|
||||
builder.stack.ID = 1
|
||||
|
||||
src := &portainer.Source{
|
||||
Name: "my-repo",
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.GitSource{
|
||||
URL: "https://github.com/org/private-repo",
|
||||
Authentication: &gittypes.GitAuthentication{
|
||||
Username: "git-user",
|
||||
Password: "git-token",
|
||||
},
|
||||
},
|
||||
}
|
||||
require.NoError(t, builder.dataStore.Source().Create(adminUserContext, src))
|
||||
|
||||
payload := &StackPayload{
|
||||
RepositoryConfigPayload: RepositoryConfigPayload{
|
||||
SourceID: src.ID,
|
||||
ReferenceName: "refs/heads/main",
|
||||
},
|
||||
}
|
||||
|
||||
err := builder.prepare(context.Background(), payload, portainer.UserID(1))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Workflow Artifact must reference the existing Source — not a new one.
|
||||
referencedSourceID := builderWorkflowSourceID(t, builder)
|
||||
assert.Equal(t, src.ID, referencedSourceID)
|
||||
|
||||
// Only one Source exists — no duplicate was created.
|
||||
allSources, err := builder.dataStore.Source().ReadAll(adminUserContext)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, allSources, 1)
|
||||
|
||||
// The merged git config picks up the Source URL/auth.
|
||||
readSrc, artifact, err := workflows.GitSourceAndArtifactForStack(builder.dataStore, adminUserContext, builder.stack.WorkflowID, builder.stack.ID)
|
||||
require.NoError(t, err)
|
||||
merged := workflows.MergeSourceAndFile(readSrc, artifact)
|
||||
assert.Equal(t, "https://github.com/org/private-repo", merged.URL)
|
||||
assert.Equal(t, "refs/heads/main", merged.ReferenceName)
|
||||
require.NotNil(t, merged.Authentication)
|
||||
assert.Equal(t, "git-user", merged.Authentication.Username)
|
||||
}
|
||||
|
||||
func TestGitMethodStackBuilder_WithSourceID_PersistsHealthyStatusOnSuccess(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := newGitMethodBuilder(t, "abc123")
|
||||
builder.stack.ID = 1
|
||||
|
||||
src := &portainer.Source{
|
||||
Name: "my-repo",
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.GitSource{URL: "https://github.com/org/private-repo"},
|
||||
}
|
||||
require.NoError(t, builder.dataStore.Source().Create(adminUserContext, src))
|
||||
|
||||
payload := &StackPayload{
|
||||
RepositoryConfigPayload: RepositoryConfigPayload{
|
||||
SourceID: src.ID,
|
||||
ReferenceName: "refs/heads/main",
|
||||
},
|
||||
}
|
||||
|
||||
err := builder.prepare(t.Context(), payload, portainer.UserID(1))
|
||||
require.NoError(t, err)
|
||||
|
||||
updatedSrc, err := builder.dataStore.Source().Read(adminUserContext, src.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, portainer.SourceStatusHealthy, updatedSrc.Status)
|
||||
assert.NotZero(t, updatedSrc.LastSync)
|
||||
}
|
||||
|
||||
func TestGitMethodStackBuilder_WithSourceID_PersistsErrorStatusOnCloneFailure(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := newGitMethodBuilder(t, "abc123")
|
||||
cloneErr := errors.New("failed to clone")
|
||||
builder.gitService = testhelpers.NewGitService(cloneErr, "abc123")
|
||||
builder.stack.ID = 1
|
||||
|
||||
src := &portainer.Source{
|
||||
Name: "my-repo",
|
||||
Type: portainer.SourceTypeGit,
|
||||
Git: &gittypes.GitSource{URL: "https://github.com/org/private-repo"},
|
||||
}
|
||||
require.NoError(t, builder.dataStore.Source().Create(adminUserContext, src))
|
||||
|
||||
payload := &StackPayload{
|
||||
RepositoryConfigPayload: RepositoryConfigPayload{
|
||||
SourceID: src.ID,
|
||||
ReferenceName: "refs/heads/main",
|
||||
},
|
||||
}
|
||||
|
||||
err := builder.prepare(t.Context(), payload, portainer.UserID(1))
|
||||
require.Error(t, err)
|
||||
|
||||
updatedSrc, err := builder.dataStore.Source().Read(adminUserContext, src.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, portainer.SourceStatusError, updatedSrc.Status)
|
||||
assert.Contains(t, updatedSrc.StatusError, cloneErr.Error())
|
||||
assert.Zero(t, updatedSrc.LastSync)
|
||||
}
|
||||
|
||||
func TestGitMethodStackBuilder_WithMissingSourceID_ReturnsError(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := newGitMethodBuilder(t, "abc123")
|
||||
builder.stack.ID = 2
|
||||
|
||||
payload := &StackPayload{
|
||||
RepositoryConfigPayload: RepositoryConfigPayload{
|
||||
SourceID: portainer.SourceID(999), // does not exist
|
||||
},
|
||||
}
|
||||
|
||||
err := builder.prepare(context.Background(), payload, portainer.UserID(1))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestGitMethodStackBuilder_WithoutSourceID_InlinePathStillWorks(t *testing.T) {
|
||||
t.Parallel()
|
||||
builder := newGitMethodBuilder(t, "feedcafe")
|
||||
builder.stack.ID = 4
|
||||
|
||||
payload := &StackPayload{
|
||||
RepositoryConfigPayload: RepositoryConfigPayload{
|
||||
URL: "https://github.com/org/public-repo",
|
||||
ReferenceName: "refs/heads/main",
|
||||
},
|
||||
}
|
||||
|
||||
err := builder.prepare(context.Background(), payload, portainer.UserID(1))
|
||||
require.NoError(t, err)
|
||||
|
||||
// A Source was created via the inline path.
|
||||
allSources, err := builder.dataStore.Source().ReadAll(adminUserContext)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, allSources, 1)
|
||||
assert.Equal(t, "https://github.com/org/public-repo", allSources[0].Git.URL)
|
||||
}
|
||||
|
||||
// builderWorkflowSourceID returns the first SourceID referenced by the Workflow Artifact for this stack.
|
||||
func builderWorkflowSourceID(t *testing.T, builder *GitMethodStackBuilder) portainer.SourceID {
|
||||
t.Helper()
|
||||
require.NotZero(t, builder.stack.WorkflowID)
|
||||
|
||||
wf, err := builder.dataStore.Workflow().Read(builder.stack.WorkflowID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, wf.Artifacts, 1)
|
||||
require.Len(t, wf.Artifacts[0].Files, 1)
|
||||
return wf.Artifacts[0].Files[0].SourceID
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
)
|
||||
|
||||
// StackPayload contains all the fields for creating a stack with all kinds of methods
|
||||
type StackPayload struct {
|
||||
// Name of the stack
|
||||
Name string `example:"myStack" validate:"required"`
|
||||
// Swarm cluster identifier
|
||||
SwarmID string `example:"jpofkc0i9uo9wtx1zesuk649w" validate:"required"`
|
||||
// Stack file data
|
||||
StackFileContent []byte
|
||||
Webhook string
|
||||
// A list of environment(endpoint) variables used during stack deployment
|
||||
Env []portainer.Pair
|
||||
// Optional GitOps update configuration
|
||||
AutoUpdate *portainer.AutoUpdateSettings
|
||||
// Whether the stack is from a app template
|
||||
FromAppTemplate bool `example:"false"`
|
||||
// Kubernetes stack name
|
||||
StackName string
|
||||
// Kubernetes stack namespace
|
||||
Namespace string
|
||||
// Path to the k8s Stack file. Used by k8s git repository method
|
||||
ManifestFile string
|
||||
// URL to the k8s Stack file. Used by k8s git repository method
|
||||
ManifestURL string
|
||||
// Path to the Stack file inside the Git repository
|
||||
ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||
// Applicable when deploying with multiple stack files
|
||||
AdditionalFiles []string `example:"[nz.compose.yml, uat.compose.yml]"`
|
||||
// Git repository configuration of a stack
|
||||
RepositoryConfigPayload
|
||||
}
|
||||
|
||||
type RepositoryConfigPayload struct {
|
||||
// SourceID references an existing Source.
|
||||
// When non-zero, only ReferenceName is still applied.
|
||||
SourceID portainer.SourceID
|
||||
// URL of a Git repository hosting the Stack file
|
||||
URL string `example:"https://github.com/openfaas/faas"`
|
||||
// Reference name of a Git repository hosting the Stack file
|
||||
ReferenceName string `example:"refs/heads/master"`
|
||||
// Use basic authentication to clone the Git repository
|
||||
Authentication bool `example:"true"`
|
||||
// Username used in basic authentication. Required when RepositoryAuthentication is true
|
||||
Username string `example:"myGitUsername"`
|
||||
// Password used in basic authentication. Required when RepositoryAuthentication is true
|
||||
Password string `example:"myGitPassword"`
|
||||
// TLSSkipVerify skips SSL verification when cloning the Git repository
|
||||
TLSSkipVerify bool `example:"false"`
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
)
|
||||
|
||||
type SwarmStackFileBuilder struct {
|
||||
StackBuilder
|
||||
SecurityContext *security.RestrictedRequestContext
|
||||
}
|
||||
|
||||
// CreateSwarmStackFileBuilder creates a builder for swarm stacks deployed from a file (either uploaded or provided as text content).
|
||||
func CreateSwarmStackFileBuilder(securityContext *security.RestrictedRequestContext,
|
||||
dataStore dataservices.DataStore,
|
||||
fileService portainer.FileService,
|
||||
stackDeployer deployments.StackDeployer) *SwarmStackFileBuilder {
|
||||
|
||||
return &SwarmStackFileBuilder{
|
||||
StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer),
|
||||
SecurityContext: securityContext,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SwarmStackFileBuilder) prepare(_ context.Context, payload *StackPayload, userID portainer.UserID) error {
|
||||
b.stack.Name = payload.Name
|
||||
b.stack.Type = portainer.DockerSwarmStack
|
||||
b.stack.SwarmID = payload.SwarmID
|
||||
b.stack.EntryPoint = filesystem.ComposeFileDefaultName
|
||||
b.stack.Env = payload.Env
|
||||
b.stack.FromAppTemplate = payload.FromAppTemplate
|
||||
|
||||
if err := b.initCreatedBy(userID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.storeStackFile(payload.StackFileContent)
|
||||
}
|
||||
|
||||
func (b *SwarmStackFileBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error {
|
||||
if err := b.initSwarmDeployment(b.SecurityContext, endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.deploymentConfiger.Deploy(ctx)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package stackbuilders
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/scheduler"
|
||||
"github.com/portainer/portainer/api/stacks/deployments"
|
||||
)
|
||||
|
||||
type SwarmStackGitBuilder struct {
|
||||
GitMethodStackBuilder
|
||||
SecurityContext *security.RestrictedRequestContext
|
||||
}
|
||||
|
||||
// CreateSwarmStackGitBuilder creates a builder for the swarm stack that will be deployed by git repository method
|
||||
func CreateSwarmStackGitBuilder(securityContext *security.RestrictedRequestContext,
|
||||
dataStore dataservices.DataStore,
|
||||
fileService portainer.FileService,
|
||||
gitService portainer.GitService,
|
||||
scheduler *scheduler.Scheduler,
|
||||
stackDeployer deployments.StackDeployer) *SwarmStackGitBuilder {
|
||||
|
||||
return &SwarmStackGitBuilder{
|
||||
GitMethodStackBuilder: GitMethodStackBuilder{
|
||||
StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer),
|
||||
gitService: gitService,
|
||||
scheduler: scheduler,
|
||||
},
|
||||
SecurityContext: securityContext,
|
||||
}
|
||||
}
|
||||
|
||||
func (b *SwarmStackGitBuilder) prepare(ctx context.Context, payload *StackPayload, userID portainer.UserID) error {
|
||||
b.stack.Name = payload.Name
|
||||
b.stack.Type = portainer.DockerSwarmStack
|
||||
b.stack.SwarmID = payload.SwarmID
|
||||
b.stack.EntryPoint = payload.ComposeFile
|
||||
b.stack.FromAppTemplate = payload.FromAppTemplate
|
||||
b.stack.Env = payload.Env
|
||||
|
||||
return b.GitMethodStackBuilder.prepare(ctx, payload, userID)
|
||||
}
|
||||
|
||||
// deploy creates deployment configuration for swarm stack
|
||||
func (b *SwarmStackGitBuilder) deploy(ctx context.Context, endpoint *portainer.Endpoint) error {
|
||||
if err := b.initSwarmDeployment(b.SecurityContext, endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return b.deploymentConfiger.Deploy(ctx)
|
||||
}
|
||||
Reference in New Issue
Block a user