Compare commits

...

10 Commits

Author SHA1 Message Date
ArrisLee
5b311f6b5a modify StackAutoUpdate struct 2021-06-21 09:50:50 +12:00
ArrisLee
08529ac0f2 rename payload 2021-06-21 09:46:38 +12:00
ArrisLee
ce7d52dda3 rename git config update handler 2021-06-21 09:37:16 +12:00
ArrisLee
93ce0bd8f6 remove git config struct from portainer.go 2021-06-20 23:40:45 +12:00
ArrisLee
6f1ef44def Merge branch 'feat/EE-829/implement-edit-git-deploy-stack-endpoint' of github.com:portainer/portainer into feat/EE-829/implement-edit-git-deploy-stack-endpoint 2021-06-20 23:39:29 +12:00
ArrisLee
90bbc18aab apply repo config from EE-161 2021-06-20 23:38:45 +12:00
Hui
3d47258069 Update api/http/handler/stacks/stack_update_git.go
Co-authored-by: Chaim Lev-Ari <chiptus@users.noreply.github.com>
2021-06-17 09:30:07 +12:00
ArrisLee
b5ea45f81b refine unit test 2021-06-15 16:12:51 +12:00
ArrisLee
fff4f6819d remove the logic of stack redeployment 2021-06-15 15:49:13 +12:00
Hui
45b477aa45 add edit git stack handler 2021-06-15 15:49:08 +12:00
9 changed files with 287 additions and 28 deletions

View File

@@ -2,16 +2,17 @@ package git
import (
"context"
"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"
"github.com/stretchr/testify/assert"
"io/ioutil"
"log"
"os"
"path/filepath"
"testing"
"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"
"github.com/stretchr/testify/assert"
)
var bareRepoDir string

19
api/git/types/types.go Normal file
View File

@@ -0,0 +1,19 @@
package gittypes
// RepoConfig represents a configuration for a repo
type RepoConfig struct {
// The repo url
URL string `example:"https://github.com/portainer/portainer-ee.git"`
// The reference name
ReferenceName string `example:"refs/heads/branch_name"`
// Path to where the config file is in this url/refName
ConfigFilePath string `example:"docker-compose.yml"`
// Git credentials
Authentication *GitAuthentication
ConfigHash []byte
}
type GitAuthentication struct {
Username string
Password string
}

View File

@@ -13,6 +13,7 @@ import (
"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"
)
@@ -112,8 +113,9 @@ type composeStackFromGitRepositoryPayload struct {
// Password used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryPassword string `example:"myGitPassword"`
// Path to the Stack file inside the Git repository
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"`
AdditionalFiles []string
AutoUpdate *portainer.StackAutoUpdate
// A list of environment variables used during stack deployment
Env []portainer.Pair
}
@@ -126,8 +128,11 @@ func (payload *composeStackFromGitRepositoryPayload) Validate(r *http.Request) e
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) {
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
if govalidator.IsNull(payload.RepositoryReferenceName) {
return errors.New("Invalid RepositoryReferenceName")
}
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
return nil
@@ -141,8 +146,8 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
}
payload.Name = handler.ComposeStackManager.NormalizeStackName(payload.Name)
if payload.ComposeFilePathInRepository == "" {
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
if payload.ComposeFile == "" {
payload.ComposeFile = filesystem.ComposeFileDefaultName
}
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false)
@@ -156,16 +161,29 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{
ID: portainer.StackID(stackID),
Name: payload.Name,
Type: portainer.DockerComposeStack,
EndpointID: endpoint.ID,
EntryPoint: payload.ComposeFilePathInRepository,
Env: payload.Env,
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,
GitConfig: &gittypes.RepoConfig{
URL: payload.RepositoryURL,
ReferenceName: payload.RepositoryReferenceName,
},
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

View File

@@ -13,6 +13,7 @@ import (
"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"
)
@@ -119,7 +120,9 @@ type swarmStackFromGitRepositoryPayload struct {
// Password used in basic authentication. Required when RepositoryAuthentication is true.
RepositoryPassword string `example:"myGitPassword"`
// Path to the Stack file inside the Git repository
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
ComposeFile string `example:"docker-compose.yml" default:"docker-compose.yml"`
AdditionalFiles []string
AutoUpdate *portainer.StackAutoUpdate
}
func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error {
@@ -132,11 +135,14 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
return errors.New("Invalid repository URL. Must correspond to a valid URL format")
}
if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) {
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
if govalidator.IsNull(payload.RepositoryReferenceName) {
return errors.New("Invalid RepositoryReferenceName")
}
if govalidator.IsNull(payload.ComposeFilePathInRepository) {
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
}
if govalidator.IsNull(payload.ComposeFile) {
payload.ComposeFile = filesystem.ComposeFileDefaultName
}
return nil
}
@@ -159,17 +165,30 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
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.ComposeFilePathInRepository,
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,
GitConfig: &gittypes.RepoConfig{
URL: payload.RepositoryURL,
ReferenceName: payload.RepositoryReferenceName,
},
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

View File

@@ -54,6 +54,8 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackDelete))).Methods(http.MethodDelete)
h.Handle("/stacks/{id}",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackUpdate))).Methods(http.MethodPut)
h.Handle("/stacks/{id}/git/config",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackGitConfigUpdate))).Methods(http.MethodPost)
h.Handle("/stacks/{id}/file",
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.stackFile))).Methods(http.MethodGet)
h.Handle("/stacks/{id}/migrate",

View File

@@ -0,0 +1,27 @@
package stacks
import (
"errors"
"time"
"github.com/asaskevich/govalidator"
portainer "github.com/portainer/portainer/api"
)
func validateStackAutoUpdate(autoUpdate *portainer.StackAutoUpdate) error {
if autoUpdate == nil {
return nil
}
if autoUpdate.Interval == "" && autoUpdate.Webhook == "" {
return errors.New("Both Interval and Webhook fields are empty")
}
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
}

View File

@@ -0,0 +1,33 @@
package stacks
import (
"testing"
portainer "github.com/portainer/portainer/api"
"github.com/stretchr/testify/assert"
)
func Test_ValidateStackAutoUpdate_Valid(t *testing.T) {
mock := &portainer.StackAutoUpdate{
Webhook: "8dce8c2f-9ca1-482b-ad20-271e86536ada",
Interval: "5h30m40s10ms",
}
err := validateStackAutoUpdate(mock)
assert.NoError(t, err)
mock = nil
err = validateStackAutoUpdate(mock)
assert.NoError(t, err)
}
func Test_ValidateStackAutoUpdate_InValid(t *testing.T) {
mock := &portainer.StackAutoUpdate{}
err := validateStackAutoUpdate(mock)
assert.Error(t, err)
mock.Webhook = "fake-web-hook"
err = validateStackAutoUpdate(mock)
assert.Error(t, err)
mock.Webhook = ""
mock.Interval = "1dd2hh3mm"
err = validateStackAutoUpdate(mock)
assert.Error(t, err)
}

View File

@@ -0,0 +1,125 @@
package stacks
import (
"errors"
"net/http"
"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"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/stackutils"
)
type stackGitConfigUpdatePayload struct {
AutoUpdate *portainer.StackAutoUpdate
Env []portainer.Pair
RepositoryReferenceName string
}
func (payload *stackGitConfigUpdatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.RepositoryReferenceName) {
return errors.New("Invalid RepositoryReferenceName")
}
if err := validateStackAutoUpdate(payload.AutoUpdate); err != nil {
return err
}
return nil
}
// @id Stacks
// @summary Update and redeploy an existing stack (with Git config)
// @description Update and redeploy an existing stack (with Git config)
// @description **Access policy**: authenticated
// @tags stacks
// @security jwt
// @produce json
// @param id path int true "Stack identifier"
// @param endpointId query int false "Stacks created before version 1.18.0 might not have an associated endpoint identifier. Use this optional parameter to set the endpoint identifier used by the stack."
// @param body body stackGitConfigUpdatePayload true "Stack Git config"
// @success 200 {object} portainer.Stack "Success"
// @failure 400 "Invalid request"
// @failure 403 "Permission denied"
// @failure 404 "Not found"
// @failure 500 "Server error"
// @router /stacks/{id}/git [post]
func (handler *Handler) stackGitConfigUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid stack identifier route variable", Err: err}
}
var payload stackGitConfigUpdatePayload
err = request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
} else if stack.GitConfig == nil {
msg := "No Git config in the found stack"
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: msg, Err: errors.New(msg)}
}
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
// The EndpointID property is not available for these stacks, this API endpoint
// can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack.
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err}
}
if endpointID != int(stack.EndpointID) {
stack.EndpointID = portainer.EndpointID(endpointID)
}
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{StatusCode: http.StatusNotFound, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
} else if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find the endpoint associated to the stack inside the database", Err: err}
}
err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err}
}
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
}
if !access {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
}
//update retrieved stack data based on the payload
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
stack.AutoUpdate = payload.AutoUpdate
stack.Env = payload.Env
//save the updated stack to DB
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: err}
}
return response.JSON(w, stack)
}

View File

@@ -3,6 +3,8 @@ package portainer
import (
"io"
"time"
gittypes "github.com/portainer/portainer/api/git/types"
)
type (
@@ -681,6 +683,12 @@ type (
SwarmID string `json:"SwarmId" example:"jpofkc0i9uo9wtx1zesuk649w"`
// Path to the Stack file
EntryPoint string `json:"EntryPoint" example:"docker-compose.yml"`
// Additional Stack files
AdditionalFiles []string `json:"AdditionalFiles" example:""`
// Stack auto update settings
AutoUpdate *StackAutoUpdate `json:"AutoUpdate" example:""`
// Git settings
GitConfig *gittypes.RepoConfig `json:"GitConfig"`
// A list of environment variables used during stack deployment
Env []Pair `json:"Env" example:""`
//
@@ -699,6 +707,13 @@ type (
UpdatedBy string `example:"bob"`
}
// StackAutoUpdate represents the git auto sync config for stack deployment
StackAutoUpdate struct {
Interval string
Webhook string //a UUID generated from client
JobID string
}
// StackID represents a stack identifier (it must be composed of Name + "_" + SwarmID to create a unique identifier)
StackID int