feat(kube): advanced apps management [EE-466] (#5446)

* feat(stack): backport changes to CE EE-1189

* feat(stack): front end backport changes to CE EE-1199 (#5455)

* feat(stack): front end backport changes to CE EE-1199

* fix k8s deploy logic

* fixed web editor confirmation message typo. EE-1501

* fix(stack): fixed issue auth detail not remembered EE-1502 (#5459)

* show status in buttons

* removed onChangeRef function.

* moved buttons in git form to its own component

* removed unused variable.

Co-authored-by: ArrisLee <arris_li@hotmail.com>

* moved formvalue to kube app component

* fix(stack): failed to pull and redeploy compose format k8s stack

* fixed form value

* fix(k8s): file content overridden when deployment failed with compose format EE-1548

* updated API response to get IsComposeFormat and show appropriate text.

* error message updates for different file type

* not display creation source for external application

* added confirmation modal to advanced app created by web editor

* stop showing confirmation modal when updating application

* disable rollback button when application type is not applicatiom form

* added analytics-on directive to pull and redeploy button

* fix(kube): don't valide resource control access for kube (#5568)

* added question marks to k8s app confirmation modal

* fix(k8s): Git authentication info not persisted

* removed unused function.

Co-authored-by: Hui <arris_li@hotmail.com>
Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com>
Co-authored-by: Felix Han <felix.han@portainer.io>
pull/5586/head
Dmitry Salakhov 2021-09-07 12:37:26 +12:00 committed by GitHub
parent f039292211
commit e49e90f304
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 2234 additions and 1657 deletions

View File

@ -1,7 +1,6 @@
package stacks package stacks
import ( import (
"context"
"fmt" "fmt"
"net/http" "net/http"
"path" "path"
@ -405,15 +404,5 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig)
} }
} }
handler.stackCreationMutex.Lock() return handler.StackDeployer.DeployComposeStack(config.stack, config.endpoint, config.registries)
defer handler.stackCreationMutex.Unlock()
handler.SwarmStackManager.Login(config.registries, config.endpoint)
err = handler.ComposeStackManager.Up(context.TODO(), config.stack, config.endpoint)
if err != nil {
return errors.Wrap(err, "failed to start up the stack")
}
return handler.SwarmStackManager.Logout(config.endpoint)
} }

View File

@ -1,26 +1,27 @@
package stacks package stacks
import ( import (
"fmt"
"io/ioutil" "io/ioutil"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strconv" "strconv"
"time" "time"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors" "github.com/pkg/errors"
"github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api" portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/filesystem"
k "github.com/portainer/portainer/api/kubernetes" gittypes "github.com/portainer/portainer/api/git/types"
"github.com/portainer/portainer/api/http/client" "github.com/portainer/portainer/api/http/client"
k "github.com/portainer/portainer/api/kubernetes"
) )
const defaultReferenceName = "refs/heads/master"
type kubernetesStringDeploymentPayload struct { type kubernetesStringDeploymentPayload struct {
ComposeFormat bool ComposeFormat bool
Namespace string Namespace string
@ -39,9 +40,9 @@ type kubernetesGitDeploymentPayload struct {
} }
type kubernetesManifestURLDeploymentPayload struct { type kubernetesManifestURLDeploymentPayload struct {
Namespace string Namespace string
ComposeFormat bool ComposeFormat bool
ManifestURL string ManifestURL string
} }
func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error { func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error {
@ -68,7 +69,7 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
return errors.New("Invalid file path in repository") return errors.New("Invalid file path in repository")
} }
if govalidator.IsNull(payload.RepositoryReferenceName) { if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultReferenceName payload.RepositoryReferenceName = defaultGitReferenceName
} }
return nil return nil
} }
@ -84,26 +85,39 @@ type createKubernetesStackResponse struct {
Output string `json:"Output"` Output string `json:"Output"`
} }
func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
var payload kubernetesStringDeploymentPayload var payload kubernetesStringDeploymentPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
} }
user, err := handler.DataStore.User().User(userID)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
}
stackID := handler.DataStore.Stack().GetNextIdentifier() stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{ stack := &portainer.Stack{
ID: portainer.StackID(stackID), ID: portainer.StackID(stackID),
Type: portainer.KubernetesStack, Type: portainer.KubernetesStack,
EndpointID: endpoint.ID, EndpointID: endpoint.ID,
EntryPoint: filesystem.ManifestFileDefaultName, EntryPoint: filesystem.ManifestFileDefaultName,
Status: portainer.StackStatusActive, Namespace: payload.Namespace,
CreationDate: time.Now().Unix(), Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
CreatedBy: user.Username,
IsComposeFormat: payload.ComposeFormat,
} }
stackFolder := strconv.Itoa(int(stack.ID)) stackFolder := strconv.Itoa(int(stack.ID))
projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err} fileType := "Manifest"
if stack.IsComposeFormat {
fileType = "Compose"
}
errMsg := fmt.Sprintf("Unable to persist Kubernetes %s file on disk", fileType)
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: errMsg, Err: err}
} }
stack.ProjectPath = projectPath stack.ProjectPath = projectPath
@ -116,6 +130,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
Owner: stack.CreatedBy, Owner: stack.CreatedBy,
Kind: "content", Kind: "content",
}) })
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
} }
@ -125,6 +140,8 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err}
} }
doCleanUp = false
resp := &createKubernetesStackResponse{ resp := &createKubernetesStackResponse{
Output: output, Output: output,
} }
@ -134,20 +151,40 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
return response.JSON(w, resp) return response.JSON(w, resp)
} }
func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
var payload kubernetesGitDeploymentPayload var payload kubernetesGitDeploymentPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
} }
user, err := handler.DataStore.User().User(userID)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
}
stackID := handler.DataStore.Stack().GetNextIdentifier() stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{ stack := &portainer.Stack{
ID: portainer.StackID(stackID), ID: portainer.StackID(stackID),
Type: portainer.KubernetesStack, Type: portainer.KubernetesStack,
EndpointID: endpoint.ID, EndpointID: endpoint.ID,
EntryPoint: payload.FilePathInRepository, EntryPoint: payload.FilePathInRepository,
Status: portainer.StackStatusActive, GitConfig: &gittypes.RepoConfig{
CreationDate: time.Now().Unix(), URL: payload.RepositoryURL,
ReferenceName: payload.RepositoryReferenceName,
ConfigFilePath: payload.FilePathInRepository,
},
Namespace: payload.Namespace,
Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(),
CreatedBy: user.Username,
IsComposeFormat: payload.ComposeFormat,
}
if payload.RepositoryAuthentication {
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: payload.RepositoryPassword,
}
} }
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID))) projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
@ -156,6 +193,12 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
doCleanUp := true doCleanUp := true
defer handler.cleanUp(stack, &doCleanUp) defer handler.cleanUp(stack, &doCleanUp)
commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err}
}
stack.GitConfig.ConfigHash = commitId
stackFileContent, err := handler.cloneManifestContentFromGitRepo(&payload, stack.ProjectPath) stackFileContent, err := handler.cloneManifestContentFromGitRepo(&payload, stack.ProjectPath)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err}
@ -167,6 +210,7 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
Owner: stack.CreatedBy, Owner: stack.CreatedBy,
Kind: "git", Kind: "git",
}) })
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
} }
@ -176,6 +220,8 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
} }
doCleanUp = false
resp := &createKubernetesStackResponse{ resp := &createKubernetesStackResponse{
Output: output, Output: output,
} }
@ -185,27 +231,33 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
return response.JSON(w, resp) return response.JSON(w, resp)
} }
func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
var payload kubernetesManifestURLDeploymentPayload var payload kubernetesManifestURLDeploymentPayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
} }
user, err := handler.DataStore.User().User(userID)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to load user information from the database", Err: err}
}
stackID := handler.DataStore.Stack().GetNextIdentifier() stackID := handler.DataStore.Stack().GetNextIdentifier()
stack := &portainer.Stack{ stack := &portainer.Stack{
ID: portainer.StackID(stackID), ID: portainer.StackID(stackID),
Type: portainer.KubernetesStack, Type: portainer.KubernetesStack,
EndpointID: endpoint.ID, EndpointID: endpoint.ID,
EntryPoint: filesystem.ManifestFileDefaultName, EntryPoint: filesystem.ManifestFileDefaultName,
Status: portainer.StackStatusActive, Status: portainer.StackStatusActive,
CreationDate: time.Now().Unix(), CreationDate: time.Now().Unix(),
CreatedBy: user.Username,
IsComposeFormat: payload.ComposeFormat,
} }
var manifestContent []byte var manifestContent []byte
manifestContent, err := client.Get(payload.ManifestURL, 30) manifestContent, err = client.Get(payload.ManifestURL, 30)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve manifest from URL", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve manifest from URL", Err: err}
} }
stackFolder := strconv.Itoa(int(stack.ID)) stackFolder := strconv.Itoa(int(stack.ID))
@ -240,7 +292,7 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit
return response.JSON(w, resp) return response.JSON(w, resp)
} }
func (handler *Handler) deployKubernetesStack(r *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string, appLabels k.KubeAppLabels) (string, error) { func (handler *Handler) deployKubernetesStack(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string, appLabels k.KubeAppLabels) (string, error) {
handler.stackCreationMutex.Lock() handler.stackCreationMutex.Lock()
defer handler.stackCreationMutex.Unlock() defer handler.stackCreationMutex.Unlock()
@ -258,7 +310,7 @@ func (handler *Handler) deployKubernetesStack(r *http.Request, endpoint *portain
return "", errors.Wrap(err, "failed to add application labels") return "", errors.Wrap(err, "failed to add application labels")
} }
return handler.KubernetesDeployer.Deploy(r, endpoint, string(manifest), namespace) return handler.KubernetesDeployer.Deploy(request, endpoint, string(manifest), namespace)
} }
func (handler *Handler) cloneManifestContentFromGitRepo(gitInfo *kubernetesGitDeploymentPayload, projectPath string) (string, error) { func (handler *Handler) cloneManifestContentFromGitRepo(gitInfo *kubernetesGitDeploymentPayload, projectPath string) (string, error) {

View File

@ -1,13 +1,14 @@
package stacks package stacks
import ( import (
"errors"
"fmt" "fmt"
"net/http" "net/http"
"path" "path"
"strconv" "strconv"
"time" "time"
"github.com/pkg/errors"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
@ -391,7 +392,7 @@ func (handler *Handler) createSwarmDeployConfig(r *http.Request, stack *portaine
func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) error { func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) error {
isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID) isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID)
if err != nil { if err != nil {
return err return errors.Wrap(err, "failed to validate user admin privileges")
} }
settings := &config.endpoint.SecuritySettings settings := &config.endpoint.SecuritySettings
@ -401,30 +402,15 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err
path := path.Join(config.stack.ProjectPath, file) path := path.Join(config.stack.ProjectPath, file)
stackContent, err := handler.FileService.GetFileContent(path) stackContent, err := handler.FileService.GetFileContent(path)
if err != nil { if err != nil {
return err return errors.WithMessage(err, "failed to get stack file content")
} }
err = handler.isValidStackFile(stackContent, settings) err = handler.isValidStackFile(stackContent, settings)
if err != nil { if err != nil {
return err return errors.WithMessage(err, "swarm stack file content validation failed")
} }
} }
} }
handler.stackCreationMutex.Lock() return handler.StackDeployer.DeploySwarmStack(config.stack, config.endpoint, config.registries, config.prune)
defer handler.stackCreationMutex.Unlock()
handler.SwarmStackManager.Login(config.registries, config.endpoint)
err = handler.SwarmStackManager.Deploy(config.stack, config.prune, config.endpoint)
if err != nil {
return err
}
err = handler.SwarmStackManager.Logout(config.endpoint)
if err != nil {
return err
}
return nil
} }

View File

@ -110,7 +110,7 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt
case portainer.DockerComposeStack: case portainer.DockerComposeStack:
return handler.createComposeStack(w, r, method, endpoint, tokenData.ID) return handler.createComposeStack(w, r, method, endpoint, tokenData.ID)
case portainer.KubernetesStack: case portainer.KubernetesStack:
return handler.createKubernetesStack(w, r, method, endpoint) return handler.createKubernetesStack(w, r, method, endpoint, tokenData.ID)
} }
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", errors.New(request.ErrInvalidQueryParameter)} return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", errors.New(request.ErrInvalidQueryParameter)}
@ -143,14 +143,14 @@ func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request,
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string, repository or file", Err: errors.New(request.ErrInvalidQueryParameter)} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string, repository or file", Err: errors.New(request.ErrInvalidQueryParameter)}
} }
func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError { func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError {
switch method { switch method {
case "string": case "string":
return handler.createKubernetesStackFromFileContent(w, r, endpoint) return handler.createKubernetesStackFromFileContent(w, r, endpoint, userID)
case "repository": case "repository":
return handler.createKubernetesStackFromGitRepository(w, r, endpoint) return handler.createKubernetesStackFromGitRepository(w, r, endpoint, userID)
case "url": case "url":
return handler.createKubernetesStackFromManifestURL(w, r, endpoint) return handler.createKubernetesStackFromManifestURL(w, r, endpoint, userID)
} }
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string or repository", Err: errors.New(request.ErrInvalidQueryParameter)} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string or repository", Err: errors.New(request.ErrInvalidQueryParameter)}
} }

View File

@ -88,12 +88,14 @@ func (handler *Handler) stackDelete(w http.ResponseWriter, r *http.Request) *htt
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
} }
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
if err != nil { access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} if err != nil {
} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
if !access { }
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
}
} }
} }

View File

@ -66,17 +66,19 @@ func (handler *Handler) stackFile(w http.ResponseWriter, r *http.Request) *httpe
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
} }
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
if err != nil { resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} if err != nil {
} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
} }
if !access { if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
}
} }
} }

View File

@ -1,9 +1,10 @@
package stacks package stacks
import ( import (
"github.com/portainer/portainer/api/http/errors"
"net/http" "net/http"
"github.com/portainer/portainer/api/http/errors"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
@ -60,21 +61,23 @@ func (handler *Handler) stackInspect(w http.ResponseWriter, r *http.Request) *ht
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
} }
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
if err != nil { resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} if err != nil {
} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
} }
if !access { if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied} return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", errors.ErrResourceAccessDenied}
} }
if resourceControl != nil { if resourceControl != nil {
stack.ResourceControl = resourceControl stack.ResourceControl = resourceControl
}
} }
} }

View File

@ -78,22 +78,24 @@ func (handler *Handler) stackMigrate(w http.ResponseWriter, r *http.Request) *ht
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
} }
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
if err != nil { resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} if err != nil {
} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err}
} }
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
} }
if !access { if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
}
} }
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1

View File

@ -69,17 +69,19 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http
return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)} return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)}
} }
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
if err != nil { resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} if err != nil {
} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
} }
if !access { if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
}
} }
if stack.Status == portainer.StackStatusActive { if stack.Status == portainer.StackStatusActive {

View File

@ -58,17 +58,19 @@ func (handler *Handler) stackStop(w http.ResponseWriter, r *http.Request) *httpe
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err}
} }
resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl) if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
if err != nil { resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} if err != nil {
} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
}
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err} return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
} }
if !access { if !access {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied}
}
} }
if stack.Status == portainer.StackStatusInactive { if stack.Status == portainer.StackStatusInactive {

View File

@ -1,11 +1,12 @@
package stacks package stacks
import ( import (
"errors"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"github.com/pkg/errors"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
@ -72,9 +73,9 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID)) stack, err := handler.DataStore.Stack().Stack(portainer.StackID(stackID))
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find a stack with the specified identifier inside the database", err} 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 { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find a stack with the specified identifier inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to find a stack with the specified identifier inside the database", Err: err}
} }
// TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1
@ -82,7 +83,7 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
// can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack. // can use the optional EndpointID query parameter to associate a valid endpoint identifier to the stack.
endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid query parameter: endpointId", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid query parameter: endpointId", Err: err}
} }
if endpointID != int(stack.EndpointID) { if endpointID != int(stack.EndpointID) {
stack.EndpointID = portainer.EndpointID(endpointID) stack.EndpointID = portainer.EndpointID(endpointID)
@ -90,32 +91,36 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID) endpoint, err := handler.DataStore.Endpoint().Endpoint(stack.EndpointID)
if err == bolterrors.ErrObjectNotFound { if err == bolterrors.ErrObjectNotFound {
return &httperror.HandlerError{http.StatusNotFound, "Unable to find the endpoint associated to the stack inside the database", err} 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 { } else if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find the endpoint associated to the stack inside the database", err} 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) err = handler.requestBouncer.AuthorizedEndpointOperation(r, endpoint)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Permission denied to access endpoint", err} 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{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err}
} }
securityContext, err := security.RetrieveRestrictedRequestContext(r) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
} }
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) //only check resource control when it is a DockerSwarmStack or a DockerComposeStack
if err != nil { if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to verify user authorizations to validate stack access", err}
} resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if !access { if err != nil {
return &httperror.HandlerError{http.StatusForbidden, "Access denied to resource", httperrors.ErrResourceAccessDenied} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", 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}
}
} }
updateError := handler.updateAndDeployStack(r, stack, endpoint) updateError := handler.updateAndDeployStack(r, stack, endpoint)
@ -123,9 +128,17 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
return updateError return updateError
} }
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Cannot find context user", Err: errors.Wrap(err, "failed to fetch the user")}
}
stack.UpdatedBy = user.Username
stack.UpdateDate = time.Now().Unix()
stack.Status = portainer.StackStatusActive
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist the stack changes inside the database", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: err}
} }
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
@ -139,15 +152,20 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt
func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { func (handler *Handler) updateAndDeployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
if stack.Type == portainer.DockerSwarmStack { if stack.Type == portainer.DockerSwarmStack {
return handler.updateSwarmStack(r, stack, endpoint) return handler.updateSwarmStack(r, stack, endpoint)
} else if stack.Type == portainer.DockerComposeStack {
return handler.updateComposeStack(r, stack, endpoint)
} else if stack.Type == portainer.KubernetesStack {
return handler.updateKubernetesStack(r, stack, endpoint)
} else {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unsupported stack", Err: errors.Errorf("unsupported stack type: %v", stack.Type)}
} }
return handler.updateComposeStack(r, stack, endpoint)
} }
func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
var payload updateComposeStackPayload var payload updateComposeStackPayload
err := request.DecodeAndValidateJSONPayload(r, &payload) err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
} }
stack.Env = payload.Env stack.Env = payload.Env
@ -155,7 +173,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
stackFolder := strconv.Itoa(int(stack.ID)) stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist updated Compose file on disk", Err: err}
} }
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint) config, configErr := handler.createComposeDeployConfig(r, stack, endpoint)
@ -163,13 +181,9 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta
return configErr return configErr
} }
stack.UpdateDate = time.Now().Unix()
stack.UpdatedBy = config.user.Username
stack.Status = portainer.StackStatusActive
err = handler.deployComposeStack(config) err = handler.deployComposeStack(config)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
} }
return nil return nil
@ -179,7 +193,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
var payload updateSwarmStackPayload var payload updateSwarmStackPayload
err := request.DecodeAndValidateJSONPayload(r, &payload) err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err} return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
} }
stack.Env = payload.Env stack.Env = payload.Env
@ -187,7 +201,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
stackFolder := strconv.Itoa(int(stack.ID)) stackFolder := strconv.Itoa(int(stack.ID))
_, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist updated Compose file on disk", Err: err}
} }
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, payload.Prune) config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, payload.Prune)
@ -195,13 +209,9 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack
return configErr return configErr
} }
stack.UpdateDate = time.Now().Unix()
stack.UpdatedBy = config.user.Username
stack.Status = portainer.StackStatusActive
err = handler.deploySwarmStack(config) err = handler.deploySwarmStack(config)
if err != nil { if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
} }
return nil return nil

View File

@ -37,8 +37,8 @@ func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
} }
// @id StackUpdateGit // @id StackUpdateGit
// @summary Redeploy a stack // @summary Update a stack's Git configs
// @description Pull and redeploy a stack via Git // @description Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate
// @description **Access policy**: restricted // @description **Access policy**: restricted
// @tags stacks // @tags stacks
// @security jwt // @security jwt
@ -46,7 +46,7 @@ func (payload *stackGitUpdatePayload) Validate(r *http.Request) error {
// @produce json // @produce json
// @param id path int true "Stack identifier" // @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 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 updateStackGitPayload true "Git configs for pull and redeploy a stack" // @param body body stackGitUpdatePayload true "Git configs for pull and redeploy a stack"
// @success 200 {object} portainer.Stack "Success" // @success 200 {object} portainer.Stack "Success"
// @failure 400 "Invalid request" // @failure 400 "Invalid request"
// @failure 403 "Permission denied" // @failure 403 "Permission denied"
@ -98,22 +98,24 @@ func (handler *Handler) stackUpdateGit(w http.ResponseWriter, r *http.Request) *
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err} 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 stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
if err != nil { resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", Err: err} 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) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
} }
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
} }
if !access { if !access {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied} return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied}
}
} }
//stop the autoupdate job if there is any //stop the autoupdate job if there is any

View File

@ -2,11 +2,14 @@ package stacks
import ( import (
"fmt" "fmt"
"io/ioutil"
"log" "log"
"net/http" "net/http"
"path/filepath"
"time" "time"
"github.com/asaskevich/govalidator" "github.com/asaskevich/govalidator"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error" httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request" "github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response" "github.com/portainer/libhttp/response"
@ -16,6 +19,7 @@ import (
httperrors "github.com/portainer/portainer/api/http/errors" httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/stackutils" "github.com/portainer/portainer/api/internal/stackutils"
k "github.com/portainer/portainer/api/kubernetes"
) )
type stackGitRedployPayload struct { type stackGitRedployPayload struct {
@ -30,11 +34,26 @@ func (payload *stackGitRedployPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.RepositoryReferenceName) { if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName payload.RepositoryReferenceName = defaultGitReferenceName
} }
return nil return nil
} }
// PUT request on /api/stacks/:id/git?endpointId=<endpointId> // @id StackGitRedeploy
// @summary Redeploy a stack
// @description Pull and redeploy a stack via Git
// @description **Access policy**: restricted
// @tags stacks
// @security jwt
// @accept json
// @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 stackGitRedployPayload true "Git configs for pull and redeploy a stack"
// @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/redeploy [put]
func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
if err != nil { if err != nil {
@ -75,22 +94,26 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err} 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) securityContext, err := security.RetrieveRestrictedRequestContext(r)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve info from request context", Err: err}
} }
access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) //only check resource control when it is a DockerSwarmStack or a DockerComposeStack
if err != nil { if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to verify user authorizations to validate stack access", Err: err}
} resourceControl, err := handler.DataStore.ResourceControl().ResourceControlByResourceIDAndType(stackutils.ResourceControlID(stack.EndpointID, stack.Name), portainer.StackResourceControl)
if !access { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied to resource", Err: httperrors.ErrResourceAccessDenied} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve a resource control associated to the stack", 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}
}
} }
var payload stackGitRedployPayload var payload stackGitRedployPayload
@ -140,9 +163,23 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
return httpErr return httpErr
} }
newHash, err := handler.GitService.LatestCommitID(stack.GitConfig.URL, stack.GitConfig.ReferenceName, repositoryUsername, repositoryPassword)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable get latest commit id", Err: errors.WithMessagef(err, "failed to fetch latest commit id of the stack %v", stack.ID)}
}
stack.GitConfig.ConfigHash = newHash
user, err := handler.DataStore.User().User(securityContext.UserID)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Cannot find context user", Err: errors.Wrap(err, "failed to fetch the user")}
}
stack.UpdatedBy = user.Username
stack.UpdateDate = time.Now().Unix()
stack.Status = portainer.StackStatusActive
err = handler.DataStore.Stack().UpdateStack(stack.ID, stack) err = handler.DataStore.Stack().UpdateStack(stack.ID, stack)
if err != nil { if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack changes inside the database", Err: errors.Wrap(err, "failed to update the stack")}
} }
if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" { if stack.GitConfig != nil && stack.GitConfig.Authentication != nil && stack.GitConfig.Authentication.Password != "" {
@ -154,37 +191,48 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request)
} }
func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError { func (handler *Handler) deployStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
if stack.Type == portainer.DockerSwarmStack { switch stack.Type {
case portainer.DockerSwarmStack:
config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
if httpErr != nil { if httpErr != nil {
return httpErr return httpErr
} }
err := handler.deploySwarmStack(config) if err := handler.deploySwarmStack(config); err != nil {
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err} return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
} }
stack.UpdateDate = time.Now().Unix() case portainer.DockerComposeStack:
stack.UpdatedBy = config.user.Username config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint)
stack.Status = portainer.StackStatusActive if httpErr != nil {
return httpErr
}
return nil if err := handler.deployComposeStack(config); err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
}
case portainer.KubernetesStack:
if stack.Namespace == "" {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Invalid namespace", Err: errors.New("Namespace must not be empty when redeploying kubernetes stacks")}
}
content, err := ioutil.ReadFile(filepath.Join(stack.ProjectPath, stack.GitConfig.ConfigFilePath))
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to read deployment.yml manifest file", Err: errors.Wrap(err, "failed to read manifest file")}
}
_, err = handler.deployKubernetesStack(r, endpoint, string(content), stack.IsComposeFormat, stack.Namespace, k.KubeAppLabels{
StackID: int(stack.ID),
Name: stack.Name,
Owner: stack.CreatedBy,
Kind: "git",
})
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to redeploy Kubernetes stack", Err: errors.WithMessage(err, "failed to deploy kube application")}
}
default:
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unsupported stack", Err: errors.Errorf("unsupported stack type: %v", stack.Type)}
} }
config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint)
if httpErr != nil {
return httpErr
}
err := handler.deployComposeStack(config)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err}
}
stack.UpdateDate = time.Now().Unix()
stack.UpdatedBy = config.user.Username
stack.Status = portainer.StackStatusActive
return nil return nil
} }

View File

@ -0,0 +1,97 @@
package stacks
import (
"fmt"
"net/http"
"strconv"
"github.com/asaskevich/govalidator"
"github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/request"
portainer "github.com/portainer/portainer/api"
gittypes "github.com/portainer/portainer/api/git/types"
k "github.com/portainer/portainer/api/kubernetes"
)
type kubernetesFileStackUpdatePayload struct {
StackFileContent string
}
type kubernetesGitStackUpdatePayload struct {
RepositoryReferenceName string
RepositoryAuthentication bool
RepositoryUsername string
RepositoryPassword string
}
func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.StackFileContent) {
return errors.New("Invalid stack file content")
}
return nil
}
func (payload *kubernetesGitStackUpdatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.RepositoryReferenceName) {
payload.RepositoryReferenceName = defaultGitReferenceName
}
return nil
}
func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.Stack, endpoint *portainer.Endpoint) *httperror.HandlerError {
if stack.GitConfig != nil {
var payload kubernetesGitStackUpdatePayload
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
stack.GitConfig.ReferenceName = payload.RepositoryReferenceName
if payload.RepositoryAuthentication {
password := payload.RepositoryPassword
if password == "" && stack.GitConfig != nil && stack.GitConfig.Authentication != nil {
password = stack.GitConfig.Authentication.Password
}
stack.GitConfig.Authentication = &gittypes.GitAuthentication{
Username: payload.RepositoryUsername,
Password: password,
}
} else {
stack.GitConfig.Authentication = nil
}
return nil
}
var payload kubernetesFileStackUpdatePayload
err := request.DecodeAndValidateJSONPayload(r, &payload)
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
_, err = handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, stack.IsComposeFormat, stack.Namespace, k.KubeAppLabels{
StackID: int(stack.ID),
Name: stack.Name,
Owner: stack.CreatedBy,
Kind: "content",
})
if err != nil {
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack via file content", Err: err}
}
stackFolder := strconv.Itoa(int(stack.ID))
_, 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.HandlerError{StatusCode: http.StatusInternalServerError, Message: errMsg, Err: err}
}
return nil
}

View File

@ -759,6 +759,10 @@ type (
AutoUpdate *StackAutoUpdate `json:"AutoUpdate"` AutoUpdate *StackAutoUpdate `json:"AutoUpdate"`
// The git config of this stack // The git config of this stack
GitConfig *gittypes.RepoConfig GitConfig *gittypes.RepoConfig
// Kubernetes namespace if stack is a kube application
Namespace string `example:"default"`
// IsComposeFormat indicates if the Kubernetes stack is created from a Docker Compose file
IsComposeFormat bool `example:"false"`
} }
//StackAutoUpdate represents the git auto sync config for stack deployment //StackAutoUpdate represents the git auto sync config for stack deployment

View File

@ -38,7 +38,7 @@ angular.module('portainer.docker').controller('KubernetesResourcePoolsDatatableC
return !ctrl.isSystemNamespace(item) || (ctrl.settings.showSystem && ctrl.isAdmin); return !ctrl.isSystemNamespace(item) || (ctrl.settings.showSystem && ctrl.isAdmin);
}; };
this.namespaceStatusColor = function(status) { this.namespaceStatusColor = function (status) {
switch (status.toLowerCase()) { switch (status.toLowerCase()) {
case 'active': case 'active':
return 'success'; return 'success';

View File

@ -15,6 +15,7 @@ import {
KubernetesPortainerApplicationOwnerLabel, KubernetesPortainerApplicationOwnerLabel,
KubernetesPortainerApplicationStackNameLabel, KubernetesPortainerApplicationStackNameLabel,
KubernetesPortainerApplicationStackIdLabel, KubernetesPortainerApplicationStackIdLabel,
KubernetesPortainerApplicationKindLabel,
} from 'Kubernetes/models/application/models'; } from 'Kubernetes/models/application/models';
import { KubernetesServiceTypes } from 'Kubernetes/models/service/models'; import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper'; import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
@ -60,6 +61,7 @@ class KubernetesApplicationConverter {
const { labels } = data.metadata; const { labels } = data.metadata;
res.StackId = labels[KubernetesPortainerApplicationStackIdLabel] ? parseInt(labels[KubernetesPortainerApplicationStackIdLabel], 10) : null; res.StackId = labels[KubernetesPortainerApplicationStackIdLabel] ? parseInt(labels[KubernetesPortainerApplicationStackIdLabel], 10) : null;
res.StackName = labels[KubernetesPortainerApplicationStackNameLabel] || ''; res.StackName = labels[KubernetesPortainerApplicationStackNameLabel] || '';
res.ApplicationKind = labels[KubernetesPortainerApplicationKindLabel] || '';
res.ApplicationOwner = labels[KubernetesPortainerApplicationOwnerLabel] || ''; res.ApplicationOwner = labels[KubernetesPortainerApplicationOwnerLabel] || '';
res.ApplicationName = labels[KubernetesPortainerApplicationNameLabel] || res.Name; res.ApplicationName = labels[KubernetesPortainerApplicationNameLabel] || res.Name;
} }

View File

@ -40,8 +40,11 @@ export const KubernetesApplicationQuotaDefaults = {
}; };
export const KubernetesPortainerApplicationStackNameLabel = 'io.portainer.kubernetes.application.stack'; export const KubernetesPortainerApplicationStackNameLabel = 'io.portainer.kubernetes.application.stack';
export const KubernetesPortainerApplicationStackIdLabel = 'io.portainer.kubernetes.application.stackid'; export const KubernetesPortainerApplicationStackIdLabel = 'io.portainer.kubernetes.application.stackid';
export const KubernetesPortainerApplicationKindLabel = 'io.portainer.kubernetes.application.kind';
export const KubernetesPortainerApplicationNameLabel = 'io.portainer.kubernetes.application.name'; export const KubernetesPortainerApplicationNameLabel = 'io.portainer.kubernetes.application.name';
export const KubernetesPortainerApplicationOwnerLabel = 'io.portainer.kubernetes.application.owner'; export const KubernetesPortainerApplicationOwnerLabel = 'io.portainer.kubernetes.application.owner';

View File

@ -7,6 +7,8 @@ const _KubernetesApplication = Object.freeze({
Id: '', Id: '',
Name: '', Name: '',
StackName: '', StackName: '',
StackId: '',
ApplicationKind: '',
ApplicationOwner: '', ApplicationOwner: '',
ApplicationName: '', ApplicationName: '',
ResourcePool: '', ResourcePool: '',
@ -91,3 +93,9 @@ export class KubernetesApplicationPort {
Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationPort))); Object.assign(this, JSON.parse(JSON.stringify(_KubernetesApplicationPort)));
} }
} }
export const KubernetesDeploymentTypes = Object.freeze({
GIT: 'git',
CONTENT: 'content',
APPLICATION_FORM: 'application form',
});

View File

@ -7,11 +7,11 @@ export const KubernetesDeployBuildMethods = Object.freeze({
GIT: 1, GIT: 1,
WEB_EDITOR: 2, WEB_EDITOR: 2,
CUSTOM_TEMPLATE: 3, CUSTOM_TEMPLATE: 3,
URL: 4 URL: 4,
}); });
export const KubernetesDeployRequestMethods = Object.freeze({ export const KubernetesDeployRequestMethods = Object.freeze({
REPOSITORY: 'repository', REPOSITORY: 'repository',
STRING: 'string', STRING: 'string',
URL: 'url' URL: 'url',
}); });

File diff suppressed because it is too large Load Diff

View File

@ -10,6 +10,7 @@ import {
KubernetesApplicationQuotaDefaults, KubernetesApplicationQuotaDefaults,
KubernetesApplicationTypes, KubernetesApplicationTypes,
KubernetesApplicationPlacementTypes, KubernetesApplicationPlacementTypes,
KubernetesDeploymentTypes,
} from 'Kubernetes/models/application/models'; } from 'Kubernetes/models/application/models';
import { import {
KubernetesApplicationConfigurationFormValue, KubernetesApplicationConfigurationFormValue,
@ -50,6 +51,7 @@ class KubernetesCreateApplicationController {
KubernetesPersistentVolumeClaimService, KubernetesPersistentVolumeClaimService,
KubernetesVolumeService, KubernetesVolumeService,
RegistryService, RegistryService,
StackService,
KubernetesNodesLimitsService KubernetesNodesLimitsService
) { ) {
this.$async = $async; this.$async = $async;
@ -66,6 +68,7 @@ class KubernetesCreateApplicationController {
this.KubernetesIngressService = KubernetesIngressService; this.KubernetesIngressService = KubernetesIngressService;
this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService; this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService;
this.RegistryService = RegistryService; this.RegistryService = RegistryService;
this.StackService = StackService;
this.KubernetesNodesLimitsService = KubernetesNodesLimitsService; this.KubernetesNodesLimitsService = KubernetesNodesLimitsService;
this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
@ -75,8 +78,11 @@ class KubernetesCreateApplicationController {
this.ApplicationTypes = KubernetesApplicationTypes; this.ApplicationTypes = KubernetesApplicationTypes;
this.ApplicationConfigurationFormValueOverridenKeyTypes = KubernetesApplicationConfigurationFormValueOverridenKeyTypes; this.ApplicationConfigurationFormValueOverridenKeyTypes = KubernetesApplicationConfigurationFormValueOverridenKeyTypes;
this.ServiceTypes = KubernetesServiceTypes; this.ServiceTypes = KubernetesServiceTypes;
this.KubernetesDeploymentTypes = KubernetesDeploymentTypes;
this.state = { this.state = {
appType: this.KubernetesDeploymentTypes.APPLICATION_FORM,
updateWebEditorInProgress: false,
actionInProgress: false, actionInProgress: false,
useLoadBalancer: false, useLoadBalancer: false,
useServerMetrics: false, useServerMetrics: false,
@ -133,9 +139,51 @@ class KubernetesCreateApplicationController {
this.updateApplicationAsync = this.updateApplicationAsync.bind(this); this.updateApplicationAsync = this.updateApplicationAsync.bind(this);
this.deployApplicationAsync = this.deployApplicationAsync.bind(this); this.deployApplicationAsync = this.deployApplicationAsync.bind(this);
this.setPullImageValidity = this.setPullImageValidity.bind(this); this.setPullImageValidity = this.setPullImageValidity.bind(this);
this.onChangeFileContent = this.onChangeFileContent.bind(this);
} }
/* #endregion */ /* #endregion */
onChangeFileContent(value) {
if (this.stackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== value.replace(/(\r\n|\n|\r)/gm, '')) {
this.state.isEditorDirty = true;
this.stackFileContent = value;
}
}
async updateApplicationViaWebEditor() {
return this.$async(async () => {
try {
const confirmed = await this.ModalService.confirmAsync({
title: 'Are you sure?',
message: 'Any changes to this application will be overriden and may cause a service interruption. Do you wish to continue?',
buttons: {
confirm: {
label: 'Update',
className: 'btn-warning',
},
},
});
if (!confirmed) {
return;
}
this.state.updateWebEditorInProgress = true;
await this.StackService.updateKubeStack({ EndpointId: this.endpoint.Id, Id: this.application.StackId }, this.stackFileContent, null);
this.state.isEditorDirty = false;
await this.$state.reload();
} catch (err) {
this.Notifications.error('Failure', err, 'Failed redeploying application');
} finally {
this.state.updateWebEditorInProgress = false;
}
});
}
async uiCanExit() {
if (this.stackFileContent && this.state.isEditorDirty) {
return this.ModalService.confirmWebEditorDiscard();
}
}
setPullImageValidity(validity) { setPullImageValidity(validity) {
this.state.pullImageValidity = validity; this.state.pullImageValidity = validity;
} }
@ -1029,6 +1077,17 @@ class KubernetesCreateApplicationController {
this.nodesLabels, this.nodesLabels,
this.ingresses this.ingresses
); );
if (this.application.ApplicationKind) {
this.state.appType = this.KubernetesDeploymentTypes[this.application.ApplicationKind.toUpperCase()];
if (this.application.StackId) {
this.stack = await this.StackService.stack(this.application.StackId);
if (this.application.ApplicationKind === this.KubernetesDeploymentTypes.CONTENT) {
this.stackFileContent = await this.StackService.getStackFile(this.application.StackId);
}
}
}
this.formValues.OriginalIngresses = this.ingresses; this.formValues.OriginalIngresses = this.ingresses;
this.formValues.ImageModel = await this.parseImageConfiguration(this.formValues.ImageModel); this.formValues.ImageModel = await this.parseImageConfiguration(this.formValues.ImageModel);
this.savedFormValues = angular.copy(this.formValues); this.savedFormValues = angular.copy(this.formValues);

View File

@ -67,7 +67,10 @@
<td>Creation</td> <td>Creation</td>
<td> <td>
<span ng-if="ctrl.application.ApplicationOwner" style="margin-right: 5px;"> <i class="fas fa-user"></i> {{ ctrl.application.ApplicationOwner }} </span> <span ng-if="ctrl.application.ApplicationOwner" style="margin-right: 5px;"> <i class="fas fa-user"></i> {{ ctrl.application.ApplicationOwner }} </span>
<span> <i class="fas fa-clock"></i> {{ ctrl.application.CreationDate | getisodate }} </span> <span> <i class="fas fa-clock"></i> {{ ctrl.application.CreationDate | getisodate }}</span>
<span ng-if="ctrl.application.ApplicationOwner">
<i class="fa fa-file-code space-left space-right" aria-hidden="true"></i> Deployed from {{ ctrl.state.appType }}</span
>
</td> </td>
</tr> </tr>
<tr> <tr>
@ -210,7 +213,7 @@
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
style="margin-left: 0;" style="margin-left: 0;"
ng-click="ctrl.rollbackApplication()" ng-click="ctrl.rollbackApplication()"
ng-disabled="ctrl.application.Revisions.length < 2" ng-disabled="ctrl.application.Revisions.length < 2 || ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
> >
<i class="fas fa-history space-right" aria-hidden="true"></i>Rollback to previous configuration <i class="fas fa-history space-right" aria-hidden="true"></i>Rollback to previous configuration
</button> </button>

View File

@ -1,7 +1,12 @@
import angular from 'angular'; import angular from 'angular';
import _ from 'lodash-es'; import _ from 'lodash-es';
import * as JsonPatch from 'fast-json-patch'; import * as JsonPatch from 'fast-json-patch';
import { KubernetesApplicationDataAccessPolicies, KubernetesApplicationDeploymentTypes, KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; import {
KubernetesApplicationDataAccessPolicies,
KubernetesApplicationDeploymentTypes,
KubernetesApplicationTypes,
KubernetesDeploymentTypes,
} from 'Kubernetes/models/application/models';
import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper'; import KubernetesEventHelper from 'Kubernetes/helpers/eventHelper';
import KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import KubernetesApplicationHelper from 'Kubernetes/helpers/application';
import { KubernetesServiceTypes } from 'Kubernetes/models/service/models'; import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
@ -127,6 +132,7 @@ class KubernetesApplicationController {
this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes;
this.KubernetesApplicationTypes = KubernetesApplicationTypes; this.KubernetesApplicationTypes = KubernetesApplicationTypes;
this.EndpointProvider = EndpointProvider; this.EndpointProvider = EndpointProvider;
this.KubernetesDeploymentTypes = KubernetesDeploymentTypes;
this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies; this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies;
this.KubernetesServiceTypes = KubernetesServiceTypes; this.KubernetesServiceTypes = KubernetesServiceTypes;
@ -137,6 +143,7 @@ class KubernetesApplicationController {
this.getApplicationAsync = this.getApplicationAsync.bind(this); this.getApplicationAsync = this.getApplicationAsync.bind(this);
this.getEvents = this.getEvents.bind(this); this.getEvents = this.getEvents.bind(this);
this.getEventsAsync = this.getEventsAsync.bind(this); this.getEventsAsync = this.getEventsAsync.bind(this);
this.updateApplicationKindText = this.updateApplicationKindText.bind(this);
this.updateApplicationAsync = this.updateApplicationAsync.bind(this); this.updateApplicationAsync = this.updateApplicationAsync.bind(this);
this.redeployApplicationAsync = this.redeployApplicationAsync.bind(this); this.redeployApplicationAsync = this.redeployApplicationAsync.bind(this);
this.rollbackApplicationAsync = this.rollbackApplicationAsync.bind(this); this.rollbackApplicationAsync = this.rollbackApplicationAsync.bind(this);
@ -262,6 +269,14 @@ class KubernetesApplicationController {
return this.$async(this.updateApplicationAsync); return this.$async(this.updateApplicationAsync);
} }
updateApplicationKindText() {
if (this.application.ApplicationKind === this.KubernetesDeploymentTypes.GIT) {
this.state.appType = `git repository`;
} else if (this.application.ApplicationKind === this.KubernetesDeploymentTypes.CONTENT) {
this.state.appType = `web editor`;
}
}
/** /**
* EVENTS * EVENTS
*/ */
@ -343,6 +358,7 @@ class KubernetesApplicationController {
namespace: this.$transition$.params().namespace, namespace: this.$transition$.params().namespace,
name: this.$transition$.params().name, name: this.$transition$.params().name,
}, },
appType: this.KubernetesDeploymentTypes.APPLICATION_FORM,
eventWarningCount: 0, eventWarningCount: 0,
placementWarning: false, placementWarning: false,
expandedNote: false, expandedNote: false,
@ -359,6 +375,7 @@ class KubernetesApplicationController {
await this.getApplication(); await this.getApplication();
await this.getEvents(); await this.getEvents();
this.updateApplicationKindText();
this.state.viewReady = true; this.state.viewReady = true;
} }

View File

@ -9,7 +9,6 @@ export const webEditorForm = {
placeholder: '@', placeholder: '@',
yml: '<', yml: '<',
value: '<', value: '<',
onChange: '<', onChange: '<',
}, },

View File

@ -6,6 +6,7 @@ export const gitFormAuthFieldset = {
bindings: { bindings: {
model: '<', model: '<',
onChange: '<', onChange: '<',
showAuthExplanation: '<',
isEdit: '<', isEdit: '<',
}, },
}; };

View File

@ -0,0 +1,15 @@
<div class="form-group" ng-class="$ctrl.className">
<div class="col-sm-12">
<p>
This stack was deployed from the git repository <code>{{ $ctrl.url }}</code>
.
</p>
<p>
Update
<code
>{{ $ctrl.configFilePath }}<span ng-if="$ctrl.additionalFiles.length > 0">,{{ $ctrl.additionalFiles.join(',') }}</span></code
>
in git and pull from here to update the stack.
</p>
</div>
</div>

View File

@ -0,0 +1,9 @@
export const gitFormInfoPanel = {
templateUrl: './git-form-info-panel.html',
bindings: {
url: '<',
configFilePath: '<',
additionalFiles: '<',
className: '@',
},
};

View File

@ -8,6 +8,7 @@ import { gitFormAutoUpdateFieldset } from './git-form-auto-update-fieldset';
import { gitFormComposePathField } from './git-form-compose-path-field'; import { gitFormComposePathField } from './git-form-compose-path-field';
import { gitFormRefField } from './git-form-ref-field'; import { gitFormRefField } from './git-form-ref-field';
import { gitFormUrlField } from './git-form-url-field'; import { gitFormUrlField } from './git-form-url-field';
import { gitFormInfoPanel } from './git-form-info-panel';
export default angular export default angular
.module('portainer.app.components.forms.git', []) .module('portainer.app.components.forms.git', [])
@ -15,6 +16,7 @@ export default angular
.component('gitFormRefField', gitFormRefField) .component('gitFormRefField', gitFormRefField)
.component('gitForm', gitForm) .component('gitForm', gitForm)
.component('gitFormUrlField', gitFormUrlField) .component('gitFormUrlField', gitFormUrlField)
.component('gitFormInfoPanel', gitFormInfoPanel)
.component('gitFormAdditionalFilesPanel', gitFormAdditionalFilesPanel) .component('gitFormAdditionalFilesPanel', gitFormAdditionalFilesPanel)
.component('gitFormAdditionalFileItem', gitFormAdditionalFileItem) .component('gitFormAdditionalFileItem', gitFormAdditionalFileItem)
.component('gitFormAutoUpdateFieldset', gitFormAutoUpdateFieldset) .component('gitFormAutoUpdateFieldset', gitFormAutoUpdateFieldset)

View File

@ -0,0 +1,96 @@
class KubernetesAppGitFormController {
/* @ngInject */
constructor($async, $state, StackService, ModalService, Notifications) {
this.$async = $async;
this.$state = $state;
this.StackService = StackService;
this.ModalService = ModalService;
this.Notifications = Notifications;
this.state = {
saveGitSettingsInProgress: false,
redeployInProgress: false,
showConfig: true,
isEdit: false,
};
this.formValues = {
RefName: '',
RepositoryAuthentication: false,
RepositoryUsername: '',
RepositoryPassword: '',
};
this.onChange = this.onChange.bind(this);
this.onChangeRef = this.onChangeRef.bind(this);
}
onChangeRef(value) {
this.onChange({ RefName: value });
}
onChange(values) {
this.formValues = {
...this.formValues,
...values,
};
}
async pullAndRedeployApplication() {
return this.$async(async () => {
try {
const confirmed = await this.ModalService.confirmAsync({
title: 'Are you sure?',
message: 'Any changes to this application will be overriden by the definition in git and may cause a service interruption. Do you wish to continue?',
buttons: {
confirm: {
label: 'Update',
className: 'btn-warning',
},
},
});
if (!confirmed) {
return;
}
this.state.redeployInProgress = true;
await this.StackService.updateKubeGit(this.stack.Id, this.stack.EndpointId, this.namespace, this.formValues);
this.Notifications.success('Pulled and redeployed stack successfully');
await this.$state.reload();
} catch (err) {
this.Notifications.error('Failure', err, 'Failed redeploying application');
} finally {
this.state.redeployInProgress = false;
}
});
}
async saveGitSettings() {
return this.$async(async () => {
try {
this.state.saveGitSettingsInProgress = true;
await this.StackService.updateKubeStack({ EndpointId: this.stack.EndpointId, Id: this.stack.Id }, null, this.formValues);
this.Notifications.success('Save stack settings successfully');
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to save application settings');
} finally {
this.state.saveGitSettingsInProgress = false;
}
});
}
isSubmitButtonDisabled() {
return this.state.saveGitSettingsInProgress || this.state.redeployInProgress;
}
$onInit() {
console.log(this);
this.formValues.RefName = this.stack.GitConfig.ReferenceName;
if (this.stack.GitConfig && this.stack.GitConfig.Authentication) {
this.formValues.RepositoryUsername = this.stack.GitConfig.Authentication.Username;
this.formValues.RepositoryAuthentication = true;
this.state.isEdit = true;
}
}
}
export default KubernetesAppGitFormController;

View File

@ -0,0 +1,59 @@
<form name="$ctrl.redeployGitForm">
<div class="col-sm-12 form-section-title">
Redeploy from git repository
</div>
<div class="form-group text-muted">
<div class="col-sm-12">
<p>
Pull the latest manifest from git and redeploy the application.
</p>
</div>
</div>
<div class="form-group">
<div class="col-sm-12">
<p>
<a class="small interactive" ng-click="$ctrl.state.showConfig = !$ctrl.state.showConfig">
<i ng-class="['fa space-right', { 'fa-minus': $ctrl.state.showConfig, 'fa-plus': !$ctrl.state.showConfig }]" aria-hidden="true"></i>
{{ $ctrl.state.showConfig ? 'Hide' : 'Advanced' }} configuration
</a>
</p>
</div>
</div>
<git-form-ref-field ng-if="$ctrl.state.showConfig" value="$ctrl.formValues.RefName" on-change="($ctrl.onChangeRef)"></git-form-ref-field>
<git-form-auth-fieldset
ng-if="$ctrl.state.showConfig"
model="$ctrl.formValues"
is-edit="$ctrl.state.isEdit"
on-change="($ctrl.onChange)"
show-auth-explanation="true"
></git-form-auth-fieldset>
<div class="col-sm-12 form-section-title">
Actions
</div>
<!-- #Git buttons -->
<button
class="btn btn-sm btn-primary"
ng-click="$ctrl.pullAndRedeployApplication()"
ng-disabled="$ctrl.isSubmitButtonDisabled() || !$ctrl.redeployGitForm.$valid"
style="margin-top: 7px; margin-left: 0;"
button-spinner="ctrl.state.redeployInProgress"
analytics-on
analytics-category="kubernetes"
analytics-event="kubernetes-application-edit-git-pull"
>
<span ng-show="!$ctrl.state.redeployInProgress"> <i class="fa fa-sync space-right" aria-hidden="true"></i> Pull and update application </span>
<span ng-show="$ctrl.state.redeployInProgress">In progress...</span>
</button>
<button
class="btn btn-sm btn-primary"
ng-click="$ctrl.saveGitSettings()"
ng-disabled="$ctrl.isSubmitButtonDisabled() || !$ctrl.redeployGitForm.$valid"
style="margin-top: 7px; margin-left: 0;"
button-spinner="$ctrl.state.saveGitSettingsInProgress"
>
<span ng-show="!$ctrl.state.saveGitSettingsInProgress"> Save settings </span>
<span ng-show="$ctrl.state.saveGitSettingsInProgress">In progress...</span>
</button>
</form>

View File

@ -0,0 +1,13 @@
import angular from 'angular';
import controller from './kubernetes-app-git-form.controller';
const kubernetesAppGitForm = {
templateUrl: './kubernetes-app-git-form.html',
controller,
bindings: {
namespace: '<',
stack: '<',
},
};
angular.module('portainer.app').component('kubernetesAppGitForm', kubernetesAppGitForm);

View File

@ -38,7 +38,6 @@ class StackRedeployGitFormController {
this.onChangeRef = this.onChangeRef.bind(this); this.onChangeRef = this.onChangeRef.bind(this);
this.onChangeAutoUpdate = this.onChangeAutoUpdate.bind(this); this.onChangeAutoUpdate = this.onChangeAutoUpdate.bind(this);
this.onChangeEnvVar = this.onChangeEnvVar.bind(this); this.onChangeEnvVar = this.onChangeEnvVar.bind(this);
this.handleEnvVarChange = this.handleEnvVarChange.bind(this);
} }
buildAnalyticsProperties() { buildAnalyticsProperties() {
@ -143,10 +142,6 @@ class StackRedeployGitFormController {
return this.state.inProgress || this.state.redeployInProgress; return this.state.inProgress || this.state.redeployInProgress;
} }
handleEnvVarChange(value) {
this.formValues.Env = value;
}
isAutoUpdateChanged() { isAutoUpdateChanged() {
const wasEnabled = !!(this.stack.AutoUpdate && (this.stack.AutoUpdate.Interval || this.stack.AutoUpdate.Webhook)); const wasEnabled = !!(this.stack.AutoUpdate && (this.stack.AutoUpdate.Interval || this.stack.AutoUpdate.Webhook));
const isEnabled = this.formValues.AutoUpdate.RepositoryAutomaticUpdates; const isEnabled = this.formValues.AutoUpdate.RepositoryAutomaticUpdates;

View File

@ -2,21 +2,12 @@
<div class="col-sm-12 form-section-title"> <div class="col-sm-12 form-section-title">
Redeploy from git repository Redeploy from git repository
</div> </div>
<div class="text-muted small form-group"> <git-form-info-panel
<div class="col-sm-12"> class-name="text-muted small"
<p> url="$ctrl.model.URL"
This stack was deployed from the git repository <code>{{ $ctrl.model.URL }}</code> config-file-path="$ctrl.model.ConfigFilePath"
. additional-files="$ctrl.stack.AdditionalFiles"
</p> ></git-form-info-panel>
<p>
Update
<code
>{{ $ctrl.model.ConfigFilePath }}<span ng-if="$ctrl.stack.AdditionalFiles.length > 0">,{{ $ctrl.stack.AdditionalFiles.join(',') }}</span></code
>
in git and pull from here to update the stack.
</p>
</div>
</div>
<git-form-auto-update-fieldset model="$ctrl.formValues.AutoUpdate" on-change="($ctrl.onChangeAutoUpdate)"></git-form-auto-update-fieldset> <git-form-auto-update-fieldset model="$ctrl.formValues.AutoUpdate" on-change="($ctrl.onChangeAutoUpdate)"></git-form-auto-update-fieldset>
<div class="form-group"> <div class="form-group">

View File

@ -7,6 +7,7 @@ export function StackViewModel(data) {
this.EndpointId = data.EndpointId; this.EndpointId = data.EndpointId;
this.SwarmId = data.SwarmId; this.SwarmId = data.SwarmId;
this.Env = data.Env ? data.Env : []; this.Env = data.Env ? data.Env : [];
this.IsComposeFormat = data.IsComposeFormat;
if (data.ResourceControl && data.ResourceControl.Id !== 0) { if (data.ResourceControl && data.ResourceControl.Id !== 0) {
this.ResourceControl = new ResourceControlViewModel(data.ResourceControl); this.ResourceControl = new ResourceControlViewModel(data.ResourceControl);
} }

View File

@ -15,6 +15,7 @@ angular.module('portainer.app').factory('StackService', [
'use strict'; 'use strict';
var service = { var service = {
updateGit, updateGit,
updateKubeGit,
}; };
service.stack = function (id) { service.stack = function (id) {
@ -268,6 +269,25 @@ angular.module('portainer.app').factory('StackService', [
return Stack.update({ endpointId: stack.EndpointId }, { id: stack.Id, StackFileContent: stackFile, Env: env, Prune: prune }).$promise; return Stack.update({ endpointId: stack.EndpointId }, { id: stack.Id, StackFileContent: stackFile, Env: env, Prune: prune }).$promise;
}; };
service.updateKubeStack = function (stack, stackFile, gitConfig) {
let payload = {};
if (stackFile) {
payload = {
StackFileContent: stackFile,
};
} else {
payload = {
RepositoryReferenceName: gitConfig.RefName,
RepositoryAuthentication: gitConfig.RepositoryAuthentication,
RepositoryUsername: gitConfig.RepositoryUsername,
RepositoryPassword: gitConfig.RepositoryPassword,
};
}
return Stack.update({ id: stack.Id, endpointId: stack.EndpointId }, payload).$promise;
};
service.createComposeStackFromFileUpload = function (name, stackFile, env, endpointId) { service.createComposeStackFromFileUpload = function (name, stackFile, env, endpointId) {
return FileUploadService.createComposeStack(name, stackFile, env, endpointId); return FileUploadService.createComposeStack(name, stackFile, env, endpointId);
}; };
@ -417,6 +437,19 @@ angular.module('portainer.app').factory('StackService', [
).$promise; ).$promise;
} }
function updateKubeGit(id, endpointId, namespace, gitConfig) {
return Stack.updateGit(
{ endpointId, id },
{
Namespace: namespace,
RepositoryReferenceName: gitConfig.RefName,
RepositoryAuthentication: gitConfig.RepositoryAuthentication,
RepositoryUsername: gitConfig.RepositoryUsername,
RepositoryPassword: gitConfig.RepositoryPassword,
}
).$promise;
}
service.updateGitStackSettings = function (id, endpointId, env, gitConfig) { service.updateGitStackSettings = function (id, endpointId, env, gitConfig) {
// prepare auto update // prepare auto update
const autoUpdate = {}; const autoUpdate = {};

View File

@ -34,7 +34,7 @@ angular
StackFile: null, StackFile: null,
RepositoryURL: '', RepositoryURL: '',
RepositoryReferenceName: '', RepositoryReferenceName: '',
RepositoryAuthentication: false, RepositoryAuthentication: true,
RepositoryUsername: '', RepositoryUsername: '',
RepositoryPassword: '', RepositoryPassword: '',
Env: [], Env: [],