Compare commits
10 Commits
release/2.
...
feat/EE-82
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b311f6b5a | ||
|
|
08529ac0f2 | ||
|
|
ce7d52dda3 | ||
|
|
93ce0bd8f6 | ||
|
|
6f1ef44def | ||
|
|
90bbc18aab | ||
|
|
3d47258069 | ||
|
|
b5ea45f81b | ||
|
|
fff4f6819d | ||
|
|
45b477aa45 |
@@ -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
19
api/git/types/types.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
27
api/http/handler/stacks/helper.go
Normal file
27
api/http/handler/stacks/helper.go
Normal 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
|
||||
}
|
||||
33
api/http/handler/stacks/helper_test.go
Normal file
33
api/http/handler/stacks/helper_test.go
Normal 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)
|
||||
}
|
||||
125
api/http/handler/stacks/stack_update_git_config.go
Normal file
125
api/http/handler/stacks/stack_update_git_config.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user