diff --git a/api/cmd/portainer/main.go b/api/cmd/portainer/main.go index c5d6a0ce5..ed503ad65 100644 --- a/api/cmd/portainer/main.go +++ b/api/cmd/portainer/main.go @@ -41,7 +41,7 @@ import ( "github.com/portainer/portainer/api/ldap" "github.com/portainer/portainer/api/oauth" "github.com/portainer/portainer/api/scheduler" - "github.com/portainer/portainer/api/stacks" + "github.com/portainer/portainer/api/stacks/deployments" "github.com/rs/zerolog/log" ) @@ -716,8 +716,8 @@ func buildServer(flags *portainer.CLIFlags) portainer.Server { } scheduler := scheduler.NewScheduler(shutdownCtx) - stackDeployer := stacks.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer) - stacks.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService) + stackDeployer := deployments.NewStackDeployer(swarmStackManager, composeStackManager, kubernetesDeployer) + deployments.StartStackSchedules(scheduler, stackDeployer, dataStore, gitService) sslDBSettings, err := dataStore.SSLSettings().Settings() if err != nil { diff --git a/api/datastore/migrator/migrate_dbversion26.go b/api/datastore/migrator/migrate_dbversion26.go index 389375876..64b80261a 100644 --- a/api/datastore/migrator/migrate_dbversion26.go +++ b/api/datastore/migrator/migrate_dbversion26.go @@ -3,7 +3,7 @@ package migrator import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices/errors" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/stackutils" "github.com/rs/zerolog/log" ) diff --git a/api/exec/compose_stack.go b/api/exec/compose_stack.go index 990ac94bb..cb60f519c 100644 --- a/api/exec/compose_stack.go +++ b/api/exec/compose_stack.go @@ -13,7 +13,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy" "github.com/portainer/portainer/api/http/proxy/factory" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/stackutils" "github.com/pkg/errors" ) @@ -58,7 +58,7 @@ func (manager *ComposeStackManager) Up(ctx context.Context, stack *portainer.Sta return errors.Wrap(err, "failed to create env file") } - filePaths := stackutils.GetStackFilePaths(stack) + filePaths := stackutils.GetStackFilePaths(stack, false) err = manager.deployer.Deploy(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile, forceRereate) return errors.Wrap(err, "failed to deploy a stack") } @@ -78,7 +78,7 @@ func (manager *ComposeStackManager) Down(ctx context.Context, stack *portainer.S return errors.Wrap(err, "failed to create env file") } - filePaths := stackutils.GetStackFilePaths(stack) + filePaths := stackutils.GetStackFilePaths(stack, false) err = manager.deployer.Remove(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile) return errors.Wrap(err, "failed to remove a stack") @@ -100,7 +100,7 @@ func (manager *ComposeStackManager) Pull(ctx context.Context, stack *portainer.S return errors.Wrap(err, "failed to create env file") } - filePaths := stackutils.GetStackFilePaths(stack) + filePaths := stackutils.GetStackFilePaths(stack, false) err = manager.deployer.Pull(ctx, stack.ProjectPath, url, stack.Name, filePaths, envFile) return errors.Wrap(err, "failed to pull images of the stack") } diff --git a/api/exec/swarm_stack.go b/api/exec/swarm_stack.go index 07818145d..78686333c 100644 --- a/api/exec/swarm_stack.go +++ b/api/exec/swarm_stack.go @@ -14,7 +14,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/internal/registryutils" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/stackutils" ) // SwarmStackManager represents a service for managing stacks. @@ -90,7 +90,7 @@ func (manager *SwarmStackManager) Logout(endpoint *portainer.Endpoint) error { // Deploy executes the docker stack deploy command. func (manager *SwarmStackManager) Deploy(stack *portainer.Stack, prune bool, pullImage bool, endpoint *portainer.Endpoint) error { - filePaths := stackutils.GetStackFilePaths(stack) + filePaths := stackutils.GetStackFilePaths(stack, false) command, args, err := manager.prepareDockerCommandAndArgs(manager.binaryPath, manager.configPath, endpoint) if err != nil { return err diff --git a/api/git/azure.go b/api/git/azure.go index c46b5ba9d..7aa7b5097 100644 --- a/api/git/azure.go +++ b/api/git/azure.go @@ -17,6 +17,7 @@ import ( githttp "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/pkg/errors" "github.com/portainer/portainer/api/archive" + gittypes "github.com/portainer/portainer/api/git/types" ) const ( @@ -483,9 +484,9 @@ func (a *azureClient) listFiles(ctx context.Context, opt fetchOption) ([]string, func checkAzureStatusCode(err error, code int) error { if code == http.StatusNotFound { - return ErrIncorrectRepositoryURL + return gittypes.ErrIncorrectRepositoryURL } else if code == http.StatusUnauthorized || code == http.StatusNonAuthoritativeInfo { - return ErrAuthenticationFailure + return gittypes.ErrAuthenticationFailure } return err } diff --git a/api/git/azure_integration_test.go b/api/git/azure_integration_test.go index 876e59496..8c22a3e91 100644 --- a/api/git/azure_integration_test.go +++ b/api/git/azure_integration_test.go @@ -9,6 +9,7 @@ import ( "time" _ "github.com/joho/godotenv/autoload" + gittypes "github.com/portainer/portainer/api/git/types" "github.com/stretchr/testify/assert" ) @@ -145,7 +146,7 @@ func TestService_ListFiles_Azure(t *testing.T) { extensions: []string{}, expect: expectResult{ shouldFail: true, - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -161,7 +162,7 @@ func TestService_ListFiles_Azure(t *testing.T) { extensions: []string{}, expect: expectResult{ shouldFail: true, - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -240,7 +241,7 @@ func TestService_ListFiles_Azure(t *testing.T) { extensions: []string{}, expect: expectResult{ shouldFail: true, - err: ErrIncorrectRepositoryURL, + err: gittypes.ErrIncorrectRepositoryURL, }, }, } diff --git a/api/git/azure_test.go b/api/git/azure_test.go index dfe81a440..bc80d1de4 100644 --- a/api/git/azure_test.go +++ b/api/git/azure_test.go @@ -7,6 +7,7 @@ import ( "net/url" "testing" + gittypes "github.com/portainer/portainer/api/git/types" "github.com/stretchr/testify/assert" ) @@ -466,7 +467,7 @@ func Test_listRefs_azure(t *testing.T) { password: "test-token", }, expect: expectResult{ - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -477,7 +478,7 @@ func Test_listRefs_azure(t *testing.T) { password: "", }, expect: expectResult{ - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -488,7 +489,7 @@ func Test_listRefs_azure(t *testing.T) { password: accessToken, }, expect: expectResult{ - err: ErrIncorrectRepositoryURL, + err: gittypes.ErrIncorrectRepositoryURL, }, }, } @@ -540,7 +541,7 @@ func Test_listFiles_azure(t *testing.T) { }, expect: expectResult{ shouldFail: true, - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -555,7 +556,7 @@ func Test_listFiles_azure(t *testing.T) { }, expect: expectResult{ shouldFail: true, - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -599,7 +600,7 @@ func Test_listFiles_azure(t *testing.T) { }, expect: expectResult{ shouldFail: true, - err: ErrIncorrectRepositoryURL, + err: gittypes.ErrIncorrectRepositoryURL, }, }, } diff --git a/api/git/git.go b/api/git/git.go index 5f187cbb3..436e3d2fe 100644 --- a/api/git/git.go +++ b/api/git/git.go @@ -6,14 +6,14 @@ import ( "path/filepath" "strings" - "github.com/pkg/errors" - "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" githttp "github.com/go-git/go-git/v5/plumbing/transport/http" "github.com/go-git/go-git/v5/storage/memory" + "github.com/pkg/errors" + gittypes "github.com/portainer/portainer/api/git/types" ) type gitClient struct { @@ -41,7 +41,7 @@ func (c *gitClient) download(ctx context.Context, dst string, opt cloneOption) e if err != nil { if err.Error() == "authentication required" { - return ErrAuthenticationFailure + return gittypes.ErrAuthenticationFailure } return errors.Wrap(err, "failed to clone git repository") } @@ -66,7 +66,7 @@ func (c *gitClient) latestCommitID(ctx context.Context, opt fetchOption) (string refs, err := remote.List(listOptions) if err != nil { if err.Error() == "authentication required" { - return "", ErrAuthenticationFailure + return "", gittypes.ErrAuthenticationFailure } return "", errors.Wrap(err, "failed to list repository refs") } @@ -172,9 +172,9 @@ func (c *gitClient) listFiles(ctx context.Context, opt fetchOption) ([]string, e func checkGitError(err error) error { errMsg := err.Error() if errMsg == "repository not found" { - return ErrIncorrectRepositoryURL + return gittypes.ErrIncorrectRepositoryURL } else if errMsg == "authentication required" { - return ErrAuthenticationFailure + return gittypes.ErrAuthenticationFailure } return err } diff --git a/api/git/git_integration_test.go b/api/git/git_integration_test.go index 977d5ba80..77f2a6429 100644 --- a/api/git/git_integration_test.go +++ b/api/git/git_integration_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + gittypes "github.com/portainer/portainer/api/git/types" "github.com/stretchr/testify/assert" ) @@ -99,7 +100,7 @@ func TestService_ListFiles_GitHub(t *testing.T) { extensions: []string{}, expect: expectResult{ shouldFail: true, - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -115,7 +116,7 @@ func TestService_ListFiles_GitHub(t *testing.T) { extensions: []string{}, expect: expectResult{ shouldFail: true, - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -194,7 +195,7 @@ func TestService_ListFiles_GitHub(t *testing.T) { extensions: []string{}, expect: expectResult{ shouldFail: true, - err: ErrIncorrectRepositoryURL, + err: gittypes.ErrIncorrectRepositoryURL, }, }, } diff --git a/api/git/git_test.go b/api/git/git_test.go index ad97d6afb..5f129d4f5 100644 --- a/api/git/git_test.go +++ b/api/git/git_test.go @@ -6,11 +6,11 @@ import ( "path/filepath" "testing" - "github.com/portainer/portainer/api/archive" - "github.com/go-git/go-git/v5" "github.com/go-git/go-git/v5/plumbing/object" "github.com/pkg/errors" + "github.com/portainer/portainer/api/archive" + gittypes "github.com/portainer/portainer/api/git/types" "github.com/stretchr/testify/assert" ) @@ -148,7 +148,7 @@ func Test_listRefsPrivateRepository(t *testing.T) { password: "test-token", }, expect: expectResult{ - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -159,7 +159,7 @@ func Test_listRefsPrivateRepository(t *testing.T) { password: "", }, expect: expectResult{ - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -170,7 +170,7 @@ func Test_listRefsPrivateRepository(t *testing.T) { password: accessToken, }, expect: expectResult{ - err: ErrIncorrectRepositoryURL, + err: gittypes.ErrIncorrectRepositoryURL, }, }, } @@ -222,7 +222,7 @@ func Test_listFilesPrivateRepository(t *testing.T) { }, expect: expectResult{ shouldFail: true, - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -237,7 +237,7 @@ func Test_listFilesPrivateRepository(t *testing.T) { }, expect: expectResult{ shouldFail: true, - err: ErrAuthenticationFailure, + err: gittypes.ErrAuthenticationFailure, }, }, { @@ -281,7 +281,7 @@ func Test_listFilesPrivateRepository(t *testing.T) { }, expect: expectResult{ shouldFail: true, - err: ErrIncorrectRepositoryURL, + err: gittypes.ErrIncorrectRepositoryURL, }, }, } diff --git a/api/git/service.go b/api/git/service.go index f7cd55222..0ce24596a 100644 --- a/api/git/service.go +++ b/api/git/service.go @@ -2,7 +2,6 @@ package git import ( "context" - "errors" "log" "strings" "sync" @@ -12,9 +11,6 @@ import ( ) var ( - ErrIncorrectRepositoryURL = errors.New("Git repository could not be found, please ensure that the URL is correct.") - ErrAuthenticationFailure = errors.New("Authentication failed, please ensure that the git credentials are correct.") - REPOSITORY_CACHE_SIZE = 4 REPOSITORY_CACHE_TTL = 5 * time.Minute ) diff --git a/api/git/types/types.go b/api/git/types/types.go index 5e774d291..21f55a699 100644 --- a/api/git/types/types.go +++ b/api/git/types/types.go @@ -1,5 +1,12 @@ package gittypes +import "errors" + +var ( + ErrIncorrectRepositoryURL = errors.New("Git repository could not be found, please ensure that the URL is correct.") + ErrAuthenticationFailure = errors.New("Authentication failed, please ensure that the git credentials are correct.") +) + // RepoConfig represents a configuration for a repo type RepoConfig struct { // The repo url diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go index 48534bebd..c026a540e 100644 --- a/api/http/handler/customtemplates/customtemplate_create.go +++ b/api/http/handler/customtemplates/customtemplate_create.go @@ -9,16 +9,15 @@ import ( "regexp" "strconv" + "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" - "github.com/portainer/portainer/api/git" + gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" - - "github.com/asaskevich/govalidator" "github.com/rs/zerolog/log" ) @@ -292,7 +291,7 @@ func (handler *Handler) createCustomTemplateFromGitRepository(r *http.Request) ( err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword) if err != nil { - if err == git.ErrAuthenticationFailure { + if err == gittypes.ErrAuthenticationFailure { return nil, fmt.Errorf("invalid git credential") } return nil, err diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index 344eef71f..1958506ca 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -3,16 +3,15 @@ package stacks import ( "fmt" "net/http" - "strconv" - "time" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" - gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackbuilders" + "github.com/portainer/portainer/api/stacks/stackutils" "github.com/asaskevich/govalidator" "github.com/pkg/errors" @@ -40,6 +39,16 @@ func (payload *composeStackFromFileContentPayload) Validate(r *http.Request) err } return nil } + +func createStackPayloadFromComposeFileContentPayload(name string, fileContent string, env []portainer.Pair, fromAppTemplate bool) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + StackFileContent: fileContent, + Env: env, + FromAppTemplate: fromAppTemplate, + } +} + func (handler *Handler) checkAndCleanStackDupFromSwarm(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID, stack *portainer.Stack) error { resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) if err != nil { @@ -48,7 +57,7 @@ func (handler *Handler) checkAndCleanStackDupFromSwarm(w http.ResponseWriter, r // stop scheduler updates of the stack before removal if stack.AutoUpdate != nil { - stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) } err = handler.DataStore.Stack().DeleteStack(stack.ID) @@ -108,47 +117,24 @@ func (handler *Handler) createComposeStackFromFileContent(w http.ResponseWriter, } } - stackID := handler.DataStore.Stack().GetNextIdentifier() - stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerComposeStack, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: payload.Env, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), - FromAppTemplate: payload.FromAppTemplate, - } - - stackFolder := strconv.Itoa(int(stack.ID)) - projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to persist Compose file on disk", err) - } - stack.ProjectPath = projectPath - - doCleanUp := true - defer handler.cleanUp(stack, &doCleanUp) - - config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, false) - if configErr != nil { - return configErr + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - err = handler.deployComposeStack(config, false) - if err != nil { - return httperror.InternalServerError(err.Error(), err) + stackPayload := createStackPayloadFromComposeFileContentPayload(payload.Name, payload.StackFileContent, payload.Env, payload.FromAppTemplate) + + composeStackBuilder := stackbuilders.CreateComposeStackFileContentBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.StackDeployer) + + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(composeStackBuilder) + stack, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint) + if httpErr != nil { + return httpErr } - stack.CreatedBy = config.user.Username - - err = handler.DataStore.Stack().Create(stack) - if err != nil { - return httperror.InternalServerError("Unable to persist the stack inside the database", err) - } - - doCleanUp = false return handler.decorateStackResponse(w, stack, userID) } @@ -177,6 +163,24 @@ type composeStackFromGitRepositoryPayload struct { FromAppTemplate bool `example:"false"` } +func createStackPayloadFromComposeGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.StackAutoUpdate, env []portainer.Pair, fromAppTemplate bool) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{ + URL: repoUrl, + ReferenceName: repoReference, + Authentication: repoAuthentication, + Username: repoUsername, + Password: repoPassword, + }, + ComposeFile: composeFile, + AdditionalFiles: additionalFiles, + AutoUpdate: autoUpdate, + Env: env, + FromAppTemplate: fromAppTemplate, + } +} + func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.Name) { return errors.New("Invalid stack name") @@ -187,7 +191,7 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) { return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") } - if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil { + if err := stackutils.ValidateStackAutoUpdate(payload.AutoUpdate); err != nil { return err } return nil @@ -234,81 +238,40 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite return httperror.InternalServerError("Unable to check for webhook ID collision", err) } if !isUnique { - return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: errWebhookIDAlreadyExists} + return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: stackutils.ErrWebhookIDAlreadyExists} } } - stackID := handler.DataStore.Stack().GetNextIdentifier() - stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerComposeStack, - EndpointID: endpoint.ID, - EntryPoint: payload.ComposeFile, - AdditionalFiles: payload.AdditionalFiles, - AutoUpdate: payload.AutoUpdate, - Env: payload.Env, - FromAppTemplate: payload.FromAppTemplate, - GitConfig: &gittypes.RepoConfig{ - URL: payload.RepositoryURL, - ReferenceName: payload.RepositoryReferenceName, - ConfigFilePath: payload.ComposeFile, - }, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), - } - - if payload.RepositoryAuthentication { - stack.GitConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: payload.RepositoryPassword, - } - } - - projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) - stack.ProjectPath = projectPath - - doCleanUp := true - defer handler.cleanUp(stack, &doCleanUp) - - err = handler.clone(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to clone git repository", err) + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - commitID, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) - if err != nil { - return httperror.InternalServerError("Unable to fetch git repository id", err) - } - stack.GitConfig.ConfigHash = commitID + stackPayload := createStackPayloadFromComposeGitPayload(payload.Name, + payload.RepositoryURL, + payload.RepositoryReferenceName, + payload.RepositoryUsername, + payload.RepositoryPassword, + payload.RepositoryAuthentication, + payload.ComposeFile, + payload.AdditionalFiles, + payload.AutoUpdate, + payload.Env, + payload.FromAppTemplate) - config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, false) - if configErr != nil { - return configErr + composeStackBuilder := stackbuilders.CreateComposeStackGitBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.GitService, + handler.Scheduler, + handler.StackDeployer) + + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(composeStackBuilder) + stack, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint) + if httpErr != nil { + return httpErr } - err = handler.deployComposeStack(config, false) - if err != nil { - return httperror.InternalServerError(err.Error(), err) - } - - if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { - jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) - if e != nil { - return e - } - - stack.AutoUpdate.JobID = jobID - } - - stack.CreatedBy = config.user.Username - - err = handler.DataStore.Stack().Create(stack) - if err != nil { - return httperror.InternalServerError("Unable to persist the stack inside the database", err) - } - - doCleanUp = false return handler.decorateStackResponse(w, stack, userID) } @@ -318,6 +281,14 @@ type composeStackFromFileUploadPayload struct { Env []portainer.Pair } +func createStackPayloadFromComposeFileUploadPayload(name string, fileContentBytes []byte, env []portainer.Pair) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + StackFileContentBytes: fileContentBytes, + Env: env, + } +} + func decodeRequestForm(r *http.Request) (*composeStackFromFileUploadPayload, error) { payload := &composeStackFromFileUploadPayload{} name, err := request.RetrieveMultiPartFormValue(r, "Name", false) @@ -371,121 +342,23 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter, } } - stackID := handler.DataStore.Stack().GetNextIdentifier() - stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerComposeStack, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: payload.Env, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), - } - - stackFolder := strconv.Itoa(int(stack.ID)) - projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, payload.StackFileContent) - if err != nil { - return httperror.InternalServerError("Unable to persist Compose file on disk", err) - } - stack.ProjectPath = projectPath - - doCleanUp := true - defer handler.cleanUp(stack, &doCleanUp) - - config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, false) - if configErr != nil { - return configErr - } - - err = handler.deployComposeStack(config, false) - if err != nil { - return httperror.InternalServerError(err.Error(), err) - } - - stack.CreatedBy = config.user.Username - - err = handler.DataStore.Stack().Create(stack) - if err != nil { - return httperror.InternalServerError("Unable to persist the stack inside the database", err) - } - - doCleanUp = false - return handler.decorateStackResponse(w, stack, userID) -} - -type composeStackDeploymentConfig struct { - stack *portainer.Stack - endpoint *portainer.Endpoint - registries []portainer.Registry - isAdmin bool - user *portainer.User - forcePullImage bool -} - -func (handler *Handler) createComposeDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, forcePullImage bool) (*composeStackDeploymentConfig, *httperror.HandlerError) { securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - user, err := handler.DataStore.User().User(securityContext.UserID) - if err != nil { - return nil, httperror.InternalServerError("Unable to load user information from the database", err) + stackPayload := createStackPayloadFromComposeFileUploadPayload(payload.Name, payload.StackFileContent, payload.Env) + + composeStackBuilder := stackbuilders.CreateComposeStackFileUploadBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.StackDeployer) + + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(composeStackBuilder) + stack, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint) + if httpErr != nil { + return httpErr } - registries, err := handler.DataStore.Registry().Registries() - if err != nil { - return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err) - } - - filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) - - config := &composeStackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - registries: filteredRegistries, - isAdmin: securityContext.IsAdmin, - user: user, - forcePullImage: forcePullImage, - } - - return config, nil -} - -// TODO: libcompose uses credentials store into a config.json file to pull images from -// private registries. Right now the only solution is to re-use the embedded Docker binary -// to login/logout, which will generate the required data in the config.json file and then -// clean it. Hence the use of the mutex. -// We should contribute to libcompose to support authentication without using the config.json file. -func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig, forceCreate bool) error { - isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID) - if err != nil { - return errors.Wrap(err, "failed to check user priviliges deploying a stack") - } - - securitySettings := &config.endpoint.SecuritySettings - - if (!securitySettings.AllowBindMountsForRegularUsers || - !securitySettings.AllowPrivilegedModeForRegularUsers || - !securitySettings.AllowHostNamespaceForRegularUsers || - !securitySettings.AllowDeviceMappingForRegularUsers || - !securitySettings.AllowSysctlSettingForRegularUsers || - !securitySettings.AllowContainerCapabilitiesForRegularUsers) && - !isAdminOrEndpointAdmin { - - for _, file := range append([]string{config.stack.EntryPoint}, config.stack.AdditionalFiles...) { - stackContent, err := handler.FileService.GetFileContent(config.stack.ProjectPath, file) - if err != nil { - return errors.Wrapf(err, "failed to get stack file content `%q`", file) - } - - err = handler.isValidStackFile(stackContent, securitySettings) - if err != nil { - return errors.Wrap(err, "compose file is invalid") - } - } - } - - return handler.StackDeployer.DeployComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, forceCreate) + return handler.decorateStackResponse(w, stack, userID) } diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 50429f5e5..09cc8f04a 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -3,10 +3,6 @@ package stacks import ( "fmt" "net/http" - "os" - "regexp" - "strconv" - "time" "github.com/asaskevich/govalidator" "github.com/pkg/errors" @@ -15,11 +11,10 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/filesystem" - gittypes "github.com/portainer/portainer/api/git/types" - "github.com/portainer/portainer/api/http/client" - "github.com/portainer/portainer/api/internal/stackutils" k "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackbuilders" + "github.com/portainer/portainer/api/stacks/stackutils" ) type kubernetesStringDeploymentPayload struct { @@ -29,6 +24,15 @@ type kubernetesStringDeploymentPayload struct { StackFileContent string } +func createStackPayloadFromK8sFileContentPayload(name, namespace, fileContent string, composeFormat bool) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + StackName: name, + Namespace: namespace, + StackFileContent: fileContent, + ComposeFormat: composeFormat, + } +} + type kubernetesGitDeploymentPayload struct { StackName string ComposeFormat bool @@ -43,6 +47,24 @@ type kubernetesGitDeploymentPayload struct { AutoUpdate *portainer.StackAutoUpdate } +func createStackPayloadFromK8sGitPayload(name, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication, composeFormat bool, namespace, manifest string, additionalFiles []string, autoUpdate *portainer.StackAutoUpdate) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + StackName: name, + RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{ + URL: repoUrl, + ReferenceName: repoReference, + Authentication: repoAuthentication, + Username: repoUsername, + Password: repoPassword, + }, + Namespace: namespace, + ComposeFormat: composeFormat, + ManifestFile: manifest, + AdditionalFiles: additionalFiles, + AutoUpdate: autoUpdate, + } +} + type kubernetesManifestURLDeploymentPayload struct { StackName string Namespace string @@ -50,6 +72,15 @@ type kubernetesManifestURLDeploymentPayload struct { ManifestURL string } +func createStackPayloadFromK8sUrlPayload(name, namespace, manifestUrl string, composeFormat bool) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + StackName: name, + Namespace: namespace, + ManifestURL: manifestUrl, + ComposeFormat: composeFormat, + } +} + func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.StackFileContent) { return errors.New("Invalid stack file content") @@ -70,7 +101,7 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.ManifestFile) { return errors.New("Invalid manifest file in repository") } - if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil { + if err := stackutils.ValidateStackAutoUpdate(payload.AutoUpdate); err != nil { return err } if govalidator.IsNull(payload.StackName) { @@ -93,12 +124,6 @@ type createKubernetesStackResponse struct { Output string `json:"Output"` } -// convert string to valid kubernetes label by replacing invalid characters with periods -func sanitizeLabel(value string) string { - re := regexp.MustCompile(`[^A-Za-z0-9\.\-\_]+`) - return re.ReplaceAllString(value, ".") -} - func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { var payload kubernetesStringDeploymentPayload if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { @@ -114,60 +139,27 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit return httperror.InternalServerError("Unable to check for name collision", err) } if !isUnique { - return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), Err: errStackAlreadyExists} + return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), Err: stackutils.ErrStackAlreadyExists} } - stackID := handler.DataStore.Stack().GetNextIdentifier() - stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Type: portainer.KubernetesStack, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ManifestFileDefaultName, - Name: payload.StackName, - Namespace: payload.Namespace, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), - CreatedBy: sanitizeLabel(user.Username), - IsComposeFormat: payload.ComposeFormat, - } + stackPayload := createStackPayloadFromK8sFileContentPayload(payload.StackName, payload.Namespace, payload.StackFileContent, payload.ComposeFormat) - stackFolder := strconv.Itoa(int(stack.ID)) + k8sStackBuilder := stackbuilders.CreateK8sStackFileContentBuilder(handler.DataStore, + handler.FileService, + handler.StackDeployer, + handler.KubernetesDeployer, + user) - projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) - if err != nil { - fileType := "Manifest" - if stack.IsComposeFormat { - fileType = "Compose" - } - errMsg := fmt.Sprintf("Unable to persist Kubernetes %s file on disk", fileType) - return httperror.InternalServerError(errMsg, err) - } - stack.ProjectPath = projectPath - - doCleanUp := true - defer handler.cleanUp(stack, &doCleanUp) - - output, err := handler.deployKubernetesStack(user.ID, endpoint, stack, k.KubeAppLabels{ - StackID: stackID, - StackName: stack.Name, - Owner: sanitizeLabel(stack.CreatedBy), - Kind: "content", - }) - - if err != nil { - return httperror.InternalServerError("Unable to deploy Kubernetes stack", err) - } - - err = handler.DataStore.Stack().Create(stack) - if err != nil { - return httperror.InternalServerError("Unable to persist the Kubernetes stack inside the database", err) + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder) + _, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint) + if httpErr != nil { + return httpErr } resp := &createKubernetesStackResponse{ - Output: output, + Output: k8sStackBuilder.GetResponse(), } - doCleanUp = false return response.JSON(w, resp) } @@ -186,7 +178,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr return httperror.InternalServerError("Unable to check for name collision", err) } if !isUnique { - return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), Err: errStackAlreadyExists} + return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), Err: stackutils.ErrStackAlreadyExists} } //make sure the webhook ID is unique @@ -196,92 +188,40 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr return httperror.InternalServerError("Unable to check for webhook ID collision", err) } if !isUnique { - return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: errWebhookIDAlreadyExists} + return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: stackutils.ErrWebhookIDAlreadyExists} } } - stackID := handler.DataStore.Stack().GetNextIdentifier() - stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Type: portainer.KubernetesStack, - EndpointID: endpoint.ID, - EntryPoint: payload.ManifestFile, - GitConfig: &gittypes.RepoConfig{ - URL: payload.RepositoryURL, - ReferenceName: payload.RepositoryReferenceName, - ConfigFilePath: payload.ManifestFile, - }, - Namespace: payload.Namespace, - Name: payload.StackName, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), - CreatedBy: user.Username, - IsComposeFormat: payload.ComposeFormat, - AutoUpdate: payload.AutoUpdate, - AdditionalFiles: payload.AdditionalFiles, - } + stackPayload := createStackPayloadFromK8sGitPayload(payload.StackName, + payload.RepositoryURL, + payload.RepositoryReferenceName, + payload.RepositoryUsername, + payload.RepositoryPassword, + payload.RepositoryAuthentication, + payload.ComposeFormat, + payload.Namespace, + payload.ManifestFile, + payload.AdditionalFiles, + payload.AutoUpdate) - if payload.RepositoryAuthentication { - stack.GitConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: payload.RepositoryPassword, - } - } + k8sStackBuilder := stackbuilders.CreateKubernetesStackGitBuilder(handler.DataStore, + handler.FileService, + handler.GitService, + handler.Scheduler, + handler.StackDeployer, + handler.KubernetesDeployer, + user) - projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) - stack.ProjectPath = projectPath - - doCleanUp := true - defer handler.cleanUp(stack, &doCleanUp) - - commitID, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) - if err != nil { - return httperror.InternalServerError("Unable to fetch git repository id", err) - } - stack.GitConfig.ConfigHash = commitID - - repositoryUsername := payload.RepositoryUsername - repositoryPassword := payload.RepositoryPassword - if !payload.RepositoryAuthentication { - repositoryUsername = "" - repositoryPassword = "" - } - - err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword) - if err != nil { - return httperror.InternalServerError("Failed to clone git repository", err) - } - - output, err := handler.deployKubernetesStack(user.ID, endpoint, stack, k.KubeAppLabels{ - StackID: stackID, - StackName: stack.Name, - Owner: sanitizeLabel(stack.CreatedBy), - Kind: "git", - }) - - if err != nil { - return httperror.InternalServerError("Unable to deploy Kubernetes stack", err) - } - - if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { - jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) - if e != nil { - return e - } - - stack.AutoUpdate.JobID = jobID - } - - err = handler.DataStore.Stack().Create(stack) - if err != nil { - return httperror.InternalServerError("Unable to persist the Kubernetes stack inside the database", err) + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder) + _, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint) + if httpErr != nil { + return httpErr } resp := &createKubernetesStackResponse{ - Output: output, + Output: k8sStackBuilder.GetResponse(), } - doCleanUp = false return response.JSON(w, resp) } @@ -300,58 +240,28 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit return httperror.InternalServerError("Unable to check for name collision", err) } if !isUnique { - return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), Err: errStackAlreadyExists} + return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("A stack with the name '%s' already exists", payload.StackName), Err: stackutils.ErrStackAlreadyExists} } - stackID := handler.DataStore.Stack().GetNextIdentifier() - stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Type: portainer.KubernetesStack, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ManifestFileDefaultName, - Namespace: payload.Namespace, - Name: payload.StackName, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), - CreatedBy: user.Username, - IsComposeFormat: payload.ComposeFormat, + stackPayload := createStackPayloadFromK8sUrlPayload(payload.StackName, + payload.Namespace, + payload.ManifestURL, + payload.ComposeFormat) + + k8sStackBuilder := stackbuilders.CreateKubernetesStackUrlBuilder(handler.DataStore, + handler.FileService, + handler.StackDeployer, + handler.KubernetesDeployer, + user) + + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(k8sStackBuilder) + _, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint) + if httpErr != nil { + return httpErr } - var manifestContent []byte - manifestContent, err = client.Get(payload.ManifestURL, 30) - if err != nil { - return httperror.InternalServerError("Unable to retrieve manifest from URL", err) - } - - stackFolder := strconv.Itoa(int(stack.ID)) - projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, manifestContent) - if err != nil { - return httperror.InternalServerError("Unable to persist Kubernetes manifest file on disk", err) - } - stack.ProjectPath = projectPath - - doCleanUp := true - defer handler.cleanUp(stack, &doCleanUp) - - output, err := handler.deployKubernetesStack(user.ID, endpoint, stack, k.KubeAppLabels{ - StackID: stackID, - StackName: stack.Name, - Owner: stack.CreatedBy, - Kind: "url", - }) - if err != nil { - return httperror.InternalServerError("Unable to deploy Kubernetes stack", err) - } - - err = handler.DataStore.Stack().Create(stack) - if err != nil { - return httperror.InternalServerError("Unable to persist the Kubernetes stack inside the database", err) - } - - doCleanUp = false - resp := &createKubernetesStackResponse{ - Output: output, + Output: k8sStackBuilder.GetResponse(), } return response.JSON(w, resp) @@ -361,10 +271,18 @@ func (handler *Handler) deployKubernetesStack(userID portainer.UserID, endpoint handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() - manifestFilePaths, tempDir, err := stackutils.CreateTempK8SDeploymentFiles(stack, handler.KubernetesDeployer, appLabels) + user := &portainer.User{ + ID: userID, + } + k8sDeploymentConfig, err := deployments.CreateKubernetesStackDeploymentConfig(stack, handler.KubernetesDeployer, appLabels, user, endpoint) if err != nil { return "", errors.Wrap(err, "failed to create temp kub deployment files") } - defer os.RemoveAll(tempDir) - return handler.KubernetesDeployer.Deploy(userID, endpoint, manifestFilePaths, stack.Namespace) + + err = k8sDeploymentConfig.Deploy() + if err != nil { + return "", err + } + + return k8sDeploymentConfig.GetResponse(), nil } diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index 5c37e2a37..08df67120 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -3,8 +3,6 @@ package stacks import ( "fmt" "net/http" - "strconv" - "time" "github.com/asaskevich/govalidator" "github.com/pkg/errors" @@ -12,9 +10,9 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/filesystem" - gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/stacks/stackbuilders" + "github.com/portainer/portainer/api/stacks/stackutils" ) type swarmStackFromFileContentPayload struct { @@ -43,6 +41,16 @@ func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error return nil } +func createStackPayloadFromSwarmFileContentPayload(name string, swarmID string, fileContent string, env []portainer.Pair, fromAppTemplate bool) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + SwarmID: swarmID, + StackFileContent: fileContent, + Env: env, + FromAppTemplate: fromAppTemplate, + } +} + func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { var payload swarmStackFromFileContentPayload err := request.DecodeAndValidateJSONPayload(r, &payload) @@ -61,48 +69,24 @@ func (handler *Handler) createSwarmStackFromFileContent(w http.ResponseWriter, r return stackExistsError(payload.Name) } - stackID := handler.DataStore.Stack().GetNextIdentifier() - stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerSwarmStack, - SwarmID: payload.SwarmID, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: payload.Env, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), - FromAppTemplate: payload.FromAppTemplate, - } - - stackFolder := strconv.Itoa(int(stack.ID)) - projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to persist Compose file on disk", err) - } - stack.ProjectPath = projectPath - - doCleanUp := true - defer handler.cleanUp(stack, &doCleanUp) - - config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false, true) - if configErr != nil { - return configErr + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - err = handler.deploySwarmStack(config) - if err != nil { - return httperror.InternalServerError(err.Error(), err) + stackPayload := createStackPayloadFromSwarmFileContentPayload(payload.Name, payload.SwarmID, payload.StackFileContent, payload.Env, payload.FromAppTemplate) + + swarmStackBuilder := stackbuilders.CreateSwarmStackFileContentBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.StackDeployer) + + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(swarmStackBuilder) + stack, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint) + if httpErr != nil { + return httpErr } - stack.CreatedBy = config.user.Username - - err = handler.DataStore.Stack().Create(stack) - if err != nil { - return httperror.InternalServerError("Unable to persist the stack inside the database", err) - } - - doCleanUp = false return handler.decorateStackResponse(w, stack, userID) } @@ -147,12 +131,31 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) { return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled") } - if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil { + if err := stackutils.ValidateStackAutoUpdate(payload.AutoUpdate); err != nil { return err } return nil } +func createStackPayloadFromSwarmGitPayload(name, swarmID, repoUrl, repoReference, repoUsername, repoPassword string, repoAuthentication bool, composeFile string, additionalFiles []string, autoUpdate *portainer.StackAutoUpdate, env []portainer.Pair, fromAppTemplate bool) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + SwarmID: swarmID, + RepositoryConfigPayload: stackbuilders.RepositoryConfigPayload{ + URL: repoUrl, + ReferenceName: repoReference, + Authentication: repoAuthentication, + Username: repoUsername, + Password: repoPassword, + }, + ComposeFile: composeFile, + AdditionalFiles: additionalFiles, + AutoUpdate: autoUpdate, + Env: env, + FromAppTemplate: fromAppTemplate, + } +} + func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { var payload swarmStackFromGitRepositoryPayload err := request.DecodeAndValidateJSONPayload(r, &payload) @@ -177,82 +180,41 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter, return httperror.InternalServerError("Unable to check for webhook ID collision", err) } if !isUnique { - return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: errWebhookIDAlreadyExists} + return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: fmt.Sprintf("Webhook ID: %s already exists", payload.AutoUpdate.Webhook), Err: stackutils.ErrWebhookIDAlreadyExists} } } - stackID := handler.DataStore.Stack().GetNextIdentifier() - stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerSwarmStack, - SwarmID: payload.SwarmID, - EndpointID: endpoint.ID, - EntryPoint: payload.ComposeFile, - AdditionalFiles: payload.AdditionalFiles, - AutoUpdate: payload.AutoUpdate, - FromAppTemplate: payload.FromAppTemplate, - GitConfig: &gittypes.RepoConfig{ - URL: payload.RepositoryURL, - ReferenceName: payload.RepositoryReferenceName, - ConfigFilePath: payload.ComposeFile, - }, - Env: payload.Env, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), - } - - if payload.RepositoryAuthentication { - stack.GitConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: payload.RepositoryPassword, - } - } - - projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) - stack.ProjectPath = projectPath - - doCleanUp := true - defer handler.cleanUp(stack, &doCleanUp) - - err = handler.clone(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) + securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return httperror.InternalServerError("Unable to clone git repository", err) + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - commitID, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) - if err != nil { - return httperror.InternalServerError("Unable to fetch git repository id", err) - } - stack.GitConfig.ConfigHash = commitID + stackPayload := createStackPayloadFromSwarmGitPayload(payload.Name, + payload.SwarmID, + payload.RepositoryURL, + payload.RepositoryReferenceName, + payload.RepositoryUsername, + payload.RepositoryPassword, + payload.RepositoryAuthentication, + payload.ComposeFile, + payload.AdditionalFiles, + payload.AutoUpdate, + payload.Env, + payload.FromAppTemplate) - config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false, true) - if configErr != nil { - return configErr + swarmStackBuilder := stackbuilders.CreateSwarmStackGitBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.GitService, + handler.Scheduler, + handler.StackDeployer) + + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(swarmStackBuilder) + stack, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint) + if httpErr != nil { + return httpErr } - err = handler.deploySwarmStack(config) - if err != nil { - return httperror.InternalServerError(err.Error(), err) - } - - if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { - jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) - if e != nil { - return e - } - - stack.AutoUpdate.JobID = jobID - } - - stack.CreatedBy = config.user.Username - - err = handler.DataStore.Stack().Create(stack) - if err != nil { - return httperror.InternalServerError("Unable to persist the stack inside the database", err) - } - - doCleanUp = false return handler.decorateStackResponse(w, stack, userID) } @@ -263,6 +225,15 @@ type swarmStackFromFileUploadPayload struct { Env []portainer.Pair } +func createStackPayloadFromSwarmFileUploadPayload(name, swarmID string, fileContentBytes []byte, env []portainer.Pair) stackbuilders.StackPayload { + return stackbuilders.StackPayload{ + Name: name, + SwarmID: swarmID, + StackFileContentBytes: fileContentBytes, + Env: env, + } +} + func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error { name, err := request.RetrieveMultiPartFormValue(r, "Name", false) if err != nil { @@ -309,112 +280,23 @@ func (handler *Handler) createSwarmStackFromFileUpload(w http.ResponseWriter, r return stackExistsError(payload.Name) } - stackID := handler.DataStore.Stack().GetNextIdentifier() - stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Name: payload.Name, - Type: portainer.DockerSwarmStack, - SwarmID: payload.SwarmID, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ComposeFileDefaultName, - Env: payload.Env, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), - } - - stackFolder := strconv.Itoa(int(stack.ID)) - projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) - if err != nil { - return httperror.InternalServerError("Unable to persist Compose file on disk", err) - } - stack.ProjectPath = projectPath - - doCleanUp := true - defer handler.cleanUp(stack, &doCleanUp) - - config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false, true) - if configErr != nil { - return configErr - } - - err = handler.deploySwarmStack(config) - if err != nil { - return httperror.InternalServerError(err.Error(), err) - } - - stack.CreatedBy = config.user.Username - - err = handler.DataStore.Stack().Create(stack) - if err != nil { - return httperror.InternalServerError("Unable to persist the stack inside the database", err) - } - - doCleanUp = false - return handler.decorateStackResponse(w, stack, userID) -} - -type swarmStackDeploymentConfig struct { - stack *portainer.Stack - endpoint *portainer.Endpoint - registries []portainer.Registry - prune bool - isAdmin bool - user *portainer.User - pullImage bool -} - -func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint, prune bool, pullImage bool) (*swarmStackDeploymentConfig, *httperror.HandlerError) { securityContext, err := security.RetrieveRestrictedRequestContext(r) if err != nil { - return nil, httperror.InternalServerError("Unable to retrieve info from request context", err) + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - user, err := handler.DataStore.User().User(securityContext.UserID) - if err != nil { - return nil, httperror.InternalServerError("Unable to load user information from the database", err) + stackPayload := createStackPayloadFromSwarmFileUploadPayload(payload.Name, payload.SwarmID, payload.StackFileContent, payload.Env) + + swarmStackBuilder := stackbuilders.CreateSwarmStackFileUploadBuilder(securityContext, + handler.DataStore, + handler.FileService, + handler.StackDeployer) + + stackBuilderDirector := stackbuilders.NewStackBuilderDirector(swarmStackBuilder) + stack, httpErr := stackBuilderDirector.Build(&stackPayload, endpoint) + if httpErr != nil { + return httpErr } - registries, err := handler.DataStore.Registry().Registries() - if err != nil { - return nil, httperror.InternalServerError("Unable to retrieve registries from the database", err) - } - - filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) - - config := &swarmStackDeploymentConfig{ - stack: stack, - endpoint: endpoint, - registries: filteredRegistries, - prune: prune, - isAdmin: securityContext.IsAdmin, - user: user, - pullImage: pullImage, - } - - return config, nil -} - -func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) error { - isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID) - if err != nil { - return errors.Wrap(err, "failed to validate user admin privileges") - } - - settings := &config.endpoint.SecuritySettings - - if !settings.AllowBindMountsForRegularUsers && !isAdminOrEndpointAdmin { - for _, file := range append([]string{config.stack.EntryPoint}, config.stack.AdditionalFiles...) { - stackContent, err := handler.FileService.GetFileContent(config.stack.ProjectPath, file) - if err != nil { - return errors.WithMessage(err, "failed to get stack file content") - } - - err = handler.isValidStackFile(stackContent, settings) - if err != nil { - return errors.WithMessage(err, "swarm stack file content validation failed") - } - } - } - - return handler.StackDeployer.DeploySwarmStack(config.stack, config.endpoint, config.registries, config.prune, config.pullImage) + return handler.decorateStackResponse(w, stack, userID) } diff --git a/api/http/handler/stacks/handler.go b/api/http/handler/stacks/handler.go index 4e3f82406..0badbf715 100644 --- a/api/http/handler/stacks/handler.go +++ b/api/http/handler/stacks/handler.go @@ -8,6 +8,9 @@ import ( "sync" "github.com/portainer/portainer/api/internal/endpointutils" + "github.com/portainer/portainer/api/kubernetes/cli" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" "github.com/docker/docker/api/types" "github.com/gorilla/mux" @@ -18,15 +21,7 @@ import ( "github.com/portainer/portainer/api/docker" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" - "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/scheduler" - "github.com/portainer/portainer/api/stacks" -) - -var ( - errStackAlreadyExists = errors.New("A stack already exists with this name") - errWebhookIDAlreadyExists = errors.New("A webhook ID already exists") - errStackNotExternal = errors.New("Not an external stack") ) // Handler is the HTTP handler used to handle stack operations. @@ -44,7 +39,7 @@ type Handler struct { KubernetesDeployer portainer.KubernetesDeployer KubernetesClientFactory *cli.ClientFactory Scheduler *scheduler.Scheduler - StackDeployer stacks.StackDeployer + StackDeployer deployments.StackDeployer } func stackExistsError(name string) *httperror.HandlerError { @@ -106,7 +101,7 @@ func (handler *Handler) userCanAccessStack(securityContext *security.RestrictedR return true, nil } - return handler.userIsAdminOrEndpointAdmin(user, endpointID) + return stackutils.UserIsAdminOrEndpointAdmin(user, endpointID) } func (handler *Handler) userIsAdmin(userID portainer.UserID) (bool, error) { @@ -120,19 +115,13 @@ func (handler *Handler) userIsAdmin(userID portainer.UserID) (bool, error) { return isAdmin, nil } -func (handler *Handler) userIsAdminOrEndpointAdmin(user *portainer.User, endpointID portainer.EndpointID) (bool, error) { - isAdmin := user.Role == portainer.AdministratorRole - - return isAdmin, nil -} - func (handler *Handler) userCanCreateStack(securityContext *security.RestrictedRequestContext, endpointID portainer.EndpointID) (bool, error) { user, err := handler.DataStore.User().User(securityContext.UserID) if err != nil { return false, err } - return handler.userIsAdminOrEndpointAdmin(user, endpointID) + return stackutils.UserIsAdminOrEndpointAdmin(user, endpointID) } // if stack management is disabled for non admins and the user isn't an admin, then return false. Otherwise return true @@ -237,26 +226,3 @@ func (handler *Handler) checkUniqueWebhookID(webhookID string) (bool, error) { } return false, err } - -func (handler *Handler) clone(projectPath, repositoryURL, refName string, auth bool, username, password string) error { - if !auth { - username = "" - password = "" - } - - err := handler.GitService.CloneRepository(projectPath, repositoryURL, refName, username, password) - if err != nil { - return fmt.Errorf("unable to clone git repository: %w", err) - } - - return nil -} - -func (handler *Handler) latestCommitID(repositoryURL, refName string, auth bool, username, password string) (string, error) { - if !auth { - username = "" - password = "" - } - - return handler.GitService.LatestCommitID(repositoryURL, refName, username, password) -} diff --git a/api/http/handler/stacks/helper.go b/api/http/handler/stacks/helper.go deleted file mode 100644 index dd42330c4..000000000 --- a/api/http/handler/stacks/helper.go +++ /dev/null @@ -1,24 +0,0 @@ -package stacks - -import ( - "time" - - "github.com/asaskevich/govalidator" - "github.com/pkg/errors" - portainer "github.com/portainer/portainer/api" -) - -func validateStackAutoUpdate(autoUpdate *portainer.StackAutoUpdate) error { - if autoUpdate == nil { - return nil - } - if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) { - return errors.New("invalid Webhook format") - } - if autoUpdate.Interval != "" { - if _, err := time.ParseDuration(autoUpdate.Interval); err != nil { - return errors.New("invalid Interval format") - } - } - return nil -} diff --git a/api/http/handler/stacks/stack_associate.go b/api/http/handler/stacks/stack_associate.go index 339273a46..f7fe64741 100644 --- a/api/http/handler/stacks/stack_associate.go +++ b/api/http/handler/stacks/stack_associate.go @@ -10,7 +10,7 @@ import ( "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/stackutils" ) // @id StackAssociate diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index 785f170a8..ee2fb1e7d 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -3,33 +3,16 @@ package stacks import ( "net/http" + "github.com/pkg/errors" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/authorization" - "github.com/portainer/portainer/api/internal/stackutils" - - "github.com/docker/cli/cli/compose/loader" - "github.com/docker/cli/cli/compose/types" - "github.com/pkg/errors" - "github.com/rs/zerolog/log" + "github.com/portainer/portainer/api/stacks/stackutils" ) -func (handler *Handler) cleanUp(stack *portainer.Stack, doCleanUp *bool) error { - if !*doCleanUp { - return nil - } - - err := handler.FileService.RemoveDirectory(stack.ProjectPath) - if err != nil { - log.Error().Err(err).Msg("unable to cleanup stack creation") - } - - return nil -} - // @id StackCreate // @summary Deploy a new stack // @description Deploy a new stack into a Docker environment(endpoint) specified via the environment(endpoint) identifier. @@ -155,63 +138,6 @@ func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Req return httperror.BadRequest("Invalid value for query parameter: method. Value must be one of: string or repository", errors.New(request.ErrInvalidQueryParameter)) } -func (handler *Handler) isValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error { - composeConfigYAML, err := loader.ParseYAML(stackFileContent) - if err != nil { - return err - } - - composeConfigFile := types.ConfigFile{ - Config: composeConfigYAML, - } - - composeConfigDetails := types.ConfigDetails{ - ConfigFiles: []types.ConfigFile{composeConfigFile}, - Environment: map[string]string{}, - } - - composeConfig, err := loader.Load(composeConfigDetails, func(options *loader.Options) { - options.SkipValidation = true - options.SkipInterpolation = true - }) - if err != nil { - return err - } - - for key := range composeConfig.Services { - service := composeConfig.Services[key] - if !securitySettings.AllowBindMountsForRegularUsers { - for _, volume := range service.Volumes { - if volume.Type == "bind" { - return errors.New("bind-mount disabled for non administrator users") - } - } - } - - if !securitySettings.AllowPrivilegedModeForRegularUsers && service.Privileged == true { - return errors.New("privileged mode disabled for non administrator users") - } - - if !securitySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" { - return errors.New("pid host disabled for non administrator users") - } - - if !securitySettings.AllowDeviceMappingForRegularUsers && service.Devices != nil && len(service.Devices) > 0 { - return errors.New("device mapping disabled for non administrator users") - } - - if !securitySettings.AllowSysctlSettingForRegularUsers && service.Sysctls != nil && len(service.Sysctls) > 0 { - return errors.New("sysctl setting disabled for non administrator users") - } - - if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) { - return errors.New("container capabilities disabled for non administrator users") - } - } - - return nil -} - func (handler *Handler) decorateStackResponse(w http.ResponseWriter, stack *portainer.Stack, userID portainer.UserID) *httperror.HandlerError { var resourceControl *portainer.ResourceControl diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index 8240930ce..ca447c903 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -16,7 +16,8 @@ import ( "github.com/portainer/portainer/api/filesystem" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" ) // @id StackDelete @@ -114,7 +115,7 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt // stop scheduler updates of the stack before removal if stack.AutoUpdate != nil { - stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) } err = handler.deleteStack(securityContext.UserID, stack, endpoint) @@ -198,7 +199,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St //if it is a compose format kub stack, create a temp dir and convert the manifest files into it //then process the remove operation if stack.IsComposeFormat { - fileNames := append([]string{stack.EntryPoint}, stack.AdditionalFiles...) + fileNames := stackutils.GetStackFilePaths(stack, false) tmpDir, err := ioutil.TempDir("", "kube_delete") if err != nil { return errors.Wrap(err, "failed to create temp directory for deleting kub stack") @@ -224,7 +225,7 @@ func (handler *Handler) deleteStack(userID portainer.UserID, stack *portainer.St manifestFiles = append(manifestFiles, manifestFilePath) } } else { - manifestFiles = stackutils.GetStackFilePaths(stack) + manifestFiles = stackutils.GetStackFilePaths(stack, true) } out, err := handler.KubernetesDeployer.Remove(userID, endpoint, manifestFiles, stack.Namespace) return errors.WithMessagef(err, "failed to remove kubernetes resources: %q", out) diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index 2b6a90ff3..475fcd6d5 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -10,7 +10,7 @@ import ( portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/stackutils" ) type stackFileResponse struct { diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go index df0fb8adc..fae1dfe34 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -10,7 +10,7 @@ import ( portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/stackutils" ) // @id StackInspect diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index be2dc309e..687b69894 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -11,7 +11,8 @@ import ( portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" ) type stackMigratePayload struct { @@ -189,26 +190,53 @@ func (handler *Handler) migrateStack(r *http.Request, stack *portainer.Stack, ne } func (handler *Handler) migrateComposeStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { - config, configErr := handler.createComposeDeployConfig(r, stack, next, false) - if configErr != nil { - return configErr + // Create compose deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - err := handler.deployComposeStack(config, false) + composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(securityContext, + stack, + next, + handler.DataStore, + handler.FileService, + handler.StackDeployer, + false, + false) if err != nil { return httperror.InternalServerError(err.Error(), err) } + // Deploy the stack + err = composeDeploymentConfig.Deploy() + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } return nil } func (handler *Handler) migrateSwarmStack(r *http.Request, stack *portainer.Stack, next *portainer.Endpoint) *httperror.HandlerError { - config, configErr := handler.createSwarmDeployConfig(r, stack, next, true, true) - if configErr != nil { - return configErr + // Create swarm deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - err := handler.deploySwarmStack(config) + swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfig(securityContext, + stack, + next, + handler.DataStore, + handler.FileService, + handler.StackDeployer, + true, + true) + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } + + // Deploy the stack + err = swarmDeploymentConfig.Deploy() if err != nil { return httperror.InternalServerError(err.Error(), err) } diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go index 31834b008..f2d67cfbb 100644 --- a/api/http/handler/stacks/stack_start.go +++ b/api/http/handler/stacks/stack_start.go @@ -9,7 +9,8 @@ import ( portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" @@ -100,9 +101,9 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http } if stack.AutoUpdate != nil && stack.AutoUpdate.Interval != "" { - stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) - jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) + jobID, e := deployments.StartAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) if e != nil { return e } diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go index 175189db1..9d6d1eba8 100644 --- a/api/http/handler/stacks/stack_stop.go +++ b/api/http/handler/stacks/stack_stop.go @@ -11,7 +11,8 @@ import ( portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" ) // @id StackStop @@ -90,7 +91,7 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe // stop scheduler updates of the stack before stopping if stack.AutoUpdate != nil && stack.AutoUpdate.JobID != "" { - stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) stack.AutoUpdate.JobID = "" } diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 970434aab..85a8faa10 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -11,7 +11,8 @@ import ( portainer "github.com/portainer/portainer/api" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" "github.com/asaskevich/govalidator" "github.com/pkg/errors" @@ -178,7 +179,7 @@ func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.S func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { // Must not be git based stack. stop the auto update job if there is any if stack.AutoUpdate != nil { - stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) stack.AutoUpdate = nil } if stack.GitConfig != nil { @@ -203,16 +204,30 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta return httperror.InternalServerError("Unable to persist updated Compose file on disk", err) } - config, configErr := handler.createComposeDeployConfig(r, stack, endpoint, payload.PullImage) - if configErr != nil { + // Create compose deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(securityContext, + stack, + endpoint, + handler.DataStore, + handler.FileService, + handler.StackDeployer, + payload.PullImage, + false) + if err != nil { if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { log.Warn().Err(rollbackErr).Msg("rollback stack file error") } - return configErr + return httperror.InternalServerError(err.Error(), err) } - err = handler.deployComposeStack(config, false) + // Deploy the stack + err = composeDeploymentConfig.Deploy() if err != nil { if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { log.Warn().Err(rollbackErr).Msg("rollback stack file error") @@ -229,7 +244,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { // Must not be git based stack. stop the auto update job if there is any if stack.AutoUpdate != nil { - stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) stack.AutoUpdate = nil } if stack.GitConfig != nil { @@ -254,16 +269,30 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack return httperror.InternalServerError("Unable to persist updated Compose file on disk", err) } - config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, payload.Prune, payload.PullImage) - if configErr != nil { + // Create swarm deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) + } + + swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfig(securityContext, + stack, + endpoint, + handler.DataStore, + handler.FileService, + handler.StackDeployer, + payload.Prune, + payload.PullImage) + if err != nil { if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { log.Warn().Err(rollbackErr).Msg("rollback stack file error") } - return configErr + return httperror.InternalServerError(err.Error(), err) } - err = handler.deploySwarmStack(config) + // Deploy the stack + err = swarmDeploymentConfig.Deploy() if err != nil { if rollbackErr := handler.FileService.RollbackStackFile(stackFolder, stack.EntryPoint); rollbackErr != nil { log.Warn().Err(rollbackErr).Msg("rollback stack file error") diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go index 69cb218f4..02bc3702a 100644 --- a/api/http/handler/stacks/stack_update_git.go +++ b/api/http/handler/stacks/stack_update_git.go @@ -12,7 +12,8 @@ import ( gittypes "github.com/portainer/portainer/api/git/types" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" ) type stackGitUpdatePayload struct { @@ -26,7 +27,7 @@ type stackGitUpdatePayload struct { } func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { - if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil { + if err := stackutils.ValidateStackAutoUpdate(payload.AutoUpdate); err != nil { return err } return nil @@ -131,7 +132,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * //stop the autoupdate job if there is any if stack.AutoUpdate != nil { - stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) } //update retrieved stack data based on the payload @@ -165,7 +166,7 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) * } if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { - jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) + jobID, e := deployments.StartAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) if e != nil { return e } diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index 2ecfe5da2..fbe69b0a1 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -13,8 +13,9 @@ import ( "github.com/portainer/portainer/api/filesystem" httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/internal/stackutils" k "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" "github.com/rs/zerolog/log" ) @@ -203,49 +204,72 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) } func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, pullImage bool, endpoint *portainer.Endpoint) *httperror.HandlerError { + var ( + deploymentConfiger deployments.StackDeploymentConfiger + err error + ) + switch stack.Type { case portainer.DockerSwarmStack: prune := false if stack.Option != nil { prune = stack.Option.Prune } - config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, prune, pullImage) - if httpErr != nil { - return httpErr + + // Create swarm deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - if err := handler.deploySwarmStack(config); err != nil { + deploymentConfiger, err = deployments.CreateSwarmStackDeploymentConfig(securityContext, stack, endpoint, handler.DataStore, handler.FileService, handler.StackDeployer, prune, pullImage) + if err != nil { return httperror.InternalServerError(err.Error(), err) } case portainer.DockerComposeStack: - config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint, pullImage) - if httpErr != nil { - return httpErr + // Create compose deployment config + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return httperror.InternalServerError("Unable to retrieve info from request context", err) } - if err := handler.deployComposeStack(config, true); err != nil { + deploymentConfiger, err = deployments.CreateComposeStackDeploymentConfig(securityContext, stack, endpoint, handler.DataStore, handler.FileService, handler.StackDeployer, pullImage, true) + if err != nil { return httperror.InternalServerError(err.Error(), err) } - case portainer.KubernetesStack: + handler.stackCreationMutex.Lock() + defer handler.stackCreationMutex.Unlock() + tokenData, err := security.RetrieveTokenData(r) if err != nil { return httperror.BadRequest("Failed to retrieve user token data", err) } - _, err = handler.deployKubernetesStack(tokenData.ID, endpoint, stack, k.KubeAppLabels{ + + user := &portainer.User{ + ID: tokenData.ID, + Username: tokenData.Username, + } + + appLabel := k.KubeAppLabels{ StackID: int(stack.ID), StackName: stack.Name, Owner: tokenData.Username, Kind: "git", - }) - if err != nil { - return httperror.InternalServerError("Unable to redeploy Kubernetes stack", errors.WithMessage(err, "failed to deploy kube application")) } + deploymentConfiger, err = deployments.CreateKubernetesStackDeploymentConfig(stack, handler.KubernetesDeployer, appLabel, user, endpoint) + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } default: return httperror.InternalServerError("Unsupported stack", errors.Errorf("unsupported stack type: %v", stack.Type)) } + err = deploymentConfiger.Deploy() + if err != nil { + return httperror.InternalServerError(err.Error(), err) + } return nil } diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go index fdbeec69a..0764c58d7 100644 --- a/api/http/handler/stacks/update_kubernetes_stack.go +++ b/api/http/handler/stacks/update_kubernetes_stack.go @@ -14,6 +14,8 @@ import ( gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/security" k "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" "github.com/asaskevich/govalidator" "github.com/pkg/errors" @@ -40,7 +42,7 @@ func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error } func (payload *kubernetesGitStackUpdatePayload) Validate(r *http.Request) error { - if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil { + if err := stackutils.ValidateStackAutoUpdate(payload.AutoUpdate); err != nil { return err } return nil @@ -51,7 +53,7 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer. if stack.GitConfig != nil { //stop the autoupdate job if there is any if stack.AutoUpdate != nil { - stopAutoupdate(stack.ID, stack.AutoUpdate.JobID, *handler.Scheduler) + deployments.StopAutoupdate(stack.ID, stack.AutoUpdate.JobID, handler.Scheduler) } var payload kubernetesGitStackUpdatePayload @@ -81,7 +83,7 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer. } if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { - jobID, e := startAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) + jobID, e := deployments.StartAutoupdate(stack.ID, stack.AutoUpdate.Interval, handler.Scheduler, handler.StackDeployer, handler.DataStore, handler.GitService) if e != nil { return e } diff --git a/api/http/handler/stacks/webhook_invoke.go b/api/http/handler/stacks/webhook_invoke.go index 0ac5a1a99..047f7aa45 100644 --- a/api/http/handler/stacks/webhook_invoke.go +++ b/api/http/handler/stacks/webhook_invoke.go @@ -6,7 +6,7 @@ import ( httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" - "github.com/portainer/portainer/api/stacks" + "github.com/portainer/portainer/api/stacks/deployments" "github.com/gofrs/uuid" "github.com/rs/zerolog/log" @@ -38,8 +38,8 @@ func (handler *Handler) webhookInvoke(w http.ResponseWriter, r *http.Request) *h return &httperror.HandlerError{StatusCode: statusCode, Message: "Unable to find the stack by webhook ID", Err: err} } - if err = stacks.RedeployWhenChanged(stack.ID, handler.StackDeployer, handler.DataStore, handler.GitService); err != nil { - if _, ok := err.(*stacks.StackAuthorMissingErr); ok { + if err = deployments.RedeployWhenChanged(stack.ID, handler.StackDeployer, handler.DataStore, handler.GitService); err != nil { + if _, ok := err.(*deployments.StackAuthorMissingErr); ok { return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: "Autoupdate for the stack isn't available", Err: err} } diff --git a/api/http/proxy/factory/docker/access_control.go b/api/http/proxy/factory/docker/access_control.go index 159435c26..4df8f7412 100644 --- a/api/http/proxy/factory/docker/access_control.go +++ b/api/http/proxy/factory/docker/access_control.go @@ -7,7 +7,7 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/http/proxy/factory/utils" "github.com/portainer/portainer/api/internal/authorization" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/stackutils" "github.com/rs/zerolog/log" ) diff --git a/api/http/server.go b/api/http/server.go index 111d3fae8..7937162bc 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -61,7 +61,7 @@ import ( k8s "github.com/portainer/portainer/api/kubernetes" "github.com/portainer/portainer/api/kubernetes/cli" "github.com/portainer/portainer/api/scheduler" - stackdeployer "github.com/portainer/portainer/api/stacks" + "github.com/portainer/portainer/api/stacks/deployments" "github.com/rs/zerolog/log" ) @@ -100,7 +100,7 @@ type Server struct { Scheduler *scheduler.Scheduler ShutdownCtx context.Context ShutdownTrigger context.CancelFunc - StackDeployer stackdeployer.StackDeployer + StackDeployer deployments.StackDeployer DemoService *demo.Service } diff --git a/api/internal/authorization/access_control.go b/api/internal/authorization/access_control.go index 5004088db..543fecb51 100644 --- a/api/internal/authorization/access_control.go +++ b/api/internal/authorization/access_control.go @@ -4,7 +4,7 @@ import ( "strconv" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/internal/stackutils" + "github.com/portainer/portainer/api/stacks/stackutils" ) // NewAdministratorsOnlyResourceControl will create a new administrators only resource control associated to the resource specified by the diff --git a/api/internal/stackutils/stackutils.go b/api/internal/stackutils/stackutils.go deleted file mode 100644 index 0d8b92e6b..000000000 --- a/api/internal/stackutils/stackutils.go +++ /dev/null @@ -1,61 +0,0 @@ -package stackutils - -import ( - "fmt" - "io/ioutil" - - "github.com/pkg/errors" - portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/filesystem" - k "github.com/portainer/portainer/api/kubernetes" -) - -// ResourceControlID returns the stack resource control id -func ResourceControlID(endpointID portainer.EndpointID, name string) string { - return fmt.Sprintf("%d_%s", endpointID, name) -} - -// GetStackFilePaths returns a list of file paths based on stack project path -func GetStackFilePaths(stack *portainer.Stack) []string { - var filePaths []string - for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) { - filePaths = append(filePaths, filesystem.JoinPaths(stack.ProjectPath, file)) - } - return filePaths -} - -// CreateTempK8SDeploymentFiles reads manifest files from original stack project path -// then add app labels into the file contents and create temp files for deployment -// return temp file paths and temp dir -func CreateTempK8SDeploymentFiles(stack *portainer.Stack, kubeDeployer portainer.KubernetesDeployer, appLabels k.KubeAppLabels) ([]string, string, error) { - fileNames := append([]string{stack.EntryPoint}, stack.AdditionalFiles...) - var manifestFilePaths []string - tmpDir, err := ioutil.TempDir("", "kub_deployment") - if err != nil { - return nil, "", errors.Wrap(err, "failed to create temp kub deployment directory") - } - - for _, fileName := range fileNames { - manifestFilePath := filesystem.JoinPaths(tmpDir, fileName) - manifestContent, err := ioutil.ReadFile(filesystem.JoinPaths(stack.ProjectPath, fileName)) - if err != nil { - return nil, "", errors.Wrap(err, "failed to read manifest file") - } - if stack.IsComposeFormat { - manifestContent, err = kubeDeployer.ConvertCompose(manifestContent) - if err != nil { - return nil, "", errors.Wrap(err, "failed to convert docker compose file to a kube manifest") - } - } - manifestContent, err = k.AddAppLabels(manifestContent, appLabels.ToMap()) - if err != nil { - return nil, "", errors.Wrap(err, "failed to add application labels") - } - err = filesystem.WriteToFile(manifestFilePath, []byte(manifestContent)) - if err != nil { - return nil, "", errors.Wrap(err, "failed to create temp manifest file") - } - manifestFilePaths = append(manifestFilePaths, manifestFilePath) - } - return manifestFilePaths, tmpDir, nil -} diff --git a/api/http/handler/stacks/autoupdate.go b/api/stacks/deployments/autoupdate.go similarity index 57% rename from api/http/handler/stacks/autoupdate.go rename to api/stacks/deployments/autoupdate.go index 1b6fbf6db..6fedea18e 100644 --- a/api/http/handler/stacks/autoupdate.go +++ b/api/stacks/deployments/autoupdate.go @@ -1,4 +1,4 @@ -package stacks +package deployments import ( "time" @@ -7,25 +7,24 @@ import ( portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/scheduler" - "github.com/portainer/portainer/api/stacks" "github.com/rs/zerolog/log" ) -func startAutoupdate(stackID portainer.StackID, interval string, scheduler *scheduler.Scheduler, stackDeployer stacks.StackDeployer, datastore dataservices.DataStore, gitService portainer.GitService) (jobID string, e *httperror.HandlerError) { +func StartAutoupdate(stackID portainer.StackID, interval string, scheduler *scheduler.Scheduler, stackDeployer StackDeployer, datastore dataservices.DataStore, gitService portainer.GitService) (jobID string, e *httperror.HandlerError) { d, err := time.ParseDuration(interval) if err != nil { return "", httperror.BadRequest("Unable to parse stack's auto update interval", err) } jobID = scheduler.StartJobEvery(d, func() error { - return stacks.RedeployWhenChanged(stackID, stackDeployer, datastore, gitService) + return RedeployWhenChanged(stackID, stackDeployer, datastore, gitService) }) return jobID, nil } -func stopAutoupdate(stackID portainer.StackID, jobID string, scheduler scheduler.Scheduler) { +func StopAutoupdate(stackID portainer.StackID, jobID string, scheduler *scheduler.Scheduler) { if jobID == "" { return } diff --git a/api/stacks/deploy.go b/api/stacks/deployments/deploy.go similarity index 99% rename from api/stacks/deploy.go rename to api/stacks/deployments/deploy.go index 1bc4a8532..0759f59b2 100644 --- a/api/stacks/deploy.go +++ b/api/stacks/deployments/deploy.go @@ -1,4 +1,4 @@ -package stacks +package deployments import ( "fmt" diff --git a/api/stacks/deploy_test.go b/api/stacks/deployments/deploy_test.go similarity index 99% rename from api/stacks/deploy_test.go rename to api/stacks/deployments/deploy_test.go index 2148e8da8..c697a248f 100644 --- a/api/stacks/deploy_test.go +++ b/api/stacks/deployments/deploy_test.go @@ -1,4 +1,4 @@ -package stacks +package deployments import ( "errors" diff --git a/api/stacks/deployer.go b/api/stacks/deployments/deployer.go similarity index 90% rename from api/stacks/deployer.go rename to api/stacks/deployments/deployer.go index 3d15f9ee3..d47bc6d31 100644 --- a/api/stacks/deployer.go +++ b/api/stacks/deployments/deployer.go @@ -1,14 +1,12 @@ -package stacks +package deployments import ( "context" - "os" "sync" "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" - "github.com/portainer/portainer/api/internal/stackutils" k "github.com/portainer/portainer/api/kubernetes" ) @@ -83,13 +81,12 @@ func (d *stackDeployer) DeployKubernetesStack(stack *portainer.Stack, endpoint * appLabels.Kind = "git" } - manifestFilePaths, tempDir, err := stackutils.CreateTempK8SDeploymentFiles(stack, d.kubernetesDeployer, appLabels) + k8sDeploymentConfig, err := CreateKubernetesStackDeploymentConfig(stack, d.kubernetesDeployer, appLabels, user, endpoint) if err != nil { return errors.Wrap(err, "failed to create temp kub deployment files") } - defer os.RemoveAll(tempDir) - _, err = d.kubernetesDeployer.Deploy(user.ID, endpoint, manifestFilePaths, stack.Namespace) + err = k8sDeploymentConfig.Deploy() if err != nil { return errors.Wrap(err, "failed to deploy kubernetes application") } diff --git a/api/stacks/deployments/deployment_compose_config.go b/api/stacks/deployments/deployment_compose_config.go new file mode 100644 index 000000000..bc5460721 --- /dev/null +++ b/api/stacks/deployments/deployment_compose_config.go @@ -0,0 +1,93 @@ +package deployments + +import ( + "fmt" + "log" + + "github.com/pkg/errors" + 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/stackutils" +) + +type ComposeStackDeploymentConfig struct { + stack *portainer.Stack + endpoint *portainer.Endpoint + registries []portainer.Registry + isAdmin bool + user *portainer.User + forcePullImage bool + ForceCreate bool + FileService portainer.FileService + StackDeployer StackDeployer +} + +func CreateComposeStackDeploymentConfig(securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, fileService portainer.FileService, deployer StackDeployer, forcePullImage, forceCreate bool) (*ComposeStackDeploymentConfig, error) { + user, err := dataStore.User().User(securityContext.UserID) + if err != nil { + return nil, fmt.Errorf("unable to load user information from the database: %w", err) + } + + registries, err := dataStore.Registry().Registries() + if err != nil { + return nil, fmt.Errorf("unable to retrieve registries from the database: %w", err) + } + + filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) + + config := &ComposeStackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + registries: filteredRegistries, + isAdmin: securityContext.IsAdmin, + user: user, + forcePullImage: forcePullImage, + ForceCreate: forceCreate, + FileService: fileService, + StackDeployer: deployer, + } + + return config, nil +} + +func (config *ComposeStackDeploymentConfig) GetUsername() string { + if config.user != nil { + return config.user.Username + } + return "" +} + +func (config *ComposeStackDeploymentConfig) Deploy() error { + if config.FileService == nil || config.StackDeployer == nil { + log.Println("[deployment, compose] file service or stack deployer is not initialised") + return errors.New("file service or stack deployer cannot be nil") + } + + isAdminOrEndpointAdmin, err := stackutils.UserIsAdminOrEndpointAdmin(config.user, config.endpoint.ID) + if err != nil { + return errors.Wrap(err, "failed to validate user admin privileges") + } + + securitySettings := &config.endpoint.SecuritySettings + + if (!securitySettings.AllowBindMountsForRegularUsers || + !securitySettings.AllowPrivilegedModeForRegularUsers || + !securitySettings.AllowHostNamespaceForRegularUsers || + !securitySettings.AllowDeviceMappingForRegularUsers || + !securitySettings.AllowSysctlSettingForRegularUsers || + !securitySettings.AllowContainerCapabilitiesForRegularUsers) && + !isAdminOrEndpointAdmin { + + err = stackutils.ValidateStackFiles(config.stack, securitySettings, config.FileService) + if err != nil { + return err + } + } + + return config.StackDeployer.DeployComposeStack(config.stack, config.endpoint, config.registries, config.forcePullImage, config.ForceCreate) +} + +func (config *ComposeStackDeploymentConfig) GetResponse() string { + return "" +} diff --git a/api/stacks/deployments/deployment_config.go b/api/stacks/deployments/deployment_config.go new file mode 100644 index 000000000..e0e35d462 --- /dev/null +++ b/api/stacks/deployments/deployment_config.go @@ -0,0 +1,7 @@ +package deployments + +type StackDeploymentConfiger interface { + GetUsername() string + Deploy() error + GetResponse() string +} diff --git a/api/stacks/deployments/deployment_kubernetes_config.go b/api/stacks/deployments/deployment_kubernetes_config.go new file mode 100644 index 000000000..faf974a8c --- /dev/null +++ b/api/stacks/deployments/deployment_kubernetes_config.go @@ -0,0 +1,89 @@ +package deployments + +import ( + "fmt" + "io/ioutil" + "os" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + k "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/stacks/stackutils" +) + +type KubernetesStackDeploymentConfig struct { + stack *portainer.Stack + kuberneteDeployer portainer.KubernetesDeployer + appLabels k.KubeAppLabels + user *portainer.User + endpoint *portainer.Endpoint + output string +} + +func CreateKubernetesStackDeploymentConfig(stack *portainer.Stack, kubeDeployer portainer.KubernetesDeployer, appLabels k.KubeAppLabels, user *portainer.User, endpoint *portainer.Endpoint) (*KubernetesStackDeploymentConfig, error) { + + return &KubernetesStackDeploymentConfig{ + stack: stack, + kuberneteDeployer: kubeDeployer, + appLabels: appLabels, + user: user, + endpoint: endpoint, + }, nil +} + +func (config *KubernetesStackDeploymentConfig) GetUsername() string { + return config.user.Username +} + +func (config *KubernetesStackDeploymentConfig) Deploy() error { + fileNames := stackutils.GetStackFilePaths(config.stack, false) + + manifestFilePaths := make([]string, len(fileNames)) + + tmpDir, err := ioutil.TempDir("", "kub_deployment") + if err != nil { + return errors.Wrap(err, "failed to create temp kub deployment directory") + } + + defer os.RemoveAll(tmpDir) + + for _, fileName := range fileNames { + manifestFilePath := filesystem.JoinPaths(tmpDir, fileName) + manifestContent, err := ioutil.ReadFile(filesystem.JoinPaths(config.stack.ProjectPath, fileName)) + if err != nil { + return errors.Wrap(err, "failed to read manifest file") + } + + if config.stack.IsComposeFormat { + manifestContent, err = config.kuberneteDeployer.ConvertCompose(manifestContent) + if err != nil { + return errors.Wrap(err, "failed to convert docker compose file to a kube manifest") + } + } + + manifestContent, err = k.AddAppLabels(manifestContent, config.appLabels.ToMap()) + if err != nil { + return errors.Wrap(err, "failed to add application labels") + } + + err = filesystem.WriteToFile(manifestFilePath, []byte(manifestContent)) + if err != nil { + return errors.Wrap(err, "failed to create temp manifest file") + } + + manifestFilePaths = append(manifestFilePaths, manifestFilePath) + } + + output, err := config.kuberneteDeployer.Deploy(config.user.ID, config.endpoint, manifestFilePaths, config.stack.Namespace) + if err != nil { + return fmt.Errorf("failed to deploy kubernete stack: %w", err) + } + + config.output = output + return nil +} + +func (config *KubernetesStackDeploymentConfig) GetResponse() string { + return config.output +} diff --git a/api/stacks/deployments/deployment_swarm_config.go b/api/stacks/deployments/deployment_swarm_config.go new file mode 100644 index 000000000..eb790f2af --- /dev/null +++ b/api/stacks/deployments/deployment_swarm_config.go @@ -0,0 +1,86 @@ +package deployments + +import ( + "fmt" + "log" + + "github.com/pkg/errors" + 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/stackutils" +) + +type SwarmStackDeploymentConfig struct { + stack *portainer.Stack + endpoint *portainer.Endpoint + registries []portainer.Registry + prune bool + isAdmin bool + user *portainer.User + pullImage bool + FileService portainer.FileService + StackDeployer StackDeployer +} + +func CreateSwarmStackDeploymentConfig(securityContext *security.RestrictedRequestContext, stack *portainer.Stack, endpoint *portainer.Endpoint, dataStore dataservices.DataStore, fileService portainer.FileService, deployer StackDeployer, prune bool, pullImage bool) (*SwarmStackDeploymentConfig, error) { + user, err := dataStore.User().User(securityContext.UserID) + if err != nil { + return nil, fmt.Errorf("unable to load user information from the database: %w", err) + } + + registries, err := dataStore.Registry().Registries() + if err != nil { + return nil, fmt.Errorf("unable to retrieve registries from the database: %w", err) + } + + filteredRegistries := security.FilterRegistries(registries, user, securityContext.UserMemberships, endpoint.ID) + + config := &SwarmStackDeploymentConfig{ + stack: stack, + endpoint: endpoint, + registries: filteredRegistries, + prune: prune, + isAdmin: securityContext.IsAdmin, + user: user, + pullImage: pullImage, + FileService: fileService, + StackDeployer: deployer, + } + + return config, nil +} + +func (config *SwarmStackDeploymentConfig) GetUsername() string { + if config.user != nil { + return config.user.Username + } + return "" +} + +func (config *SwarmStackDeploymentConfig) Deploy() error { + if config.FileService == nil || config.StackDeployer == nil { + log.Println("[deployment, swarm] file service or stack deployer is not initialised") + return errors.New("file service or stack deployer cannot be nil") + } + + isAdminOrEndpointAdmin, err := stackutils.UserIsAdminOrEndpointAdmin(config.user, config.endpoint.ID) + if err != nil { + return errors.Wrap(err, "failed to validate user admin privileges") + } + + settings := &config.endpoint.SecuritySettings + + if !settings.AllowBindMountsForRegularUsers && !isAdminOrEndpointAdmin { + err = stackutils.ValidateStackFiles(config.stack, settings, config.FileService) + if err != nil { + return err + } + } + + return config.StackDeployer.DeploySwarmStack(config.stack, config.endpoint, config.registries, config.prune, config.pullImage) +} + +func (config *SwarmStackDeploymentConfig) GetResponse() string { + return "" +} diff --git a/api/stacks/scheduled.go b/api/stacks/deployments/scheduled.go similarity index 98% rename from api/stacks/scheduled.go rename to api/stacks/deployments/scheduled.go index 4712d4a70..0bf4b28fe 100644 --- a/api/stacks/scheduled.go +++ b/api/stacks/deployments/scheduled.go @@ -1,4 +1,4 @@ -package stacks +package deployments import ( "time" diff --git a/api/stacks/stackbuilders/compose_file_content_builder.go b/api/stacks/stackbuilders/compose_file_content_builder.go new file mode 100644 index 000000000..df72207eb --- /dev/null +++ b/api/stacks/stackbuilders/compose_file_content_builder.go @@ -0,0 +1,81 @@ +package stackbuilders + +import ( + "strconv" + + httperror "github.com/portainer/libhttp/error" + 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 ComposeStackFileContentBuilder struct { + FileContentMethodStackBuilder + SecurityContext *security.RestrictedRequestContext +} + +// CreateComposeStackFileContentBuilder creates a builder for the compose stack (docker standalone) that will be deployed by file content method +func CreateComposeStackFileContentBuilder(securityContext *security.RestrictedRequestContext, + dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer) *ComposeStackFileContentBuilder { + + return &ComposeStackFileContentBuilder{ + FileContentMethodStackBuilder: FileContentMethodStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + }, + SecurityContext: securityContext, + } +} + +func (b *ComposeStackFileContentBuilder) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess { + b.FileContentMethodStackBuilder.SetGeneralInfo(payload, endpoint) + return b +} + +func (b *ComposeStackFileContentBuilder) SetUniqueInfo(payload *StackPayload) FileContentMethodStackBuildProcess { + if b.hasError() { + return b + } + 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 + return b +} + +func (b *ComposeStackFileContentBuilder) SetFileContent(payload *StackPayload) FileContentMethodStackBuildProcess { + if b.hasError() { + return b + } + + stackFolder := strconv.Itoa(int(b.stack.ID)) + projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + b.err = httperror.InternalServerError("Unable to persist Compose file on disk", err) + return b + } + b.stack.ProjectPath = projectPath + + return b +} + +func (b *ComposeStackFileContentBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess { + if b.hasError() { + return b + } + + composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false) + if err != nil { + b.err = httperror.InternalServerError(err.Error(), err) + return b + } + + b.deploymentConfiger = composeDeploymentConfig + b.stack.CreatedBy = b.deploymentConfiger.GetUsername() + + return b.FileContentMethodStackBuilder.Deploy(payload, endpoint) +} diff --git a/api/stacks/stackbuilders/compose_file_upload_builder.go b/api/stacks/stackbuilders/compose_file_upload_builder.go new file mode 100644 index 000000000..1ea5a2556 --- /dev/null +++ b/api/stacks/stackbuilders/compose_file_upload_builder.go @@ -0,0 +1,72 @@ +package stackbuilders + +import ( + httperror "github.com/portainer/libhttp/error" + 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 ComposeStackFileUploadBuilder struct { + FileUploadMethodStackBuilder + SecurityContext *security.RestrictedRequestContext +} + +// CreateComposeStackFileUploadBuilder creates a builder for the compose stack (docker standalone) that will be deployed by file upload method +func CreateComposeStackFileUploadBuilder(securityContext *security.RestrictedRequestContext, + dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer) *ComposeStackFileUploadBuilder { + + return &ComposeStackFileUploadBuilder{ + FileUploadMethodStackBuilder: FileUploadMethodStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + }, + SecurityContext: securityContext, + } +} + +func (b *ComposeStackFileUploadBuilder) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) FileUploadMethodStackBuildProcess { + b.FileUploadMethodStackBuilder.SetGeneralInfo(payload, endpoint) + return b +} + +func (b *ComposeStackFileUploadBuilder) SetUniqueInfo(payload *StackPayload) FileUploadMethodStackBuildProcess { + if b.hasError() { + return b + } + b.stack.Name = payload.Name + b.stack.Type = portainer.DockerComposeStack + b.stack.EntryPoint = filesystem.ComposeFileDefaultName + b.stack.Env = payload.Env + return b +} + +func (b *ComposeStackFileUploadBuilder) SetUploadedFile(payload *StackPayload) FileUploadMethodStackBuildProcess { + if b.hasError() { + return b + } + + b.FileUploadMethodStackBuilder.SetUploadedFile(payload) + + return b +} + +func (b *ComposeStackFileUploadBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileUploadMethodStackBuildProcess { + if b.hasError() { + return b + } + + composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false) + if err != nil { + b.err = httperror.InternalServerError(err.Error(), err) + return b + } + + b.deploymentConfiger = composeDeploymentConfig + b.stack.CreatedBy = b.deploymentConfiger.GetUsername() + + return b.FileUploadMethodStackBuilder.Deploy(payload, endpoint) +} diff --git a/api/stacks/stackbuilders/compose_git_builder.go b/api/stacks/stackbuilders/compose_git_builder.go new file mode 100644 index 000000000..e0198f631 --- /dev/null +++ b/api/stacks/stackbuilders/compose_git_builder.go @@ -0,0 +1,77 @@ +package stackbuilders + +import ( + httperror "github.com/portainer/libhttp/error" + 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) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) GitMethodStackBuildProcess { + b.GitMethodStackBuilder.SetGeneralInfo(payload, endpoint) + return b +} + +func (b *ComposeStackGitBuilder) SetUniqueInfo(payload *StackPayload) GitMethodStackBuildProcess { + if b.hasError() { + return b + } + 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 +} + +func (b *ComposeStackGitBuilder) SetGitRepository(payload *StackPayload) GitMethodStackBuildProcess { + b.GitMethodStackBuilder.SetGitRepository(payload) + return b +} + +func (b *ComposeStackGitBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) GitMethodStackBuildProcess { + if b.hasError() { + return b + } + + composeDeploymentConfig, err := deployments.CreateComposeStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, false) + if err != nil { + b.err = httperror.InternalServerError(err.Error(), err) + return b + } + + b.deploymentConfiger = composeDeploymentConfig + b.stack.CreatedBy = b.deploymentConfiger.GetUsername() + + return b.GitMethodStackBuilder.Deploy(payload, endpoint) +} + +func (b *ComposeStackGitBuilder) SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess { + b.GitMethodStackBuilder.SetAutoUpdate(payload) + return b +} diff --git a/api/stacks/stackbuilders/director.go b/api/stacks/stackbuilders/director.go new file mode 100644 index 000000000..2134c9aba --- /dev/null +++ b/api/stacks/stackbuilders/director.go @@ -0,0 +1,55 @@ +package stackbuilders + +import ( + "errors" + + httperror "github.com/portainer/libhttp/error" + "github.com/portainer/libhttp/request" + portainer "github.com/portainer/portainer/api" +) + +type StackBuilderDirector struct { + builder interface{} +} + +func NewStackBuilderDirector(b interface{}) *StackBuilderDirector { + return &StackBuilderDirector{ + builder: b, + } +} + +func (d *StackBuilderDirector) Build(payload *StackPayload, endpoint *portainer.Endpoint) (*portainer.Stack, *httperror.HandlerError) { + + switch builder := d.builder.(type) { + case GitMethodStackBuildProcess: + return builder.SetGeneralInfo(payload, endpoint). + SetUniqueInfo(payload). + SetGitRepository(payload). + Deploy(payload, endpoint). + SetAutoUpdate(payload). + SaveStack() + + case FileUploadMethodStackBuildProcess: + return builder.SetGeneralInfo(payload, endpoint). + SetUniqueInfo(payload). + SetUploadedFile(payload). + Deploy(payload, endpoint). + SaveStack() + + case FileContentMethodStackBuildProcess: + return builder.SetGeneralInfo(payload, endpoint). + SetUniqueInfo(payload). + SetFileContent(payload). + Deploy(payload, endpoint). + SaveStack() + + case UrlMethodStackBuildProcess: + return builder.SetGeneralInfo(payload, endpoint). + SetUniqueInfo(payload). + SetURL(payload). + Deploy(payload, endpoint). + SaveStack() + } + + return nil, httperror.BadRequest("Invalid value for query parameter: method. Value must be one of: string or repository or url or file", errors.New(request.ErrInvalidQueryParameter)) +} diff --git a/api/stacks/stackbuilders/k8s_file_content_builder.go b/api/stacks/stackbuilders/k8s_file_content_builder.go new file mode 100644 index 000000000..ee0335465 --- /dev/null +++ b/api/stacks/stackbuilders/k8s_file_content_builder.go @@ -0,0 +1,108 @@ +package stackbuilders + +import ( + "fmt" + "strconv" + "sync" + + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/filesystem" + k "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" +) + +type K8sStackFileContentBuilder struct { + FileContentMethodStackBuilder + stackCreateMut *sync.Mutex + KuberneteDeployer portainer.KubernetesDeployer + User *portainer.User +} + +// CreateK8sStackFileContentBuilder creates a builder for the Kubernetes stack that will be deployed by file content method +func CreateK8sStackFileContentBuilder(dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer, + kuberneteDeployer portainer.KubernetesDeployer, + user *portainer.User) *K8sStackFileContentBuilder { + + return &K8sStackFileContentBuilder{ + FileContentMethodStackBuilder: FileContentMethodStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + }, + stackCreateMut: &sync.Mutex{}, + KuberneteDeployer: kuberneteDeployer, + User: user, + } +} + +func (b *K8sStackFileContentBuilder) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess { + b.FileContentMethodStackBuilder.SetGeneralInfo(payload, endpoint) + return b +} + +func (b *K8sStackFileContentBuilder) SetUniqueInfo(payload *StackPayload) FileContentMethodStackBuildProcess { + if b.hasError() { + return b + } + b.stack.Name = payload.StackName + b.stack.Type = portainer.KubernetesStack + b.stack.EntryPoint = filesystem.ManifestFileDefaultName + b.stack.Namespace = payload.Namespace + b.stack.CreatedBy = b.User.Username + b.stack.IsComposeFormat = payload.ComposeFormat + return b +} + +func (b *K8sStackFileContentBuilder) SetFileContent(payload *StackPayload) FileContentMethodStackBuildProcess { + if b.hasError() { + return b + } + + stackFolder := strconv.Itoa(int(b.stack.ID)) + projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + fileType := "Manifest" + if b.stack.IsComposeFormat { + fileType = "Compose" + } + errMsg := fmt.Sprintf("Unable to persist Kubernetes %s file on disk", fileType) + b.err = httperror.InternalServerError(errMsg, err) + return b + } + b.stack.ProjectPath = projectPath + + return b +} + +func (b *K8sStackFileContentBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess { + if b.hasError() { + return b + } + + b.stackCreateMut.Lock() + defer b.stackCreateMut.Unlock() + + k8sAppLabel := k.KubeAppLabels{ + StackID: int(b.stack.ID), + StackName: b.stack.Name, + Owner: stackutils.SanitizeLabel(b.stack.CreatedBy), + Kind: "content", + } + + k8sDeploymentConfig, err := deployments.CreateKubernetesStackDeploymentConfig(b.stack, b.KuberneteDeployer, k8sAppLabel, b.User, endpoint) + if err != nil { + b.err = httperror.InternalServerError("failed to create temp kub deployment files", err) + return b + } + + b.deploymentConfiger = k8sDeploymentConfig + + return b.FileContentMethodStackBuilder.Deploy(payload, endpoint) +} + +func (b *K8sStackFileContentBuilder) GetResponse() string { + return b.FileContentMethodStackBuilder.deploymentConfiger.GetResponse() +} diff --git a/api/stacks/stackbuilders/k8s_git_builder.go b/api/stacks/stackbuilders/k8s_git_builder.go new file mode 100644 index 000000000..1907f8cf7 --- /dev/null +++ b/api/stacks/stackbuilders/k8s_git_builder.go @@ -0,0 +1,100 @@ +package stackbuilders + +import ( + "sync" + + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + k "github.com/portainer/portainer/api/kubernetes" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" +) + +type KubernetesStackGitBuilder struct { + GitMethodStackBuilder + stackCreateMut *sync.Mutex + KuberneteDeployer portainer.KubernetesDeployer + user *portainer.User +} + +// CreateKuberntesStackGitBuilder 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, + kuberneteDeployer portainer.KubernetesDeployer, + user *portainer.User) *KubernetesStackGitBuilder { + + return &KubernetesStackGitBuilder{ + GitMethodStackBuilder: GitMethodStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + gitService: gitService, + scheduler: scheduler, + }, + stackCreateMut: &sync.Mutex{}, + KuberneteDeployer: kuberneteDeployer, + user: user, + } +} + +func (b *KubernetesStackGitBuilder) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) GitMethodStackBuildProcess { + b.GitMethodStackBuilder.SetGeneralInfo(payload, endpoint) + return b +} + +func (b *KubernetesStackGitBuilder) SetUniqueInfo(payload *StackPayload) GitMethodStackBuildProcess { + if b.hasError() { + return b + } + + b.stack.Type = portainer.KubernetesStack + b.stack.Namespace = payload.Namespace + b.stack.Name = payload.StackName + b.stack.EntryPoint = payload.ManifestFile + b.stack.CreatedBy = b.user.Username + b.stack.IsComposeFormat = payload.ComposeFormat + return b +} + +func (b *KubernetesStackGitBuilder) SetGitRepository(payload *StackPayload) GitMethodStackBuildProcess { + b.GitMethodStackBuilder.SetGitRepository(payload) + return b +} + +func (b *KubernetesStackGitBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) GitMethodStackBuildProcess { + if b.hasError() { + return b + } + + b.stackCreateMut.Lock() + defer b.stackCreateMut.Unlock() + + k8sAppLabel := k.KubeAppLabels{ + StackID: int(b.stack.ID), + StackName: b.stack.Name, + Owner: stackutils.SanitizeLabel(b.stack.CreatedBy), + Kind: "git", + } + + k8sDeploymentConfig, err := deployments.CreateKubernetesStackDeploymentConfig(b.stack, b.KuberneteDeployer, k8sAppLabel, b.user, endpoint) + if err != nil { + b.err = httperror.InternalServerError("failed to create temp kub deployment files", err) + return b + } + + b.deploymentConfiger = k8sDeploymentConfig + + return b.GitMethodStackBuilder.Deploy(payload, endpoint) +} + +func (b *KubernetesStackGitBuilder) SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess { + b.GitMethodStackBuilder.SetAutoUpdate(payload) + return b +} + +func (b *KubernetesStackGitBuilder) GetResponse() string { + return b.GitMethodStackBuilder.deploymentConfiger.GetResponse() +} diff --git a/api/stacks/stackbuilders/k8s_url_builder.go b/api/stacks/stackbuilders/k8s_url_builder.go new file mode 100644 index 000000000..ddd1d24ce --- /dev/null +++ b/api/stacks/stackbuilders/k8s_url_builder.go @@ -0,0 +1,111 @@ +package stackbuilders + +import ( + "strconv" + "sync" + + httperror "github.com/portainer/libhttp/error" + 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 KubernetesStackUrlBuilder struct { + UrlMethodStackBuilder + stackCreateMut *sync.Mutex + KuberneteDeployer portainer.KubernetesDeployer + user *portainer.User +} + +// CreateKuberntesStackGitBuilder creates a builder for the Kubernetes stack that will be deployed by git repository method +func CreateKubernetesStackUrlBuilder(dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer, + kuberneteDeployer portainer.KubernetesDeployer, + user *portainer.User) *KubernetesStackUrlBuilder { + + return &KubernetesStackUrlBuilder{ + UrlMethodStackBuilder: UrlMethodStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + }, + stackCreateMut: &sync.Mutex{}, + KuberneteDeployer: kuberneteDeployer, + user: user, + } +} + +func (b *KubernetesStackUrlBuilder) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) UrlMethodStackBuildProcess { + b.UrlMethodStackBuilder.SetGeneralInfo(payload, endpoint) + return b +} + +func (b *KubernetesStackUrlBuilder) SetUniqueInfo(payload *StackPayload) UrlMethodStackBuildProcess { + if b.hasError() { + return b + } + + b.stack.Type = portainer.KubernetesStack + b.stack.Namespace = payload.Namespace + b.stack.Name = payload.StackName + b.stack.EntryPoint = filesystem.ManifestFileDefaultName + b.stack.CreatedBy = b.user.Username + b.stack.IsComposeFormat = payload.ComposeFormat + return b +} + +func (b *KubernetesStackUrlBuilder) SetURL(payload *StackPayload) UrlMethodStackBuildProcess { + if b.hasError() { + return b + } + + var manifestContent []byte + manifestContent, err := client.Get(payload.ManifestURL, 30) + if err != nil { + b.err = httperror.InternalServerError("Unable to retrieve manifest from URL", err) + return b + } + + stackFolder := strconv.Itoa(int(b.stack.ID)) + projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, manifestContent) + if err != nil { + b.err = httperror.InternalServerError("Unable to persist Kubernetes manifest file on disk", err) + return b + } + b.stack.ProjectPath = projectPath + + return b +} + +func (b *KubernetesStackUrlBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) UrlMethodStackBuildProcess { + if b.hasError() { + return b + } + + b.stackCreateMut.Lock() + defer b.stackCreateMut.Unlock() + + k8sAppLabel := k.KubeAppLabels{ + StackID: int(b.stack.ID), + StackName: b.stack.Name, + Owner: stackutils.SanitizeLabel(b.stack.CreatedBy), + Kind: "url", + } + + k8sDeploymentConfig, err := deployments.CreateKubernetesStackDeploymentConfig(b.stack, b.KuberneteDeployer, k8sAppLabel, b.user, endpoint) + if err != nil { + b.err = httperror.InternalServerError("failed to create temp kub deployment files", err) + return b + } + + b.deploymentConfiger = k8sDeploymentConfig + + return b.UrlMethodStackBuilder.Deploy(payload, endpoint) +} + +func (b *KubernetesStackUrlBuilder) GetResponse() string { + return b.UrlMethodStackBuilder.deploymentConfiger.GetResponse() +} diff --git a/api/stacks/stackbuilders/stack_builder.go b/api/stacks/stackbuilders/stack_builder.go new file mode 100644 index 000000000..235e14d56 --- /dev/null +++ b/api/stacks/stackbuilders/stack_builder.go @@ -0,0 +1,62 @@ +package stackbuilders + +import ( + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/dataservices" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/rs/zerolog/log" +) + +type StackBuilder struct { + stack *portainer.Stack + dataStore dataservices.DataStore + fileService portainer.FileService + stackDeployer deployments.StackDeployer + deploymentConfiger deployments.StackDeploymentConfiger + err *httperror.HandlerError + 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) SaveStack() (*portainer.Stack, *httperror.HandlerError) { + defer b.cleanUp() + if b.hasError() { + return nil, b.err + } + + err := b.dataStore.Stack().Create(b.stack) + if err != nil { + b.err = httperror.InternalServerError("Unable to persist the stack inside the database", err) + return nil, b.err + } + + b.doCleanUp = false + return b.stack, b.err +} + +func (b *StackBuilder) cleanUp() error { + if !b.doCleanUp { + return nil + } + + err := b.fileService.RemoveDirectory(b.stack.ProjectPath) + if err != nil { + log.Error().Err(err).Msg("unable to cleanup stack creation") + } + + return nil +} + +func (b *StackBuilder) hasError() bool { + return b.err != nil +} diff --git a/api/stacks/stackbuilders/stack_file_content_builder.go b/api/stacks/stackbuilders/stack_file_content_builder.go new file mode 100644 index 000000000..e80efdef4 --- /dev/null +++ b/api/stacks/stackbuilders/stack_file_content_builder.go @@ -0,0 +1,68 @@ +package stackbuilders + +import ( + "time" + + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" +) + +type FileContentMethodStackBuildProcess interface { + // Set general stack information + SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess + // Set unique stack information, e.g. swarm stack has swarmID, kubernetes stack has namespace + SetUniqueInfo(payload *StackPayload) FileContentMethodStackBuildProcess + // Deploy stack based on the configuration + Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess + // Save the stack information to database + SaveStack() (*portainer.Stack, *httperror.HandlerError) + // Get reponse from http request. Use if it is needed + GetResponse() string + // Process the file content + SetFileContent(payload *StackPayload) FileContentMethodStackBuildProcess +} + +type FileContentMethodStackBuilder struct { + StackBuilder +} + +func (b *FileContentMethodStackBuilder) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess { + stackID := b.dataStore.Stack().GetNextIdentifier() + b.stack.ID = portainer.StackID(stackID) + b.stack.EndpointID = endpoint.ID + b.stack.Status = portainer.StackStatusActive + b.stack.CreationDate = time.Now().Unix() + return b +} + +func (b *FileContentMethodStackBuilder) SetUniqueInfo(payload *StackPayload) FileContentMethodStackBuildProcess { + + return b +} + +func (b *FileContentMethodStackBuilder) SetFileContent(payload *StackPayload) FileContentMethodStackBuildProcess { + if b.hasError() { + return b + } + + return b +} + +func (b *FileContentMethodStackBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess { + if b.hasError() { + return b + } + + // Deploy the stack + err := b.deploymentConfiger.Deploy() + if err != nil { + b.err = httperror.InternalServerError(err.Error(), err) + return b + } + + return b +} + +func (b *FileContentMethodStackBuilder) GetResponse() string { + return "" +} diff --git a/api/stacks/stackbuilders/stack_file_upload_builder.go b/api/stacks/stackbuilders/stack_file_upload_builder.go new file mode 100644 index 000000000..94a3952b3 --- /dev/null +++ b/api/stacks/stackbuilders/stack_file_upload_builder.go @@ -0,0 +1,78 @@ +package stackbuilders + +import ( + "strconv" + "time" + + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" +) + +type FileUploadMethodStackBuildProcess interface { + // Set general stack information + SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) FileUploadMethodStackBuildProcess + // Set unique stack information, e.g. swarm stack has swarmID, kubernetes stack has namespace + SetUniqueInfo(payload *StackPayload) FileUploadMethodStackBuildProcess + // Deploy stack based on the configuration + Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileUploadMethodStackBuildProcess + // Save the stack information to database + SaveStack() (*portainer.Stack, *httperror.HandlerError) + // Get reponse from http request. Use if it is needed + GetResponse() string + // Process the upload file + SetUploadedFile(payload *StackPayload) FileUploadMethodStackBuildProcess +} + +type FileUploadMethodStackBuilder struct { + StackBuilder +} + +func (b *FileUploadMethodStackBuilder) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) FileUploadMethodStackBuildProcess { + stackID := b.dataStore.Stack().GetNextIdentifier() + b.stack.ID = portainer.StackID(stackID) + b.stack.EndpointID = endpoint.ID + b.stack.Status = portainer.StackStatusActive + b.stack.CreationDate = time.Now().Unix() + return b +} + +func (b *FileUploadMethodStackBuilder) SetUniqueInfo(payload *StackPayload) FileUploadMethodStackBuildProcess { + + return b +} + +func (b *FileUploadMethodStackBuilder) SetUploadedFile(payload *StackPayload) FileUploadMethodStackBuildProcess { + if b.hasError() { + return b + } + + stackFolder := strconv.Itoa(int(b.stack.ID)) + projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, payload.StackFileContentBytes) + if err != nil { + b.err = httperror.InternalServerError("Unable to persist Compose file on disk", err) + return b + } + b.stack.ProjectPath = projectPath + + return b +} + +func (b *FileUploadMethodStackBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileUploadMethodStackBuildProcess { + if b.hasError() { + return b + } + + // Deploy the stack + err := b.deploymentConfiger.Deploy() + if err != nil { + b.err = httperror.InternalServerError(err.Error(), err) + return b + } + + b.doCleanUp = false + return b +} + +func (b *FileUploadMethodStackBuilder) GetResponse() string { + return "" +} diff --git a/api/stacks/stackbuilders/stack_git_builder.go b/api/stacks/stackbuilders/stack_git_builder.go new file mode 100644 index 000000000..212a73e62 --- /dev/null +++ b/api/stacks/stackbuilders/stack_git_builder.go @@ -0,0 +1,130 @@ +package stackbuilders + +import ( + "strconv" + "time" + + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + "github.com/portainer/portainer/api/scheduler" + "github.com/portainer/portainer/api/stacks/deployments" + "github.com/portainer/portainer/api/stacks/stackutils" +) + +type GitMethodStackBuildProcess interface { + // Set general stack information + SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) GitMethodStackBuildProcess + // Set unique stack information, e.g. swarm stack has swarmID, kubernetes stack has namespace + SetUniqueInfo(payload *StackPayload) GitMethodStackBuildProcess + // Deploy stack based on the configuration + Deploy(payload *StackPayload, endpoint *portainer.Endpoint) GitMethodStackBuildProcess + // Save the stack information to database and return the stack object + SaveStack() (*portainer.Stack, *httperror.HandlerError) + // Get reponse from http request. Use if it is needed + GetResponse() string + // Set git repository configuration + SetGitRepository(payload *StackPayload) GitMethodStackBuildProcess + // Set auto update setting + SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess +} + +type GitMethodStackBuilder struct { + StackBuilder + gitService portainer.GitService + scheduler *scheduler.Scheduler +} + +func (b *GitMethodStackBuilder) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) GitMethodStackBuildProcess { + stackID := b.dataStore.Stack().GetNextIdentifier() + b.stack.ID = portainer.StackID(stackID) + b.stack.EndpointID = endpoint.ID + b.stack.AdditionalFiles = payload.AdditionalFiles + b.stack.Status = portainer.StackStatusActive + b.stack.CreationDate = time.Now().Unix() + b.stack.AutoUpdate = payload.AutoUpdate + return b +} + +func (b *GitMethodStackBuilder) SetUniqueInfo(payload *StackPayload) GitMethodStackBuildProcess { + + return b +} + +func (b *GitMethodStackBuilder) SetGitRepository(payload *StackPayload) GitMethodStackBuildProcess { + if b.hasError() { + return b + } + + var repoConfig gittypes.RepoConfig + if payload.Authentication { + repoConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryConfigPayload.Username, + Password: payload.RepositoryConfigPayload.Password, + } + } + + repoConfig.URL = payload.URL + repoConfig.ReferenceName = payload.ReferenceName + repoConfig.ConfigFilePath = payload.ComposeFile + if payload.ComposeFile == "" { + repoConfig.ConfigFilePath = filesystem.ComposeFileDefaultName + } + + stackFolder := strconv.Itoa(int(b.stack.ID)) + // Set the project path on the disk + b.stack.ProjectPath = b.fileService.GetStackProjectPath(stackFolder) + + commitHash, err := stackutils.DownloadGitRepository(b.stack.ID, repoConfig, b.gitService, b.fileService) + if err != nil { + b.err = httperror.InternalServerError(err.Error(), err) + return b + } + + // Update the latest commit id + repoConfig.ConfigHash = commitHash + b.stack.GitConfig = &repoConfig + return b +} + +func (b *GitMethodStackBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) GitMethodStackBuildProcess { + if b.hasError() { + return b + } + + // Deploy the stack + err := b.deploymentConfiger.Deploy() + if err != nil { + b.err = httperror.InternalServerError(err.Error(), err) + return b + } + + return b +} + +func (b *GitMethodStackBuilder) SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess { + if b.hasError() { + return b + } + + if payload.AutoUpdate != nil && payload.AutoUpdate.Interval != "" { + jobID, err := deployments.StartAutoupdate(b.stack.ID, + b.stack.AutoUpdate.Interval, + b.scheduler, + b.stackDeployer, + b.dataStore, + b.gitService) + if err != nil { + b.err = err + return b + } + + b.stack.AutoUpdate.JobID = jobID + } + return b +} + +func (b *GitMethodStackBuilder) GetResponse() string { + return "" +} diff --git a/api/stacks/stackbuilders/stack_payload.go b/api/stacks/stackbuilders/stack_payload.go new file mode 100644 index 000000000..8720177d8 --- /dev/null +++ b/api/stacks/stackbuilders/stack_payload.go @@ -0,0 +1,55 @@ +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 in byte format. Used by file upload method + StackFileContentBytes []byte + // Stack file data in string format. Used by file content method + StackFileContent string + Webhook string + // A list of environment(endpoint) variables used during stack deployment + Env []portainer.Pair + // Optional auto update configuration + AutoUpdate *portainer.StackAutoUpdate + // Whether the stack is from a app template + FromAppTemplate bool `example:"false"` + // Kubernetes stack name + StackName string + // Whether the kubernetes stack config file is compose format + ComposeFormat bool + // 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 { + // URL of a Git repository hosting the Stack file + URL string `example:"https://github.com/openfaas/faas" validate:"required"` + // 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 + // and RepositoryGitCredentialID is 0 + Username string `example:"myGitUsername"` + // Password used in basic authentication. Required when RepositoryAuthentication is true + // and RepositoryGitCredentialID is 0 + Password string `example:"myGitPassword"` +} diff --git a/api/stacks/stackbuilders/stack_url_builder.go b/api/stacks/stackbuilders/stack_url_builder.go new file mode 100644 index 000000000..97753efa2 --- /dev/null +++ b/api/stacks/stackbuilders/stack_url_builder.go @@ -0,0 +1,68 @@ +package stackbuilders + +import ( + "time" + + httperror "github.com/portainer/libhttp/error" + portainer "github.com/portainer/portainer/api" +) + +type UrlMethodStackBuildProcess interface { + // Set general stack information + SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) UrlMethodStackBuildProcess + // Set unique stack information, e.g. swarm stack has swarmID, kubernetes stack has namespace + SetUniqueInfo(payload *StackPayload) UrlMethodStackBuildProcess + // Deploy stack based on the configuration + Deploy(payload *StackPayload, endpoint *portainer.Endpoint) UrlMethodStackBuildProcess + // Save the stack information to database + SaveStack() (*portainer.Stack, *httperror.HandlerError) + // Get reponse from http request. Use if it is needed + GetResponse() string + // Set manifest url + SetURL(payload *StackPayload) UrlMethodStackBuildProcess +} + +type UrlMethodStackBuilder struct { + StackBuilder +} + +func (b *UrlMethodStackBuilder) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) UrlMethodStackBuildProcess { + stackID := b.dataStore.Stack().GetNextIdentifier() + b.stack.ID = portainer.StackID(stackID) + b.stack.EndpointID = endpoint.ID + b.stack.Status = portainer.StackStatusActive + b.stack.CreationDate = time.Now().Unix() + return b +} + +func (b *UrlMethodStackBuilder) SetUniqueInfo(payload *StackPayload) UrlMethodStackBuildProcess { + + return b +} + +func (b *UrlMethodStackBuilder) SetURL(payload *StackPayload) UrlMethodStackBuildProcess { + if b.hasError() { + return b + } + + return b +} + +func (b *UrlMethodStackBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) UrlMethodStackBuildProcess { + if b.hasError() { + return b + } + + // Deploy the stack + err := b.deploymentConfiger.Deploy() + if err != nil { + b.err = httperror.InternalServerError(err.Error(), err) + return b + } + + return b +} + +func (b *UrlMethodStackBuilder) GetResponse() string { + return "" +} diff --git a/api/stacks/stackbuilders/swarm_file_content_builder.go b/api/stacks/stackbuilders/swarm_file_content_builder.go new file mode 100644 index 000000000..06e501806 --- /dev/null +++ b/api/stacks/stackbuilders/swarm_file_content_builder.go @@ -0,0 +1,82 @@ +package stackbuilders + +import ( + "strconv" + + httperror "github.com/portainer/libhttp/error" + 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 SwarmStackFileContentBuilder struct { + FileContentMethodStackBuilder + SecurityContext *security.RestrictedRequestContext +} + +// CreateSwarmStackFileContentBuilder creates a builder for the swarm stack that will be deployed by file content method +func CreateSwarmStackFileContentBuilder(securityContext *security.RestrictedRequestContext, + dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer) *SwarmStackFileContentBuilder { + + return &SwarmStackFileContentBuilder{ + FileContentMethodStackBuilder: FileContentMethodStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + }, + SecurityContext: securityContext, + } +} + +func (b *SwarmStackFileContentBuilder) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess { + b.FileContentMethodStackBuilder.SetGeneralInfo(payload, endpoint) + return b +} + +func (b *SwarmStackFileContentBuilder) SetUniqueInfo(payload *StackPayload) FileContentMethodStackBuildProcess { + if b.hasError() { + return b + } + 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 + return b +} + +func (b *SwarmStackFileContentBuilder) SetFileContent(payload *StackPayload) FileContentMethodStackBuildProcess { + if b.hasError() { + return b + } + + stackFolder := strconv.Itoa(int(b.stack.ID)) + projectPath, err := b.fileService.StoreStackFileFromBytes(stackFolder, b.stack.EntryPoint, []byte(payload.StackFileContent)) + if err != nil { + b.err = httperror.InternalServerError("Unable to persist Compose file on disk", err) + return b + } + b.stack.ProjectPath = projectPath + + return b +} + +func (b *SwarmStackFileContentBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileContentMethodStackBuildProcess { + if b.hasError() { + return b + } + + swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, true) + if err != nil { + b.err = httperror.InternalServerError(err.Error(), err) + return b + } + + b.deploymentConfiger = swarmDeploymentConfig + b.stack.CreatedBy = b.deploymentConfiger.GetUsername() + + return b.FileContentMethodStackBuilder.Deploy(payload, endpoint) +} diff --git a/api/stacks/stackbuilders/swarm_file_upload_builder.go b/api/stacks/stackbuilders/swarm_file_upload_builder.go new file mode 100644 index 000000000..73fd8e044 --- /dev/null +++ b/api/stacks/stackbuilders/swarm_file_upload_builder.go @@ -0,0 +1,74 @@ +package stackbuilders + +import ( + httperror "github.com/portainer/libhttp/error" + 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 SwarmStackFileUploadBuilder struct { + FileUploadMethodStackBuilder + SecurityContext *security.RestrictedRequestContext +} + +// CreateSwarmStackFileUploadBuilder creates a builder for the swarm stack that will be deployed by file upload method +func CreateSwarmStackFileUploadBuilder(securityContext *security.RestrictedRequestContext, + dataStore dataservices.DataStore, + fileService portainer.FileService, + stackDeployer deployments.StackDeployer) *SwarmStackFileUploadBuilder { + + return &SwarmStackFileUploadBuilder{ + FileUploadMethodStackBuilder: FileUploadMethodStackBuilder{ + StackBuilder: CreateStackBuilder(dataStore, fileService, stackDeployer), + }, + SecurityContext: securityContext, + } +} + +func (b *SwarmStackFileUploadBuilder) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) FileUploadMethodStackBuildProcess { + b.FileUploadMethodStackBuilder.SetGeneralInfo(payload, endpoint) + + return b +} + +func (b *SwarmStackFileUploadBuilder) SetUniqueInfo(payload *StackPayload) FileUploadMethodStackBuildProcess { + if b.hasError() { + return b + } + 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 + return b +} + +func (b *SwarmStackFileUploadBuilder) SetUploadedFile(payload *StackPayload) FileUploadMethodStackBuildProcess { + if b.hasError() { + return b + } + + b.FileUploadMethodStackBuilder.SetUploadedFile(payload) + + return b +} + +func (b *SwarmStackFileUploadBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) FileUploadMethodStackBuildProcess { + if b.hasError() { + return b + } + + swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, true) + if err != nil { + b.err = httperror.InternalServerError(err.Error(), err) + return b + } + + b.deploymentConfiger = swarmDeploymentConfig + b.stack.CreatedBy = b.deploymentConfiger.GetUsername() + + return b.FileUploadMethodStackBuilder.Deploy(payload, endpoint) +} diff --git a/api/stacks/stackbuilders/swarm_git_builder.go b/api/stacks/stackbuilders/swarm_git_builder.go new file mode 100644 index 000000000..2a437176b --- /dev/null +++ b/api/stacks/stackbuilders/swarm_git_builder.go @@ -0,0 +1,79 @@ +package stackbuilders + +import ( + httperror "github.com/portainer/libhttp/error" + 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) SetGeneralInfo(payload *StackPayload, endpoint *portainer.Endpoint) GitMethodStackBuildProcess { + b.GitMethodStackBuilder.SetGeneralInfo(payload, endpoint) + return b +} + +func (b *SwarmStackGitBuilder) SetUniqueInfo(payload *StackPayload) GitMethodStackBuildProcess { + if b.hasError() { + return b + } + 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 +} + +func (b *SwarmStackGitBuilder) SetGitRepository(payload *StackPayload) GitMethodStackBuildProcess { + b.GitMethodStackBuilder.SetGitRepository(payload) + return b +} + +// Deploy creates deployment configuration for swarm stack +func (b *SwarmStackGitBuilder) Deploy(payload *StackPayload, endpoint *portainer.Endpoint) GitMethodStackBuildProcess { + if b.hasError() { + return b + } + + swarmDeploymentConfig, err := deployments.CreateSwarmStackDeploymentConfig(b.SecurityContext, b.stack, endpoint, b.dataStore, b.fileService, b.stackDeployer, false, true) + if err != nil { + b.err = httperror.InternalServerError(err.Error(), err) + return b + } + + b.deploymentConfiger = swarmDeploymentConfig + b.stack.CreatedBy = b.deploymentConfiger.GetUsername() + + return b.GitMethodStackBuilder.Deploy(payload, endpoint) +} + +func (b *SwarmStackGitBuilder) SetAutoUpdate(payload *StackPayload) GitMethodStackBuildProcess { + b.GitMethodStackBuilder.SetAutoUpdate(payload) + return b +} diff --git a/api/stacks/stackutils/gitops.go b/api/stacks/stackutils/gitops.go new file mode 100644 index 000000000..325e13637 --- /dev/null +++ b/api/stacks/stackutils/gitops.go @@ -0,0 +1,47 @@ +package stackutils + +import ( + "fmt" + + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" + gittypes "github.com/portainer/portainer/api/git/types" +) + +var ( + ErrStackAlreadyExists = errors.New("A stack already exists with this name") + ErrWebhookIDAlreadyExists = errors.New("A webhook ID already exists") + ErrInvalidGitCredential = errors.New("Invalid git credential") +) + +// DownloadGitRepository downloads the target git repository on the disk +// The first return value represents the commit hash of the downloaded git repository +func DownloadGitRepository(stackID portainer.StackID, config gittypes.RepoConfig, gitService portainer.GitService, fileService portainer.FileService) (string, error) { + username := "" + password := "" + if config.Authentication != nil { + username = config.Authentication.Username + password = config.Authentication.Password + } + + stackFolder := fmt.Sprintf("%d", stackID) + projectPath := fileService.GetStackProjectPath(stackFolder) + + err := gitService.CloneRepository(projectPath, config.URL, config.ReferenceName, username, password) + if err != nil { + if err == gittypes.ErrAuthenticationFailure { + newErr := ErrInvalidGitCredential + return "", newErr + } + + newErr := fmt.Errorf("unable to clone git repository: %w", err) + return "", newErr + } + + commitID, err := gitService.LatestCommitID(config.URL, config.ReferenceName, username, password) + if err != nil { + newErr := fmt.Errorf("unable to fetch git repository id: %w", err) + return "", newErr + } + return commitID, nil +} diff --git a/api/stacks/stackutils/util.go b/api/stacks/stackutils/util.go new file mode 100644 index 000000000..bb3d82d23 --- /dev/null +++ b/api/stacks/stackutils/util.go @@ -0,0 +1,39 @@ +package stackutils + +import ( + "fmt" + "regexp" + + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" +) + +func UserIsAdminOrEndpointAdmin(user *portainer.User, endpointID portainer.EndpointID) (bool, error) { + isAdmin := user.Role == portainer.AdministratorRole + + return isAdmin, nil +} + +// GetStackFilePaths returns a list of file paths based on stack project path +func GetStackFilePaths(stack *portainer.Stack, absolute bool) []string { + if !absolute { + return append([]string{stack.EntryPoint}, stack.AdditionalFiles...) + } + + var filePaths []string + for _, file := range append([]string{stack.EntryPoint}, stack.AdditionalFiles...) { + filePaths = append(filePaths, filesystem.JoinPaths(stack.ProjectPath, file)) + } + return filePaths +} + +// ResourceControlID returns the stack resource control id +func ResourceControlID(endpointID portainer.EndpointID, name string) string { + return fmt.Sprintf("%d_%s", endpointID, name) +} + +// convert string to valid kubernetes label by replacing invalid characters with periods +func SanitizeLabel(value string) string { + re := regexp.MustCompile(`[^A-Za-z0-9\.\-\_]+`) + return re.ReplaceAllString(value, ".") +} diff --git a/api/internal/stackutils/stackutils_test.go b/api/stacks/stackutils/util_test.go similarity index 82% rename from api/internal/stackutils/stackutils_test.go rename to api/stacks/stackutils/util_test.go index 6af19d8af..79d64b251 100644 --- a/api/internal/stackutils/stackutils_test.go +++ b/api/stacks/stackutils/util_test.go @@ -15,12 +15,12 @@ func Test_GetStackFilePaths(t *testing.T) { t.Run("stack doesn't have additional files", func(t *testing.T) { expected := []string{"/tmp/stack/1/file-one.yml"} - assert.ElementsMatch(t, expected, GetStackFilePaths(stack)) + assert.ElementsMatch(t, expected, GetStackFilePaths(stack, true)) }) t.Run("stack has additional files", func(t *testing.T) { stack.AdditionalFiles = []string{"file-two.yml", "file-three.yml"} expected := []string{"/tmp/stack/1/file-one.yml", "/tmp/stack/1/file-two.yml", "/tmp/stack/1/file-three.yml"} - assert.ElementsMatch(t, expected, GetStackFilePaths(stack)) + assert.ElementsMatch(t, expected, GetStackFilePaths(stack, true)) }) } diff --git a/api/stacks/stackutils/validation.go b/api/stacks/stackutils/validation.go new file mode 100644 index 000000000..e5ffe564d --- /dev/null +++ b/api/stacks/stackutils/validation.go @@ -0,0 +1,98 @@ +package stackutils + +import ( + "time" + + "github.com/asaskevich/govalidator" + "github.com/docker/cli/cli/compose/loader" + "github.com/docker/cli/cli/compose/types" + "github.com/pkg/errors" + portainer "github.com/portainer/portainer/api" +) + +func IsValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error { + composeConfigYAML, err := loader.ParseYAML(stackFileContent) + if err != nil { + return err + } + + composeConfigFile := types.ConfigFile{ + Config: composeConfigYAML, + } + + composeConfigDetails := types.ConfigDetails{ + ConfigFiles: []types.ConfigFile{composeConfigFile}, + Environment: map[string]string{}, + } + + composeConfig, err := loader.Load(composeConfigDetails, func(options *loader.Options) { + options.SkipValidation = true + options.SkipInterpolation = true + }) + if err != nil { + return err + } + + for key := range composeConfig.Services { + service := composeConfig.Services[key] + if !securitySettings.AllowBindMountsForRegularUsers { + for _, volume := range service.Volumes { + if volume.Type == "bind" { + return errors.New("bind-mount disabled for non administrator users") + } + } + } + + if !securitySettings.AllowPrivilegedModeForRegularUsers && service.Privileged == true { + return errors.New("privileged mode disabled for non administrator users") + } + + if !securitySettings.AllowHostNamespaceForRegularUsers && service.Pid == "host" { + return errors.New("pid host disabled for non administrator users") + } + + if !securitySettings.AllowDeviceMappingForRegularUsers && service.Devices != nil && len(service.Devices) > 0 { + return errors.New("device mapping disabled for non administrator users") + } + + if !securitySettings.AllowSysctlSettingForRegularUsers && service.Sysctls != nil && len(service.Sysctls) > 0 { + return errors.New("sysctl setting disabled for non administrator users") + } + + if !securitySettings.AllowContainerCapabilitiesForRegularUsers && (len(service.CapAdd) > 0 || len(service.CapDrop) > 0) { + return errors.New("container capabilities disabled for non administrator users") + } + } + + return nil +} + +func ValidateStackAutoUpdate(autoUpdate *portainer.StackAutoUpdate) error { + if autoUpdate == nil { + return nil + } + if autoUpdate.Webhook != "" && !govalidator.IsUUID(autoUpdate.Webhook) { + return errors.New("invalid Webhook format") + } + if autoUpdate.Interval != "" { + if _, err := time.ParseDuration(autoUpdate.Interval); err != nil { + return errors.New("invalid Interval format") + } + } + return nil +} + +func ValidateStackFiles(stack *portainer.Stack, securitySettings *portainer.EndpointSecuritySettings, fileService portainer.FileService) error { + for _, file := range GetStackFilePaths(stack, false) { + stackContent, err := fileService.GetFileContent(stack.ProjectPath, file) + if err != nil { + return errors.Wrap(err, "failed to get stack file content") + } + + err = IsValidStackFile(stackContent, securitySettings) + if err != nil { + return errors.Wrap(err, "stack config file is invalid") + } + } + return nil +} diff --git a/api/http/handler/stacks/helper_test.go b/api/stacks/stackutils/validation_test.go similarity index 93% rename from api/http/handler/stacks/helper_test.go rename to api/stacks/stackutils/validation_test.go index c3e564349..b256cbfb5 100644 --- a/api/http/handler/stacks/helper_test.go +++ b/api/stacks/stackutils/validation_test.go @@ -1,4 +1,4 @@ -package stacks +package stackutils import ( "testing" @@ -35,7 +35,7 @@ func Test_ValidateStackAutoUpdate(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - err := validateStackAutoUpdate(tt.value) + err := ValidateStackAutoUpdate(tt.value) assert.Equalf(t, tt.wantErr, err != nil, "received %+v", err) }) }