chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
@@ -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)
}
+93
View File
@@ -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")
}
}
+164
View File
@@ -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)
}
+140
View File
@@ -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
}
+54
View File
@@ -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)
}