From e49e90f304ba259ce42084ea87f3f3ae5a435606 Mon Sep 17 00:00:00 2001 From: Dmitry Salakhov Date: Tue, 7 Sep 2021 12:37:26 +1200 Subject: [PATCH] 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 * 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 Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com> Co-authored-by: Felix Han --- .../handler/stacks/create_compose_stack.go | 13 +- .../handler/stacks/create_kubernetes_stack.go | 122 +- api/http/handler/stacks/create_swarm_stack.go | 26 +- api/http/handler/stacks/stack_create.go | 10 +- api/http/handler/stacks/stack_delete.go | 14 +- api/http/handler/stacks/stack_file.go | 22 +- api/http/handler/stacks/stack_inspect.go | 31 +- api/http/handler/stacks/stack_migrate.go | 30 +- api/http/handler/stacks/stack_start.go | 22 +- api/http/handler/stacks/stack_stop.go | 22 +- api/http/handler/stacks/stack_update.go | 80 +- api/http/handler/stacks/stack_update_git.go | 36 +- .../stacks/stack_update_git_redeploy.go | 118 +- .../handler/stacks/update_kubernetes_stack.go | 97 + api/portainer.go | 4 + .../resourcePoolsDatatable.html | 2 +- .../resourcePoolsDatatableController.js | 2 +- .../volumes-datatable/volumesDatatable.html | 2 +- app/kubernetes/converters/application.js | 2 + .../models/application/models/constants.js | 3 + .../models/application/models/index.js | 8 + app/kubernetes/models/deploy.js | 4 +- .../create/createApplication.html | 2874 +++++++++-------- .../create/createApplicationController.js | 59 + .../views/applications/edit/application.html | 7 +- .../edit/applicationController.js | 19 +- app/kubernetes/views/deploy/deploy.html | 4 +- .../form-components/web-editor-form/index.js | 1 - .../git-form/git-form-auth-fieldset/index.js | 1 + .../git-form-info-panel.html | 15 + .../git-form/git-form-info-panel/index.js | 9 + .../components/forms/git-form/index.js | 2 + .../kubernetes-app-git-form.controller.js | 96 + .../kubernetes-app-git-form.html | 59 + .../kubernetes-app-git-form.js | 13 + .../stack-redeploy-git-form.controller.js | 5 - .../stack-redeploy-git-form.html | 21 +- app/portainer/models/stack.js | 1 + app/portainer/services/api/stackService.js | 33 + .../stacks/create/createStackController.js | 2 +- 40 files changed, 2234 insertions(+), 1657 deletions(-) create mode 100644 api/http/handler/stacks/update_kubernetes_stack.go create mode 100644 app/portainer/components/forms/git-form/git-form-info-panel/git-form-info-panel.html create mode 100644 app/portainer/components/forms/git-form/git-form-info-panel/index.js create mode 100644 app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.controller.js create mode 100644 app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.html create mode 100644 app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.js diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go index bd27a2eb4..df78ca965 100644 --- a/api/http/handler/stacks/create_compose_stack.go +++ b/api/http/handler/stacks/create_compose_stack.go @@ -1,7 +1,6 @@ package stacks import ( - "context" "fmt" "net/http" "path" @@ -405,15 +404,5 @@ func (handler *Handler) deployComposeStack(config *composeStackDeploymentConfig) } } - handler.stackCreationMutex.Lock() - 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) + return handler.StackDeployer.DeployComposeStack(config.stack, config.endpoint, config.registries) } diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index f6129d81d..29ddfe280 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -1,26 +1,27 @@ package stacks import ( + "fmt" "io/ioutil" "net/http" "path/filepath" "strconv" "time" - "github.com/asaskevich/govalidator" "github.com/pkg/errors" + "github.com/asaskevich/govalidator" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" - k "github.com/portainer/portainer/api/kubernetes" + gittypes "github.com/portainer/portainer/api/git/types" "github.com/portainer/portainer/api/http/client" + k "github.com/portainer/portainer/api/kubernetes" ) -const defaultReferenceName = "refs/heads/master" - type kubernetesStringDeploymentPayload struct { ComposeFormat bool Namespace string @@ -39,9 +40,9 @@ type kubernetesGitDeploymentPayload struct { } type kubernetesManifestURLDeploymentPayload struct { - Namespace string - ComposeFormat bool - ManifestURL string + Namespace string + ComposeFormat bool + ManifestURL string } 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") } if govalidator.IsNull(payload.RepositoryReferenceName) { - payload.RepositoryReferenceName = defaultReferenceName + payload.RepositoryReferenceName = defaultGitReferenceName } return nil } @@ -84,26 +85,39 @@ type createKubernetesStackResponse struct { 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 if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { 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() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Type: portainer.KubernetesStack, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ManifestFileDefaultName, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), + ID: portainer.StackID(stackID), + Type: portainer.KubernetesStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ManifestFileDefaultName, + Namespace: payload.Namespace, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), + CreatedBy: user.Username, + IsComposeFormat: payload.ComposeFormat, } stackFolder := strconv.Itoa(int(stack.ID)) projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) 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 @@ -116,6 +130,7 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit Owner: stack.CreatedBy, Kind: "content", }) + if err != nil { 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} } + doCleanUp = false + resp := &createKubernetesStackResponse{ Output: output, } @@ -134,20 +151,40 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit 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 if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { 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() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Type: portainer.KubernetesStack, - EndpointID: endpoint.ID, - EntryPoint: payload.FilePathInRepository, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), + ID: portainer.StackID(stackID), + Type: portainer.KubernetesStack, + EndpointID: endpoint.ID, + EntryPoint: payload.FilePathInRepository, + GitConfig: &gittypes.RepoConfig{ + 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))) @@ -156,6 +193,12 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) + commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword) + if err != nil { + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to fetch git repository id", Err: err} + } + stack.GitConfig.ConfigHash = commitId + stackFileContent, err := handler.cloneManifestContentFromGitRepo(&payload, stack.ProjectPath) if err != nil { 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, Kind: "git", }) + if err != nil { 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} } + doCleanUp = false + resp := &createKubernetesStackResponse{ Output: output, } @@ -185,27 +231,33 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr return response.JSON(w, resp) } - -func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError { +func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint, userID portainer.UserID) *httperror.HandlerError { var payload kubernetesManifestURLDeploymentPayload if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil { 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() stack := &portainer.Stack{ - ID: portainer.StackID(stackID), - Type: portainer.KubernetesStack, - EndpointID: endpoint.ID, - EntryPoint: filesystem.ManifestFileDefaultName, - Status: portainer.StackStatusActive, - CreationDate: time.Now().Unix(), + ID: portainer.StackID(stackID), + Type: portainer.KubernetesStack, + EndpointID: endpoint.ID, + EntryPoint: filesystem.ManifestFileDefaultName, + Status: portainer.StackStatusActive, + CreationDate: time.Now().Unix(), + CreatedBy: user.Username, + IsComposeFormat: payload.ComposeFormat, } var manifestContent []byte - manifestContent, err := client.Get(payload.ManifestURL, 30) + manifestContent, err = client.Get(payload.ManifestURL, 30) 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)) @@ -240,7 +292,7 @@ func (handler *Handler) createKubernetesStackFromManifestURL(w http.ResponseWrit 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() 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 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) { diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go index e76338384..b2252b9af 100644 --- a/api/http/handler/stacks/create_swarm_stack.go +++ b/api/http/handler/stacks/create_swarm_stack.go @@ -1,13 +1,14 @@ package stacks import ( - "errors" "fmt" "net/http" "path" "strconv" "time" + "github.com/pkg/errors" + "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "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 { isAdminOrEndpointAdmin, err := handler.userIsAdminOrEndpointAdmin(config.user, config.endpoint.ID) if err != nil { - return err + return errors.Wrap(err, "failed to validate user admin privileges") } settings := &config.endpoint.SecuritySettings @@ -401,30 +402,15 @@ func (handler *Handler) deploySwarmStack(config *swarmStackDeploymentConfig) err path := path.Join(config.stack.ProjectPath, file) stackContent, err := handler.FileService.GetFileContent(path) if err != nil { - return err + return errors.WithMessage(err, "failed to get stack file content") } err = handler.isValidStackFile(stackContent, settings) if err != nil { - return err + return errors.WithMessage(err, "swarm stack file content validation failed") } } } - handler.stackCreationMutex.Lock() - 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 + return handler.StackDeployer.DeploySwarmStack(config.stack, config.endpoint, config.registries, config.prune) } diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go index 2ffb455e8..fe5a0526f 100644 --- a/api/http/handler/stacks/stack_create.go +++ b/api/http/handler/stacks/stack_create.go @@ -110,7 +110,7 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt case portainer.DockerComposeStack: return handler.createComposeStack(w, r, method, endpoint, tokenData.ID) 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)} @@ -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)} } -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 { case "string": - return handler.createKubernetesStackFromFileContent(w, r, endpoint) + return handler.createKubernetesStackFromFileContent(w, r, endpoint, userID) case "repository": - return handler.createKubernetesStackFromGitRepository(w, r, endpoint) + return handler.createKubernetesStackFromGitRepository(w, r, endpoint, userID) 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)} } diff --git a/api/http/handler/stacks/stack_delete.go b/api/http/handler/stacks/stack_delete.go index 98c3e57ff..424255b89 100644 --- a/api/http/handler/stacks/stack_delete.go +++ b/api/http/handler/stacks/stack_delete.go @@ -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} } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - 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 stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + 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} + } } } diff --git a/api/http/handler/stacks/stack_file.go b/api/http/handler/stacks/stack_file.go index f21083bb1..35d38d378 100644 --- a/api/http/handler/stacks/stack_file.go +++ b/api/http/handler/stacks/stack_file.go @@ -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} } - 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} - } + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + 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} + } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - 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", errors.ErrResourceAccessDenied} + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + 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", errors.ErrResourceAccessDenied} + } } } diff --git a/api/http/handler/stacks/stack_inspect.go b/api/http/handler/stacks/stack_inspect.go index d0797700e..05c1ab102 100644 --- a/api/http/handler/stacks/stack_inspect.go +++ b/api/http/handler/stacks/stack_inspect.go @@ -1,9 +1,10 @@ package stacks import ( - "github.com/portainer/portainer/api/http/errors" "net/http" + "github.com/portainer/portainer/api/http/errors" + httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "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} } - 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} - } + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + 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} + } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - 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", errors.ErrResourceAccessDenied} - } + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + 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", errors.ErrResourceAccessDenied} + } - if resourceControl != nil { - stack.ResourceControl = resourceControl + if resourceControl != nil { + stack.ResourceControl = resourceControl + } } } diff --git a/api/http/handler/stacks/stack_migrate.go b/api/http/handler/stacks/stack_migrate.go index 2323a50ab..19377f418 100644 --- a/api/http/handler/stacks/stack_migrate.go +++ b/api/http/handler/stacks/stack_migrate.go @@ -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} } - 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} - } + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + 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) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} - } + securityContext, err := security.RetrieveRestrictedRequestContext(r) + if err != nil { + return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve info from request context", err} + } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - 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} + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + 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} + } } // TODO: this is a work-around for stacks created with Portainer version >= 1.17.1 diff --git a/api/http/handler/stacks/stack_start.go b/api/http/handler/stacks/stack_start.go index e2c9fbfba..4b036a287 100644 --- a/api/http/handler/stacks/stack_start.go +++ b/api/http/handler/stacks/stack_start.go @@ -69,17 +69,19 @@ func (handler *Handler) stackStart(w http.ResponseWriter, r *http.Request) *http return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)} } - 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} - } + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + 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} + } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - 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} + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + 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 stack.Status == portainer.StackStatusActive { diff --git a/api/http/handler/stacks/stack_stop.go b/api/http/handler/stacks/stack_stop.go index fcab18929..8b435c560 100644 --- a/api/http/handler/stacks/stack_stop.go +++ b/api/http/handler/stacks/stack_stop.go @@ -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} } - 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} - } + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + 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} + } - access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) - 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} + access, err := handler.userCanAccessStack(securityContext, endpoint.ID, resourceControl) + 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 stack.Status == portainer.StackStatusInactive { diff --git a/api/http/handler/stacks/stack_update.go b/api/http/handler/stacks/stack_update.go index 24f85f817..99aeab570 100644 --- a/api/http/handler/stacks/stack_update.go +++ b/api/http/handler/stacks/stack_update.go @@ -1,11 +1,12 @@ package stacks import ( - "errors" "net/http" "strconv" "time" + "github.com/pkg/errors" + "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "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)) 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 { - 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 @@ -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. endpointID, err := request.RetrieveNumericQueryParameter(r, "endpointId", true) 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) { 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) 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 { - 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) if err != nil { - 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 err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve a resource control associated to the stack", err} + return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Permission denied to access endpoint", Err: err} } securityContext, err := security.RetrieveRestrictedRequestContext(r) 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) - 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} + //only check resource control when it is a DockerSwarmStack or a DockerComposeStack + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + + 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} + } + + 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) @@ -123,9 +128,17 @@ func (handler *Handler) stackUpdate(w http.ResponseWriter, r *http.Request) *htt 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) 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 != "" { @@ -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 { if stack.Type == portainer.DockerSwarmStack { 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 { var payload updateComposeStackPayload err := request.DecodeAndValidateJSONPayload(r, &payload) 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 @@ -155,7 +173,7 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta stackFolder := strconv.Itoa(int(stack.ID)) _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) 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) @@ -163,13 +181,9 @@ func (handler *Handler) updateComposeStack(r *http.Request, stack *portainer.Sta return configErr } - stack.UpdateDate = time.Now().Unix() - stack.UpdatedBy = config.user.Username - stack.Status = portainer.StackStatusActive - err = handler.deployComposeStack(config) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err} } return nil @@ -179,7 +193,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack var payload updateSwarmStackPayload err := request.DecodeAndValidateJSONPayload(r, &payload) 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 @@ -187,7 +201,7 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack stackFolder := strconv.Itoa(int(stack.ID)) _, err = handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) 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) @@ -195,13 +209,9 @@ func (handler *Handler) updateSwarmStack(r *http.Request, stack *portainer.Stack return configErr } - stack.UpdateDate = time.Now().Unix() - stack.UpdatedBy = config.user.Username - stack.Status = portainer.StackStatusActive - err = handler.deploySwarmStack(config) if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, err.Error(), err} + return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: err.Error(), Err: err} } return nil diff --git a/api/http/handler/stacks/stack_update_git.go b/api/http/handler/stacks/stack_update_git.go index 48b7d2af4..7e1e7883c 100644 --- a/api/http/handler/stacks/stack_update_git.go +++ b/api/http/handler/stacks/stack_update_git.go @@ -37,8 +37,8 @@ func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { } // @id StackUpdateGit -// @summary Redeploy a stack -// @description Pull and redeploy a stack via Git +// @summary Update a stack's Git configs +// @description Update the Git settings in a stack, e.g., RepositoryReferenceName and AutoUpdate // @description **Access policy**: restricted // @tags stacks // @security jwt @@ -46,7 +46,7 @@ func (payload *stackGitUpdatePayload) Validate(r *http.Request) error { // @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 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" // @failure 400 "Invalid request" // @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} } - 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} - } + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + 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} - } + 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} + 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} + } } //stop the autoupdate job if there is any diff --git a/api/http/handler/stacks/stack_update_git_redeploy.go b/api/http/handler/stacks/stack_update_git_redeploy.go index c338757e0..445ba9604 100644 --- a/api/http/handler/stacks/stack_update_git_redeploy.go +++ b/api/http/handler/stacks/stack_update_git_redeploy.go @@ -2,11 +2,14 @@ package stacks import ( "fmt" + "io/ioutil" "log" "net/http" + "path/filepath" "time" "github.com/asaskevich/govalidator" + "github.com/pkg/errors" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -16,6 +19,7 @@ import ( httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" "github.com/portainer/portainer/api/internal/stackutils" + k "github.com/portainer/portainer/api/kubernetes" ) type stackGitRedployPayload struct { @@ -30,11 +34,26 @@ func (payload *stackGitRedployPayload) Validate(r *http.Request) error { if govalidator.IsNull(payload.RepositoryReferenceName) { payload.RepositoryReferenceName = defaultGitReferenceName } - return nil } -// PUT request on /api/stacks/:id/git?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 { stackID, err := request.RetrieveNumericRouteVariableValue(r, "id") 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} } - 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} + //only check resource control when it is a DockerSwarmStack or a DockerComposeStack + if stack.Type == portainer.DockerSwarmStack || stack.Type == portainer.DockerComposeStack { + + 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} + } + + 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 @@ -140,9 +163,23 @@ func (handler *Handler) stackGitRedeploy(w http.ResponseWriter, r *http.Request) 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) 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 != "" { @@ -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 { - if stack.Type == portainer.DockerSwarmStack { + switch stack.Type { + case portainer.DockerSwarmStack: config, httpErr := handler.createSwarmDeployConfig(r, stack, endpoint, false) if httpErr != nil { return httpErr } - err := handler.deploySwarmStack(config) - if err != nil { + if err := handler.deploySwarmStack(config); 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 + case portainer.DockerComposeStack: + config, httpErr := handler.createComposeDeployConfig(r, stack, endpoint) + 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 } diff --git a/api/http/handler/stacks/update_kubernetes_stack.go b/api/http/handler/stacks/update_kubernetes_stack.go new file mode 100644 index 000000000..114552b68 --- /dev/null +++ b/api/http/handler/stacks/update_kubernetes_stack.go @@ -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 +} diff --git a/api/portainer.go b/api/portainer.go index 48aab26da..87903d4b9 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -759,6 +759,10 @@ type ( AutoUpdate *StackAutoUpdate `json:"AutoUpdate"` // The git config of this stack 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 diff --git a/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatable.html b/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatable.html index 880ad737f..0b0d95060 100644 --- a/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatable.html +++ b/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatable.html @@ -101,7 +101,7 @@ - + Quota diff --git a/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatableController.js b/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatableController.js index a669e3bc0..46519a99d 100644 --- a/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatableController.js +++ b/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatableController.js @@ -38,7 +38,7 @@ angular.module('portainer.docker').controller('KubernetesResourcePoolsDatatableC return !ctrl.isSystemNamespace(item) || (ctrl.settings.showSystem && ctrl.isAdmin); }; - this.namespaceStatusColor = function(status) { + this.namespaceStatusColor = function (status) { switch (status.toLowerCase()) { case 'active': return 'success'; diff --git a/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html b/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html index 56958fc34..1f67c26b5 100644 --- a/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html +++ b/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html @@ -56,7 +56,7 @@ + -
- Application -
- -
- -
- + + + + + + + + + +

+ + Portainer uses Kompose to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that not + all the Compose format options are supported by Kompose at the moment. +

+

+ You can get more information about Compose file format in the + official documentation. +

+ + +

+ + This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...). +

+

+ You can get more information about Kubernetes file format in the + official documentation. +

+
+ + + +
+
+ Application
-
-
-
-
-

This field is required.

-

This field must consist of lower case alphanumeric characters or '-', start with an alphabetic - character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123').

+
+ +
+ +
+
+
+
+
+

This field is required.

+

This field must consist of lower case alphanumeric characters or '-', start with an alphabetic + character, and end with an alphanumeric character (e.g. 'my-name', or 'abc-123').

+
+

An application with the same name already exists inside the selected namespace.

-

An application with the same name already exists inside the selected namespace.

-
- + - + -
-
- -
-
- -
- Stack -
- -
-
- - Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to use the - application name. -
-
- -
- -
- -
-
- - -
- Environment -
- -
-
- - - add environment variable - -
- -
-
-
-
-
- name - -
-
- -
- value - -
- -
- - -
-
-
-
-
- -

Environment variable name is required.

-

This field must consist of alphabetic characters, digits, '_', '-', or '.', and must not - start with a digit (e.g. 'my.env-name', or 'MY_ENV.NAME', or 'MyEnvName1'.

-
-

This environment variable is already defined.

-
-
-
-
-
-
-
-
- - -
- Configurations -
- -
-
- - - add configuration - -
-
- - Portainer will automatically expose all the keys of a configuration as environment variables. This behavior can be overriden to filesystem mounts for each key via - the override button. -
-
- - -
- -
- -
-
- - - -
- -
-
-
- The following keys will be loaded from the {{ config.SelectedConfiguration.Name }} configuration as environment variables: - - {{ key }}{{ $last ? '' : ', ' }} - -
-
- - - -
-
-
-
-
- configuration key - -
- -
-
- path on disk - -
-
- -
- - -
-
- -
-
-
-
-
- -

Path is required.

-
-

This path is already used.

-
-
-
-
-
-
- -
- - - -
- Persisting data -
- -
-
- - No storage option is available to persist data, contact your administrator to enable a storage option. -
-
- -
-
- - - add persisted folder - -
- -
-
-
- path in container - -
- -
- - - - -
- -
- requested size - - - - -
- -
- storage - - -
- -
- volume - -
- -
-
- - -
-
-
- -
-
-
- -

Path is required.

-
-

This path is already defined.

-
-
- -
- -
-
- -

Size is required.

-

This value must be greater than zero.

-
-
-
- -

Volume is required.

-
-

This volume is already used.

-
-
- -
-
-
-
- - - -
- +
+
+ Stack +
+
- Specify how the data will be used across instances. + + Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to use + the application name.
- -
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- -
- - -
- Resource reservations -
- -
-
- - Resource reservations are applied per instance of the application. -
-
- -
-
- - A resource quota is set on this namespace, you must specify resource reservations. Resource reservations are applied per instance of the application. Maximums are - inherited from the namespace quota. -
-
- -
-
- - This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the - namespace. -
-
- - -
- -
- -
-
- -
-
-

- Maximum memory usage (MB) -

-
-
-
-
-
-

Value must be between {{ ctrl.state.sliders.memory.min }} and - {{ ctrl.state.sliders.memory.max }} -

-
-
-
- - -
- -
- -
-
-

- Maximum CPU usage -

-
-
- -
-
- - These reservations would exceed the resources currently available in the cluster. -
-
- - - -
- Deployment -
- -
-
- Select how you want to deploy your application inside the cluster. -
-
- - -
-
-
- - -
-
- - -
-
+
+ +
-
-
- + - -
-
- - -
-
-
-
- -

Instance count is required.

-

Instance count must be greater than 0.

-
-
-
- - -
-
- - This application will reserve the following resources: - {{ ctrl.formValues.CpuLimit * ctrl.formValues.ReplicaCount | kubernetesApplicationCPUValue }} CPU and - {{ ctrl.formValues.MemoryLimit * ctrl.formValues.ReplicaCount }} MB of memory. -
-
- -
-
- - This application would exceed available resources. Please review resource reservations or the instance count. -
-
- -
-
- - The following storage option(s) do not support concurrent access from multiples instances: {{ ctrl.getNonScalableStorage() }}. You will not be able to scale that application. -
-
- - - -
- Auto-scaling -
- -
-
- - -
-
- -
-
-

- This feature is currently disabled and must be enabled by an administrator user. -

-

- Server metrics features must be enabled in the - endpoint configuration view. -

-
-
- -
- - - - - - - - - - - - - -
Minimum instancesMaximum instances - Target CPU usage (%) - - -
-
- -
-
-
- -

Minimum instances is required.

-

Minimum instances must be greater than 0.

-

Minimum instances must be smaller than maximum instances.

-
-
-
-
-
- -
-
-
- -

Maximum instances is required.

-

Maximum instances must be greater than minimum instances.

-
-
-
-
-
- -
-
-
- -

Target CPU usage is required.

-

Target CPU usage must be greater than 0.

-

Target CPU usage must be smaller than 100.

-
-
-
-
- -
-
- - This application would exceed available resources. Please review resource reservations or the maximum instance count of the auto-scaling policy. -
-
-
- - -
- Placement preferences and constraints + Environment
- - +
- - - add rule + + + add environment variable
-
- - Deploy this application on nodes that respect ALL of the following placement rules. Placement rules are based on node labels. -
-
-
-
- -
-
- -
- -
- - -
-
-
-
-
-

- This label is already defined. -

+
+
+
+
+ name + +
+ +
+ value + +
+ +
+ + +
+
+
+
+
+ +

Environment variable name is required.

+

This field must consist of alphabetic characters, digits, '_', '-', or '.', and must + not start with a digit (e.g. 'my.env-name', or 'MY_ENV.NAME', or 'MyEnvName1'.

+
+

This environment variable is already defined.

+
+
+
+
-
+ + +
+ Configurations +
+ +
+
+ + + add configuration + +
+
+ + Portainer will automatically expose all the keys of a configuration as environment variables. This behavior can be overriden to filesystem mounts for each key via + the override button. +
+
+ + +
+ +
+ +
+
+ + + +
+ +
+
+
+ The following keys will be loaded from the {{ config.SelectedConfiguration.Name }} configuration as environment variables: + + {{ key }}{{ $last ? '' : ', ' }} + +
+
+ + + +
+
+
+
+
+ configuration key + +
+ +
+
+ path on disk + +
+
+ +
+ + +
+
+ +
+
+
+
+
+ +

Path is required.

+
+

This path is already used.

+
+
+
+
+
+
+ +
+ + + +
+ Persisting data +
+ +
+
+ + No storage option is available to persist data, contact your administrator to enable a storage option. +
+
+ +
+
+ + + add persisted folder + +
+ +
+
+
+ path in container + +
+ +
+ + + + +
+ +
+ requested size + + + + +
+ +
+ storage + + +
+ +
+ volume + +
+ +
+
+ + +
+
+
+ +
+
+
+ +

Path is required.

+
+

This path is already defined.

+
+
+ +
+ +
+
+ +

Size is required.

+

This value must be greater than zero.

+
+
+
+ +

Volume is required.

+
+

This volume is already used.

+
+
+ +
+
+
+
+ + + +
- +
- Specify the policy associated to the placement rules. + Specify how the data will be used across instances.
- -
+ +
-
- -
- +
-
-
- Publishing the application -
- -
-
- Select how you want to publish your application. +
+ Resource reservations
-
- - -
-
-
- - - -
- -
- - - -
-
- - - -
-
- - - + +
+
+ + Resource reservations are applied per instance of the application.
-
- - -
-
- - - publish a new port - +
+
+ + A resource quota is set on this namespace, you must specify resource reservations. Resource reservations are applied per instance of the application. Maximums are + inherited from the namespace quota. +
+
+
+ + This namespace has exhausted its resource capacity and you will not be able to deploy the application. Contact your administrator to expand the capacity of the + namespace. +
+
+ + +
+ +
+ +
+
+ +
+
+

+ Maximum memory usage (MB) +

+
+
+
+
+
+

Value must be between {{ ctrl.state.sliders.memory.min }} and + {{ ctrl.state.sliders.memory.max }} +

+
+
+
+ + +
+ +
+ +
+
+

+ Maximum CPU usage +

+
+
+ +
+
+ + These reservations would exceed the resources currently available in the cluster. +
+
+ + + +
+ Deployment +
+ +
+
+ Select how you want to deploy your application inside the cluster. +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + +
+
+ + +
+
+
+
+ +

Instance count is required.

+

Instance count must be greater than 0.

+
+
+
+ +
- - When publishing a port in cluster mode, the node port is optional. If left empty Kubernetes will use a random port number. If you wish to specify a port, use a port - number inside the default range 30000-32767. -
-
- At least one published port must be defined. +
+ + This application will reserve the following resources: + {{ ctrl.formValues.CpuLimit * ctrl.formValues.ReplicaCount | kubernetesApplicationCPUValue }} CPU and + {{ ctrl.formValues.MemoryLimit * ctrl.formValues.ReplicaCount }} MB of memory. +
-
- -
-
- container port - -
+
+
+ + This application would exceed available resources. Please review resource reservations or the instance count. +
+
-
- node port - -
+
+
+ + The following storage option(s) do not support concurrent access from multiples instances: {{ ctrl.getNonScalableStorage() }}. You will not be able to scale that application. +
+
+ -
- load balancer port - -
+ +
+ Auto-scaling +
-
- ingress - -
+
+
+ + +
+
-
- hostname - -
+
+
+

+ This feature is currently disabled and must be enabled by an administrator user. +

+

+ Server metrics features must be enabled in the + endpoint configuration view. +

+
+
-
- route - -
+
+ + + + + + + + + + + + + +
Minimum instancesMaximum instances + Target CPU usage (%) + + +
+
+ +
+
+
+ +

Minimum instances is required.

+

Minimum instances must be greater than 0.

+

Minimum instances must be smaller than maximum instances.

+
+
+
+
+
+ +
+
+
+ +

Maximum instances is required.

+

Maximum instances must be greater than minimum instances.

+
+
+
+
+
+ +
+
+
+ +

Target CPU usage is required.

+

Target CPU usage must be greater than 0.

+

Target CPU usage must be smaller than 100.

+
+
+
+
-
-
- - -
- - +
+
+ + This application would exceed available resources. Please review resource reservations or the maximum instance count of the auto-scaling policy.
+
+ + +
+
+ Placement preferences and constraints +
+ + +
+
+ + + add rule + +
+ +
+ + Deploy this application on nodes that respect ALL of the following placement rules. Placement rules are based on node labels. +
+ +
+
+
+ +
+
+ +
+ +
+ + +
+
+
+
+
+

+ This label is already defined. +

+
+
+
+
+
+
+
+
+ +
+
+ +
+
+ Specify the policy associated to the placement rules. +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+ Publishing the application +
+ +
+
+ Select how you want to publish your application. +
+
+ + +
+
+
+ + + +
+ +
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + + +
+
+ + + publish a new port + +
-
-
+ + When publishing a port in cluster mode, the node port is optional. If left empty Kubernetes will use a random port number. If you wish to specify a port, use a + port number inside the default range 30000-32767. +
+
+ At least one published port must be defined. +
+ +
+ +
+ container port + +
+ +
-
-

Container port number is required.

-

Container port number must be inside the range 1-65535.

-

Container port number must be inside the range 1-65535.

-
-

- This port is already used. -

+ node port +
-
-
-
-

Node port number must be inside the range 30000-32767.

-

Node port number must be inside the range 30000-32767.

-
-

- This port is already used. -

+ load balancer port +
-
-
-
-
-

Ingress selection is required.

-
-
-
-
-
-

Route is required.

-

This field must consist of alphanumeric characters or the special characters: '-', '_' or - '/'. It must start and end with an alphanumeric character (e.g. 'my-route', or 'route-123').

ingress + +
+ +
+ hostname + +
+ +
+ route + +
+ +
+
+ +
-

- This route is already used. -

+ +
+ -
-
-
-

Load balancer port number is required.

-

Load balancer port number must be inside the range 1-65535.

-

Load balancer port number must be inside the range 1-65535.

+ +
+
+
+
+

Container port number is required.

+

Container port number must be inside the range 1-65535.

+

Container port number must be inside the range 1-65535.

+
+

+ This port is already used. +

-

- - This port is already used. -

+ +
+
+
+

Node port number must be inside the range 30000-32767.

+

Node port number must be inside the range 30000-32767.

+
+

+ This port is already used. +

+
+
+ +
+
+
+

Ingress selection is required.

+
+
+
+
+
+
+

Route is required.

+

This field must consist of alphanumeric characters or the special characters: '-', '_' or + '/'. It must start and end with an alphanumeric character (e.g. 'my-route', or 'route-123').

+
+

+ This route is already used. +

+
+
+ +
+
+
+

Load balancer port number is required.

+

Load balancer port number must be inside the range 1-65535.

+

Load balancer port number must be inside the range 1-65535.

+
+

+ + This port is already used. +

+
+
+ +
- -
+
-
+ + + +
- - - - -
+
Actions
-
+ +
diff --git a/app/kubernetes/views/applications/create/createApplicationController.js b/app/kubernetes/views/applications/create/createApplicationController.js index 31ca80f16..064486f04 100644 --- a/app/kubernetes/views/applications/create/createApplicationController.js +++ b/app/kubernetes/views/applications/create/createApplicationController.js @@ -10,6 +10,7 @@ import { KubernetesApplicationQuotaDefaults, KubernetesApplicationTypes, KubernetesApplicationPlacementTypes, + KubernetesDeploymentTypes, } from 'Kubernetes/models/application/models'; import { KubernetesApplicationConfigurationFormValue, @@ -50,6 +51,7 @@ class KubernetesCreateApplicationController { KubernetesPersistentVolumeClaimService, KubernetesVolumeService, RegistryService, + StackService, KubernetesNodesLimitsService ) { this.$async = $async; @@ -66,6 +68,7 @@ class KubernetesCreateApplicationController { this.KubernetesIngressService = KubernetesIngressService; this.KubernetesPersistentVolumeClaimService = KubernetesPersistentVolumeClaimService; this.RegistryService = RegistryService; + this.StackService = StackService; this.KubernetesNodesLimitsService = KubernetesNodesLimitsService; this.ApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; @@ -75,8 +78,11 @@ class KubernetesCreateApplicationController { this.ApplicationTypes = KubernetesApplicationTypes; this.ApplicationConfigurationFormValueOverridenKeyTypes = KubernetesApplicationConfigurationFormValueOverridenKeyTypes; this.ServiceTypes = KubernetesServiceTypes; + this.KubernetesDeploymentTypes = KubernetesDeploymentTypes; this.state = { + appType: this.KubernetesDeploymentTypes.APPLICATION_FORM, + updateWebEditorInProgress: false, actionInProgress: false, useLoadBalancer: false, useServerMetrics: false, @@ -133,9 +139,51 @@ class KubernetesCreateApplicationController { this.updateApplicationAsync = this.updateApplicationAsync.bind(this); this.deployApplicationAsync = this.deployApplicationAsync.bind(this); this.setPullImageValidity = this.setPullImageValidity.bind(this); + this.onChangeFileContent = this.onChangeFileContent.bind(this); } /* #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) { this.state.pullImageValidity = validity; } @@ -1029,6 +1077,17 @@ class KubernetesCreateApplicationController { this.nodesLabels, 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.ImageModel = await this.parseImageConfiguration(this.formValues.ImageModel); this.savedFormValues = angular.copy(this.formValues); diff --git a/app/kubernetes/views/applications/edit/application.html b/app/kubernetes/views/applications/edit/application.html index 54a641ca8..5ea71a2e1 100644 --- a/app/kubernetes/views/applications/edit/application.html +++ b/app/kubernetes/views/applications/edit/application.html @@ -67,7 +67,10 @@ Creation {{ ctrl.application.ApplicationOwner }} - {{ ctrl.application.CreationDate | getisodate }} + {{ ctrl.application.CreationDate | getisodate }} + + Deployed from {{ ctrl.state.appType }} @@ -210,7 +213,7 @@ class="btn btn-sm btn-primary" style="margin-left: 0;" 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" > Rollback to previous configuration diff --git a/app/kubernetes/views/applications/edit/applicationController.js b/app/kubernetes/views/applications/edit/applicationController.js index 710571c53..935de40aa 100644 --- a/app/kubernetes/views/applications/edit/applicationController.js +++ b/app/kubernetes/views/applications/edit/applicationController.js @@ -1,7 +1,12 @@ import angular from 'angular'; import _ from 'lodash-es'; 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 KubernetesApplicationHelper from 'Kubernetes/helpers/application'; import { KubernetesServiceTypes } from 'Kubernetes/models/service/models'; @@ -127,6 +132,7 @@ class KubernetesApplicationController { this.KubernetesApplicationDeploymentTypes = KubernetesApplicationDeploymentTypes; this.KubernetesApplicationTypes = KubernetesApplicationTypes; this.EndpointProvider = EndpointProvider; + this.KubernetesDeploymentTypes = KubernetesDeploymentTypes; this.ApplicationDataAccessPolicies = KubernetesApplicationDataAccessPolicies; this.KubernetesServiceTypes = KubernetesServiceTypes; @@ -137,6 +143,7 @@ class KubernetesApplicationController { this.getApplicationAsync = this.getApplicationAsync.bind(this); this.getEvents = this.getEvents.bind(this); this.getEventsAsync = this.getEventsAsync.bind(this); + this.updateApplicationKindText = this.updateApplicationKindText.bind(this); this.updateApplicationAsync = this.updateApplicationAsync.bind(this); this.redeployApplicationAsync = this.redeployApplicationAsync.bind(this); this.rollbackApplicationAsync = this.rollbackApplicationAsync.bind(this); @@ -262,6 +269,14 @@ class KubernetesApplicationController { 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 */ @@ -343,6 +358,7 @@ class KubernetesApplicationController { namespace: this.$transition$.params().namespace, name: this.$transition$.params().name, }, + appType: this.KubernetesDeploymentTypes.APPLICATION_FORM, eventWarningCount: 0, placementWarning: false, expandedNote: false, @@ -359,6 +375,7 @@ class KubernetesApplicationController { await this.getApplication(); await this.getEvents(); + this.updateApplicationKindText(); this.state.viewReady = true; } diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html index e0ed3d9e9..bf892cfd6 100644 --- a/app/kubernetes/views/deploy/deploy.html +++ b/app/kubernetes/views/deploy/deploy.html @@ -111,7 +111,7 @@ - +
@@ -135,7 +135,7 @@ />
-
+
diff --git a/app/portainer/components/form-components/web-editor-form/index.js b/app/portainer/components/form-components/web-editor-form/index.js index bc1c80a07..ab3c06417 100644 --- a/app/portainer/components/form-components/web-editor-form/index.js +++ b/app/portainer/components/form-components/web-editor-form/index.js @@ -9,7 +9,6 @@ export const webEditorForm = { placeholder: '@', yml: '<', value: '<', - onChange: '<', }, diff --git a/app/portainer/components/forms/git-form/git-form-auth-fieldset/index.js b/app/portainer/components/forms/git-form/git-form-auth-fieldset/index.js index 3000869c3..a5fe96be2 100644 --- a/app/portainer/components/forms/git-form/git-form-auth-fieldset/index.js +++ b/app/portainer/components/forms/git-form/git-form-auth-fieldset/index.js @@ -6,6 +6,7 @@ export const gitFormAuthFieldset = { bindings: { model: '<', onChange: '<', + showAuthExplanation: '<', isEdit: '<', }, }; diff --git a/app/portainer/components/forms/git-form/git-form-info-panel/git-form-info-panel.html b/app/portainer/components/forms/git-form/git-form-info-panel/git-form-info-panel.html new file mode 100644 index 000000000..ae8c95252 --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-info-panel/git-form-info-panel.html @@ -0,0 +1,15 @@ +
+
+

+ This stack was deployed from the git repository {{ $ctrl.url }} + . +

+

+ Update + {{ $ctrl.configFilePath }},{{ $ctrl.additionalFiles.join(',') }} + in git and pull from here to update the stack. +

+
+
diff --git a/app/portainer/components/forms/git-form/git-form-info-panel/index.js b/app/portainer/components/forms/git-form/git-form-info-panel/index.js new file mode 100644 index 000000000..c2529969b --- /dev/null +++ b/app/portainer/components/forms/git-form/git-form-info-panel/index.js @@ -0,0 +1,9 @@ +export const gitFormInfoPanel = { + templateUrl: './git-form-info-panel.html', + bindings: { + url: '<', + configFilePath: '<', + additionalFiles: '<', + className: '@', + }, +}; diff --git a/app/portainer/components/forms/git-form/index.js b/app/portainer/components/forms/git-form/index.js index 60eff71e6..e73966464 100644 --- a/app/portainer/components/forms/git-form/index.js +++ b/app/portainer/components/forms/git-form/index.js @@ -8,6 +8,7 @@ import { gitFormAutoUpdateFieldset } from './git-form-auto-update-fieldset'; import { gitFormComposePathField } from './git-form-compose-path-field'; import { gitFormRefField } from './git-form-ref-field'; import { gitFormUrlField } from './git-form-url-field'; +import { gitFormInfoPanel } from './git-form-info-panel'; export default angular .module('portainer.app.components.forms.git', []) @@ -15,6 +16,7 @@ export default angular .component('gitFormRefField', gitFormRefField) .component('gitForm', gitForm) .component('gitFormUrlField', gitFormUrlField) + .component('gitFormInfoPanel', gitFormInfoPanel) .component('gitFormAdditionalFilesPanel', gitFormAdditionalFilesPanel) .component('gitFormAdditionalFileItem', gitFormAdditionalFileItem) .component('gitFormAutoUpdateFieldset', gitFormAutoUpdateFieldset) diff --git a/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.controller.js b/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.controller.js new file mode 100644 index 000000000..68ab3d1bc --- /dev/null +++ b/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.controller.js @@ -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; diff --git a/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.html b/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.html new file mode 100644 index 000000000..88f019f26 --- /dev/null +++ b/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.html @@ -0,0 +1,59 @@ + +
+ Redeploy from git repository +
+
+
+

+ Pull the latest manifest from git and redeploy the application. +

+
+
+ + + + + +
+ Actions +
+ + + + diff --git a/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.js b/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.js new file mode 100644 index 000000000..7a21a6384 --- /dev/null +++ b/app/portainer/components/forms/kubernetes-app-git-form/kubernetes-app-git-form.js @@ -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); diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js index 6641f25f0..5012b7241 100644 --- a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.controller.js @@ -38,7 +38,6 @@ class StackRedeployGitFormController { this.onChangeRef = this.onChangeRef.bind(this); this.onChangeAutoUpdate = this.onChangeAutoUpdate.bind(this); this.onChangeEnvVar = this.onChangeEnvVar.bind(this); - this.handleEnvVarChange = this.handleEnvVarChange.bind(this); } buildAnalyticsProperties() { @@ -143,10 +142,6 @@ class StackRedeployGitFormController { return this.state.inProgress || this.state.redeployInProgress; } - handleEnvVarChange(value) { - this.formValues.Env = value; - } - isAutoUpdateChanged() { const wasEnabled = !!(this.stack.AutoUpdate && (this.stack.AutoUpdate.Interval || this.stack.AutoUpdate.Webhook)); const isEnabled = this.formValues.AutoUpdate.RepositoryAutomaticUpdates; diff --git a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html index 5d9025438..bb9c6585e 100644 --- a/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html +++ b/app/portainer/components/forms/stack-redeploy-git-form/stack-redeploy-git-form.html @@ -2,21 +2,12 @@
Redeploy from git repository
-
-
-

- This stack was deployed from the git repository {{ $ctrl.model.URL }} - . -

-

- Update - {{ $ctrl.model.ConfigFilePath }},{{ $ctrl.stack.AdditionalFiles.join(',') }} - in git and pull from here to update the stack. -

-
-
+
diff --git a/app/portainer/models/stack.js b/app/portainer/models/stack.js index 41c32f70f..118174d0a 100644 --- a/app/portainer/models/stack.js +++ b/app/portainer/models/stack.js @@ -7,6 +7,7 @@ export function StackViewModel(data) { this.EndpointId = data.EndpointId; this.SwarmId = data.SwarmId; this.Env = data.Env ? data.Env : []; + this.IsComposeFormat = data.IsComposeFormat; if (data.ResourceControl && data.ResourceControl.Id !== 0) { this.ResourceControl = new ResourceControlViewModel(data.ResourceControl); } diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js index 86ad1905a..da3a1a545 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -15,6 +15,7 @@ angular.module('portainer.app').factory('StackService', [ 'use strict'; var service = { updateGit, + updateKubeGit, }; 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; }; + 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) { return FileUploadService.createComposeStack(name, stackFile, env, endpointId); }; @@ -417,6 +437,19 @@ angular.module('portainer.app').factory('StackService', [ ).$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) { // prepare auto update const autoUpdate = {}; diff --git a/app/portainer/views/stacks/create/createStackController.js b/app/portainer/views/stacks/create/createStackController.js index d01646ef8..93c5ca05d 100644 --- a/app/portainer/views/stacks/create/createStackController.js +++ b/app/portainer/views/stacks/create/createStackController.js @@ -34,7 +34,7 @@ angular StackFile: null, RepositoryURL: '', RepositoryReferenceName: '', - RepositoryAuthentication: false, + RepositoryAuthentication: true, RepositoryUsername: '', RepositoryPassword: '', Env: [],