diff --git a/.vscode.example/portainer.code-snippets b/.vscode.example/portainer.code-snippets
index fefb732ce..fa3511098 100644
--- a/.vscode.example/portainer.code-snippets
+++ b/.vscode.example/portainer.code-snippets
@@ -163,5 +163,19 @@
"// @failure 500 \"Server error\"",
"// @router /{id} [get]"
]
+ },
+ "analytics": {
+ "prefix": "nlt",
+ "body": ["analytics-on", "analytics-category=\"$1\"", "analytics-event=\"$2\""],
+ "description": "analytics"
+ },
+ "analytics-if": {
+ "prefix": "nltf",
+ "body": ["analytics-if=\"$1\""],
+ "description": "analytics"
+ },
+ "analytics-metadata": {
+ "prefix": "nltm",
+ "body": "analytics-properties=\"{ metadata: { $1 } }\""
}
}
diff --git a/api/http/handler/customtemplates/customtemplate_create.go b/api/http/handler/customtemplates/customtemplate_create.go
index e01ba01df..dc741da7a 100644
--- a/api/http/handler/customtemplates/customtemplate_create.go
+++ b/api/http/handler/customtemplates/customtemplate_create.go
@@ -105,9 +105,10 @@ type customTemplateFromFileContentPayload struct {
Note string `example:"This is my custom template"`
// Platform associated to the template.
// Valid values are: 1 - 'linux', 2 - 'windows'
- Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"`
- // Type of created stack (1 - swarm, 2 - compose)
- Type portainer.StackType `example:"1" enums:"1,2" validate:"required"`
+ // Required for Docker stacks
+ Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"`
+ // Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes)
+ Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"`
// Content of stack file
FileContent string `validate:"required"`
}
@@ -122,10 +123,10 @@ func (payload *customTemplateFromFileContentPayload) Validate(r *http.Request) e
if govalidator.IsNull(payload.FileContent) {
return errors.New("Invalid file content")
}
- if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
+ if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
return errors.New("Invalid custom template platform")
}
- if payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack {
+ if payload.Type != portainer.KubernetesStack && payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack {
return errors.New("Invalid custom template type")
}
return nil
@@ -171,7 +172,8 @@ type customTemplateFromGitRepositoryPayload struct {
Note string `example:"This is my custom template"`
// Platform associated to the template.
// Valid values are: 1 - 'linux', 2 - 'windows'
- Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"`
+ // Required for Docker stacks
+ Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"`
// Type of created stack (1 - swarm, 2 - compose)
Type portainer.StackType `example:"1" enums:"1,2" validate:"required"`
@@ -205,6 +207,11 @@ func (payload *customTemplateFromGitRepositoryPayload) Validate(r *http.Request)
if govalidator.IsNull(payload.ComposeFilePathInRepository) {
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
}
+
+ if payload.Type == portainer.KubernetesStack {
+ return errors.New("Creating a Kubernetes custom template from git is not supported")
+ }
+
if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
return errors.New("Invalid custom template platform")
}
@@ -278,20 +285,21 @@ func (payload *customTemplateFromFileUploadPayload) Validate(r *http.Request) er
note, _ := request.RetrieveMultiPartFormValue(r, "Note", true)
payload.Note = note
- platform, _ := request.RetrieveNumericMultiPartFormValue(r, "Platform", true)
- templatePlatform := portainer.CustomTemplatePlatform(platform)
- if templatePlatform != portainer.CustomTemplatePlatformLinux && templatePlatform != portainer.CustomTemplatePlatformWindows {
- return errors.New("Invalid custom template platform")
- }
- payload.Platform = templatePlatform
-
typeNumeral, _ := request.RetrieveNumericMultiPartFormValue(r, "Type", true)
templateType := portainer.StackType(typeNumeral)
- if templateType != portainer.DockerComposeStack && templateType != portainer.DockerSwarmStack {
+ if templateType != portainer.KubernetesStack && templateType != portainer.DockerSwarmStack && templateType != portainer.DockerComposeStack {
return errors.New("Invalid custom template type")
}
payload.Type = templateType
+ platform, _ := request.RetrieveNumericMultiPartFormValue(r, "Platform", true)
+ templatePlatform := portainer.CustomTemplatePlatform(platform)
+ if templateType != portainer.KubernetesStack && templatePlatform != portainer.CustomTemplatePlatformLinux && templatePlatform != portainer.CustomTemplatePlatformWindows {
+ return errors.New("Invalid custom template platform")
+ }
+
+ payload.Platform = templatePlatform
+
composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "File")
if err != nil {
return errors.New("Invalid Compose file. Ensure that the Compose file is uploaded correctly")
diff --git a/api/http/handler/customtemplates/customtemplate_list.go b/api/http/handler/customtemplates/customtemplate_list.go
index 13dd10363..e6d616de3 100644
--- a/api/http/handler/customtemplates/customtemplate_list.go
+++ b/api/http/handler/customtemplates/customtemplate_list.go
@@ -2,7 +2,9 @@ package customtemplates
import (
"net/http"
+ "strconv"
+ "github.com/pkg/errors"
httperror "github.com/portainer/libhttp/error"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
@@ -17,10 +19,16 @@ import (
// @tags custom_templates
// @security jwt
// @produce json
+// @param type query []int true "Template types" Enums(1,2,3)
// @success 200 {array} portainer.CustomTemplate "Success"
// @failure 500 "Server error"
// @router /custom_templates [get]
func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
+ templateTypes, err := parseTemplateTypes(r)
+ if err != nil {
+ return &httperror.HandlerError{http.StatusBadRequest, "Invalid Custom template type", err}
+ }
+
customTemplates, err := handler.DataStore.CustomTemplate().CustomTemplates()
if err != nil {
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve custom templates from the database", err}
@@ -52,5 +60,52 @@ func (handler *Handler) customTemplateList(w http.ResponseWriter, r *http.Reques
customTemplates = authorization.FilterAuthorizedCustomTemplates(customTemplates, user, userTeamIDs)
}
+ customTemplates = filterByType(customTemplates, templateTypes)
+
return response.JSON(w, customTemplates)
}
+
+func parseTemplateTypes(r *http.Request) ([]portainer.StackType, error) {
+ err := r.ParseForm()
+ if err != nil {
+ return nil, errors.WithMessage(err, "failed to parse request params")
+ }
+
+ types, exist := r.Form["type"]
+ if !exist {
+ return []portainer.StackType{}, nil
+ }
+
+ res := []portainer.StackType{}
+ for _, templateTypeStr := range types {
+ templateType, err := strconv.Atoi(templateTypeStr)
+ if err != nil {
+ return nil, errors.WithMessage(err, "failed parsing template type")
+ }
+
+ res = append(res, portainer.StackType(templateType))
+ }
+
+ return res, nil
+}
+
+func filterByType(customTemplates []portainer.CustomTemplate, templateTypes []portainer.StackType) []portainer.CustomTemplate {
+ if len(templateTypes) == 0 {
+ return customTemplates
+ }
+
+ typeSet := map[portainer.StackType]bool{}
+ for _, templateType := range templateTypes {
+ typeSet[templateType] = true
+ }
+
+ filtered := []portainer.CustomTemplate{}
+
+ for _, template := range customTemplates {
+ if typeSet[template.Type] {
+ filtered = append(filtered, template)
+ }
+ }
+
+ return filtered
+}
diff --git a/api/http/handler/customtemplates/customtemplate_update.go b/api/http/handler/customtemplates/customtemplate_update.go
index ecbd9c48a..dcbb1dd65 100644
--- a/api/http/handler/customtemplates/customtemplate_update.go
+++ b/api/http/handler/customtemplates/customtemplate_update.go
@@ -27,9 +27,10 @@ type customTemplateUpdatePayload struct {
Note string `example:"This is my custom template"`
// Platform associated to the template.
// Valid values are: 1 - 'linux', 2 - 'windows'
- Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2" validate:"required"`
- // Type of created stack (1 - swarm, 2 - compose)
- Type portainer.StackType `example:"1" enums:"1,2" validate:"required"`
+ // Required for Docker stacks
+ Platform portainer.CustomTemplatePlatform `example:"1" enums:"1,2"`
+ // Type of created stack (1 - swarm, 2 - compose, 3 - kubernetes)
+ Type portainer.StackType `example:"1" enums:"1,2,3" validate:"required"`
// Content of stack file
FileContent string `validate:"required"`
}
@@ -41,10 +42,10 @@ func (payload *customTemplateUpdatePayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.FileContent) {
return errors.New("Invalid file content")
}
- if payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
+ if payload.Type != portainer.KubernetesStack && payload.Platform != portainer.CustomTemplatePlatformLinux && payload.Platform != portainer.CustomTemplatePlatformWindows {
return errors.New("Invalid custom template platform")
}
- if payload.Type != portainer.DockerComposeStack && payload.Type != portainer.DockerSwarmStack {
+ if payload.Type != portainer.KubernetesStack && payload.Type != portainer.DockerSwarmStack && payload.Type != portainer.DockerComposeStack {
return errors.New("Invalid custom template type")
}
if govalidator.IsNull(payload.Description) {
diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go
index df78ca965..18d282fb8 100644
--- a/api/http/handler/stacks/create_compose_stack.go
+++ b/api/http/handler/stacks/create_compose_stack.go
@@ -208,11 +208,11 @@ func (handler *Handler) createComposeStackFromGitRepository(w http.ResponseWrite
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
}
- commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
+ 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
+ stack.GitConfig.ConfigHash = commitID
config, configErr := handler.createComposeDeployConfig(r, stack, endpoint)
if configErr != nil {
diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go
index 63aff8220..4d8661493 100644
--- a/api/http/handler/stacks/create_kubernetes_stack.go
+++ b/api/http/handler/stacks/create_kubernetes_stack.go
@@ -17,6 +17,7 @@ import (
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
gittypes "github.com/portainer/portainer/api/git/types"
+ "github.com/portainer/portainer/api/http/client"
"github.com/portainer/portainer/api/internal/stackutils"
k "github.com/portainer/portainer/api/kubernetes"
)
@@ -40,6 +41,12 @@ type kubernetesGitDeploymentPayload struct {
AutoUpdate *portainer.StackAutoUpdate
}
+type kubernetesManifestURLDeploymentPayload struct {
+ Namespace string
+ ComposeFormat bool
+ ManifestURL string
+}
+
func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.StackFileContent) {
return errors.New("Invalid stack file content")
@@ -72,6 +79,13 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
return nil
}
+func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request) error {
+ if govalidator.IsNull(payload.ManifestURL) || !govalidator.IsURL(payload.ManifestURL) {
+ return errors.New("Invalid manifest URL")
+ }
+ return nil
+}
+
type createKubernetesStackResponse struct {
Output string `json:"Output"`
}
@@ -137,6 +151,8 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit
Output: output,
}
+ doCleanUp = false
+
return response.JSON(w, resp)
}
@@ -181,17 +197,24 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
AutoUpdate: payload.AutoUpdate,
}
+ if payload.RepositoryAuthentication {
+ stack.GitConfig.Authentication = &gittypes.GitAuthentication{
+ Username: payload.RepositoryUsername,
+ Password: payload.RepositoryPassword,
+ }
+ }
+
projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
stack.ProjectPath = projectPath
doCleanUp := true
defer handler.cleanUp(stack, &doCleanUp)
- commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
+ 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
+ stack.GitConfig.ConfigHash = commitID
repositoryUsername := payload.RepositoryUsername
repositoryPassword := payload.RepositoryPassword
@@ -235,6 +258,70 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr
resp := &createKubernetesStackResponse{
Output: output,
}
+
+ doCleanUp = false
+
+ return response.JSON(w, resp)
+}
+
+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(),
+ CreatedBy: user.Username,
+ IsComposeFormat: payload.ComposeFormat,
+ }
+
+ var manifestContent []byte
+ manifestContent, err = client.Get(payload.ManifestURL, 30)
+ if err != nil {
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to retrieve manifest from URL", Err: err}
+ }
+
+ stackFolder := strconv.Itoa(int(stack.ID))
+ projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, manifestContent)
+ if err != nil {
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err}
+ }
+ stack.ProjectPath = projectPath
+
+ doCleanUp := true
+ defer handler.cleanUp(stack, &doCleanUp)
+
+ output, err := handler.deployKubernetesStack(r, endpoint, stack, k.KubeAppLabels{
+ StackID: stackID,
+ Name: stack.Name,
+ Owner: stack.CreatedBy,
+ Kind: "url",
+ })
+ if err != nil {
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
+ }
+
+ err = handler.DataStore.Stack().CreateStack(stack)
+ if err != nil {
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err}
+ }
+
+ resp := &createKubernetesStackResponse{
+ Output: output,
+ }
+
return response.JSON(w, resp)
}
diff --git a/api/http/handler/stacks/create_swarm_stack.go b/api/http/handler/stacks/create_swarm_stack.go
index b2252b9af..ba2ced868 100644
--- a/api/http/handler/stacks/create_swarm_stack.go
+++ b/api/http/handler/stacks/create_swarm_stack.go
@@ -218,11 +218,11 @@ func (handler *Handler) createSwarmStackFromGitRepository(w http.ResponseWriter,
return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to clone git repository", Err: err}
}
- commitId, err := handler.latestCommitID(payload.RepositoryURL, payload.RepositoryReferenceName, payload.RepositoryAuthentication, payload.RepositoryUsername, payload.RepositoryPassword)
+ 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
+ stack.GitConfig.ConfigHash = commitID
config, configErr := handler.createSwarmDeployConfig(r, stack, endpoint, false)
if configErr != nil {
diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go
index 319f31639..fe5a0526f 100644
--- a/api/http/handler/stacks/stack_create.go
+++ b/api/http/handler/stacks/stack_create.go
@@ -149,6 +149,8 @@ func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Req
return handler.createKubernetesStackFromFileContent(w, r, endpoint, userID)
case "repository":
return handler.createKubernetesStackFromGitRepository(w, r, endpoint, userID)
+ case "url":
+ 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/websocket/pod.go b/api/http/handler/websocket/pod.go
index 8fd943890..e917d5a75 100644
--- a/api/http/handler/websocket/pod.go
+++ b/api/http/handler/websocket/pod.go
@@ -135,21 +135,25 @@ func (handler *Handler) hijackPodExecStartOperation(
stdoutReader, stdoutWriter := io.Pipe()
defer stdoutWriter.Close()
+ // errorChan is used to propagate errors from the go routines to the caller.
errorChan := make(chan error, 1)
go streamFromWebsocketToWriter(websocketConn, stdinWriter, errorChan)
go streamFromReaderToWebsocket(websocketConn, stdoutReader, errorChan)
- err = cli.StartExecProcess(serviceAccountToken, isAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter)
- if err != nil {
- return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err}
- }
+ // StartExecProcess is a blocking operation which streams IO to/from pod;
+ // this must execute in asynchronously, since the websocketConn could return errors (e.g. client disconnects) before
+ // the blocking operation is completed.
+ go cli.StartExecProcess(serviceAccountToken, isAdminToken, namespace, podName, containerName, commandArray, stdinReader, stdoutWriter, errorChan)
err = <-errorChan
+
+ // websocket client successfully disconnected
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNoStatusReceived) {
log.Printf("websocket error: %s \n", err.Error())
+ return nil
}
- return nil
+ return &httperror.HandlerError{http.StatusInternalServerError, "Unable to start exec process inside container", err}
}
func (handler *Handler) getToken(request *http.Request, endpoint *portainer.Endpoint, setLocalAdminToken bool) (string, bool, error) {
diff --git a/api/kubernetes/cli/exec.go b/api/kubernetes/cli/exec.go
index 55cc38bc9..f15e43c91 100644
--- a/api/kubernetes/cli/exec.go
+++ b/api/kubernetes/cli/exec.go
@@ -4,7 +4,7 @@ import (
"errors"
"io"
- "k8s.io/api/core/v1"
+ v1 "k8s.io/api/core/v1"
"k8s.io/client-go/kubernetes/scheme"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/remotecommand"
@@ -15,10 +15,12 @@ import (
// using the specified command. The stdin parameter will be bound to the stdin process and the stdout process will write
// to the stdout parameter.
// This function only works against a local endpoint using an in-cluster config with the user's SA token.
-func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error {
+// This is a blocking operation.
+func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error) {
config, err := rest.InClusterConfig()
if err != nil {
- return err
+ errChan <- err
+ return
}
if !useAdminToken {
@@ -44,7 +46,8 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
exec, err := remotecommand.NewSPDYExecutor(config, "POST", req.URL())
if err != nil {
- return err
+ errChan <- err
+ return
}
err = exec.Stream(remotecommand.StreamOptions{
@@ -54,9 +57,7 @@ func (kcl *KubeClient) StartExecProcess(token string, useAdminToken bool, namesp
})
if err != nil {
if _, ok := err.(utilexec.ExitError); !ok {
- return errors.New("unable to start exec process")
+ errChan <- errors.New("unable to start exec process")
}
}
-
- return nil
}
diff --git a/api/portainer.go b/api/portainer.go
index 8cfbaf86d..6e7660220 100644
--- a/api/portainer.go
+++ b/api/portainer.go
@@ -1232,7 +1232,7 @@ type (
GetServiceAccount(tokendata *TokenData) (*v1.ServiceAccount, error)
GetServiceAccountBearerToken(userID int) (string, error)
CreateUserShellPod(ctx context.Context, serviceAccountName string) (*KubernetesShellPod, error)
- StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer) error
+ StartExecProcess(token string, useAdminToken bool, namespace, podName, containerName string, command []string, stdin io.Reader, stdout io.Writer, errChan chan error)
NamespaceAccessPoliciesDeleteNamespace(namespace string) error
GetNodesLimits() (K8sNodesLimits, error)
GetNamespaceAccessPolicies() (map[string]K8sNamespaceAccessPolicy, error)
diff --git a/app/angulartics.matomo/index.js b/app/angulartics.matomo/index.js
index d9477ad1f..f6a4e528e 100644
--- a/app/angulartics.matomo/index.js
+++ b/app/angulartics.matomo/index.js
@@ -1,4 +1,5 @@
import angular from 'angular';
+import _ from 'lodash-es';
const basePath = 'http://portainer-ce.app';
@@ -131,7 +132,8 @@ function config($analyticsProvider, $windowProvider) {
let metadataString = '';
if (metadata) {
- metadataString = JSON.stringify(metadata).toLowerCase();
+ const kebabCasedMetadata = Object.fromEntries(Object.entries(metadata).map(([key, value]) => [_.kebabCase(key), value]));
+ metadataString = JSON.stringify(kebabCasedMetadata).toLowerCase();
}
push([
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html
index c135cca0d..916bcc1a7 100644
--- a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html
+++ b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html
@@ -199,6 +199,10 @@
ng-click="$ctrl.createStack()"
button-spinner="$ctrl.state.actionInProgress"
data-cy="edgeStackCreate-createStackButton"
+ analytics-on
+ analytics-event="edge-stack-creation"
+ analytics-category="edge"
+ analytics-properties="$ctrl.buildAnalyticsProperties()"
>
Deploy the stack
Deployment in progress...
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js
index bccfeddac..a9b229aa4 100644
--- a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js
+++ b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackViewController.js
@@ -43,6 +43,30 @@ export class CreateEdgeStackViewController {
this.onChangeFormValues = this.onChangeFormValues.bind(this);
}
+ buildAnalyticsProperties() {
+ const format = 'compose';
+ const metadata = { type: methodLabel(this.state.Method), format };
+
+ if (metadata.type === 'template') {
+ metadata.templateName = this.selectedTemplate.title;
+ }
+
+ return { metadata };
+
+ function methodLabel(method) {
+ switch (method) {
+ case 'editor':
+ return 'web-editor';
+ case 'repository':
+ return 'git';
+ case 'upload':
+ return 'file-upload';
+ case 'template':
+ return 'template';
+ }
+ }
+ }
+
async uiCanExit() {
if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) {
return this.ModalService.confirmWebEditorDiscard();
diff --git a/app/kubernetes/__module.js b/app/kubernetes/__module.js
index 2d34c1420..3e854bead 100644
--- a/app/kubernetes/__module.js
+++ b/app/kubernetes/__module.js
@@ -1,6 +1,7 @@
import registriesModule from './registries';
+import customTemplateModule from './custom-templates';
-angular.module('portainer.kubernetes', ['portainer.app', registriesModule]).config([
+angular.module('portainer.kubernetes', ['portainer.app', registriesModule, customTemplateModule]).config([
'$stateRegistryProvider',
function ($stateRegistryProvider) {
'use strict';
@@ -208,12 +209,15 @@ angular.module('portainer.kubernetes', ['portainer.app', registriesModule]).conf
const deploy = {
name: 'kubernetes.deploy',
- url: '/deploy',
+ url: '/deploy?templateId',
views: {
'content@': {
component: 'kubernetesDeployView',
},
},
+ params: {
+ templateId: '',
+ },
};
const resourcePools = {
diff --git a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html
index 029e58111..3a2517be6 100644
--- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html
+++ b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html
@@ -73,7 +73,10 @@
Remove
+
@@ -162,7 +165,7 @@
system
external
-
{{ item.StackName }} |
+
{{ item.StackName || '-' }} |
{{ item.ResourcePool }}
|
diff --git a/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatable.html b/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatable.html
index b6e050651..96a5cfc19 100644
--- a/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatable.html
+++ b/app/kubernetes/components/datatables/configurations-datatable/configurationsDatatable.html
@@ -66,8 +66,11 @@
>
Remove
-
diff --git a/app/kubernetes/components/datatables/integrated-applications-datatable/integratedApplicationsDatatable.html b/app/kubernetes/components/datatables/integrated-applications-datatable/integratedApplicationsDatatable.html
index 2e29e453d..caa2d5a5a 100644
--- a/app/kubernetes/components/datatables/integrated-applications-datatable/integratedApplicationsDatatable.html
+++ b/app/kubernetes/components/datatables/integrated-applications-datatable/integratedApplicationsDatatable.html
@@ -91,7 +91,7 @@
{{ item.Name }} |
-
{{ item.StackName }} |
+
{{ item.StackName || '-' }} |
{{ item.Image | truncate: 64 }} |
diff --git a/app/kubernetes/components/datatables/node-applications-datatable/nodeApplicationsDatatable.html b/app/kubernetes/components/datatables/node-applications-datatable/nodeApplicationsDatatable.html
index cb46d5de4..488ac5a6d 100644
--- a/app/kubernetes/components/datatables/node-applications-datatable/nodeApplicationsDatatable.html
+++ b/app/kubernetes/components/datatables/node-applications-datatable/nodeApplicationsDatatable.html
@@ -114,7 +114,7 @@
system
external
- {{ item.StackName }} |
+ {{ item.StackName || '-' }} |
{{ item.ResourcePool }}
|
diff --git a/app/kubernetes/components/datatables/resource-pool-applications-datatable/resourcePoolApplicationsDatatable.html b/app/kubernetes/components/datatables/resource-pool-applications-datatable/resourcePoolApplicationsDatatable.html
index 02ac0411e..bda29ee58 100644
--- a/app/kubernetes/components/datatables/resource-pool-applications-datatable/resourcePoolApplicationsDatatable.html
+++ b/app/kubernetes/components/datatables/resource-pool-applications-datatable/resourcePoolApplicationsDatatable.html
@@ -106,7 +106,7 @@
{{ item.Name }}
external
- {{ item.StackName }} |
+ {{ item.StackName || '-' }} |
{{ item.Image | truncate: 64 }} + {{ item.Containers.length - 1 }} |
diff --git a/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatable.html b/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatable.html
index 4002b169c..0b0d95060 100644
--- a/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatable.html
+++ b/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatable.html
@@ -60,8 +60,11 @@
>
Remove
-
- Add namespace
+
+ Add namespace with form
+
+
+ Create from manifest
@@ -92,6 +95,13 @@
+
+
+ Status
+
+
+
+ |
Quota
@@ -124,6 +134,9 @@
{{ item.Namespace.Name }}
system
+ |
+ {{ item.Namespace.Status }}
+ |
{{ item.Quota ? 'Yes' : 'No' }} |
{{ item.Namespace.CreationDate | getisodate }} {{ item.Namespace.ResourcePoolOwner ? 'by ' + item.Namespace.ResourcePoolOwner : '' }} |
diff --git a/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatableController.js b/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatableController.js
index c7f4e1896..46519a99d 100644
--- a/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatableController.js
+++ b/app/kubernetes/components/datatables/resource-pools-datatable/resourcePoolsDatatableController.js
@@ -38,6 +38,17 @@ angular.module('portainer.docker').controller('KubernetesResourcePoolsDatatableC
return !ctrl.isSystemNamespace(item) || (ctrl.settings.showSystem && ctrl.isAdmin);
};
+ this.namespaceStatusColor = function (status) {
+ switch (status.toLowerCase()) {
+ case 'active':
+ return 'success';
+ case 'terminating':
+ return 'danger';
+ default:
+ return 'primary';
+ }
+ };
+
/**
* Do not allow system namespaces to be selected
*/
diff --git a/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html b/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html
index b740429e9..1f67c26b5 100644
--- a/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html
+++ b/app/kubernetes/components/datatables/volumes-datatable/volumesDatatable.html
@@ -54,6 +54,9 @@
Remove
+
+ Create from manifest
+
diff --git a/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js b/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js
index d60ae0b36..2c9423c7e 100644
--- a/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js
+++ b/app/kubernetes/components/kubectl-shell/kubectl-shell.controller.js
@@ -19,6 +19,7 @@ export default class KubectlShellController {
this.state.shell.term.dispose();
this.state.shell.connected = false;
this.TerminalWindow.terminalclose();
+ this.$window.onresize = null;
}
screenClear() {
diff --git a/app/kubernetes/components/kubectl-shell/kubectl-shell.html b/app/kubernetes/components/kubectl-shell/kubectl-shell.html
index 76dae9afe..a17c86533 100644
--- a/app/kubernetes/components/kubectl-shell/kubectl-shell.html
+++ b/app/kubernetes/components/kubectl-shell/kubectl-shell.html
@@ -1,4 +1,13 @@
-
+
kubectl shell
diff --git a/app/kubernetes/components/kubernetes-sidebar/kubernetes-sidebar.html b/app/kubernetes/components/kubernetes-sidebar/kubernetes-sidebar.html
index 72be2b681..14444ae09 100644
--- a/app/kubernetes/components/kubernetes-sidebar/kubernetes-sidebar.html
+++ b/app/kubernetes/components/kubernetes-sidebar/kubernetes-sidebar.html
@@ -8,6 +8,16 @@
Dashboard
+
+ Custom Templates
+
+
{
+ const { method } = this.state;
+
+ if (!this.validateForm(method)) {
+ return;
+ }
+
+ this.state.actionInProgress = true;
+ try {
+ const customTemplate = await this.createCustomTemplateByMethod(method, this.formValues);
+
+ const accessControlData = this.formValues.AccessControlData;
+ const userDetails = this.Authentication.getUserDetails();
+ const userId = userDetails.ID;
+ await this.ResourceControlService.applyResourceControl(userId, accessControlData, customTemplate.ResourceControl);
+
+ this.Notifications.success('Custom template successfully created');
+ this.state.isEditorDirty = false;
+ this.$state.go('kubernetes.templates.custom');
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Failed creating custom template');
+ } finally {
+ this.state.actionInProgress = false;
+ }
+ });
+ }
+
+ createCustomTemplateByMethod(method, template) {
+ template.Type = 3;
+
+ switch (method) {
+ case 'editor':
+ return this.createCustomTemplateFromFileContent(template);
+ case 'upload':
+ return this.createCustomTemplateFromFileUpload(template);
+ }
+ }
+
+ createCustomTemplateFromFileContent(template) {
+ return this.CustomTemplateService.createCustomTemplateFromFileContent(template);
+ }
+
+ createCustomTemplateFromFileUpload(template) {
+ return this.CustomTemplateService.createCustomTemplateFromFileUpload(template);
+ }
+
+ validateForm(method) {
+ this.state.formValidationError = '';
+
+ if (method === 'editor' && this.formValues.FileContent === '') {
+ this.state.formValidationError = 'Template file content must not be empty';
+ return false;
+ }
+
+ const title = this.formValues.Title;
+ const isNotUnique = this.templates.some((template) => template.Title === title);
+ if (isNotUnique) {
+ this.state.formValidationError = 'A template with the same name already exists';
+ return false;
+ }
+
+ const isAdmin = this.Authentication.isAdmin();
+ const accessControlData = this.formValues.AccessControlData;
+ const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
+
+ if (error) {
+ this.state.formValidationError = error;
+ return false;
+ }
+
+ return true;
+ }
+
+ async $onInit() {
+ return this.$async(async () => {
+ const { fileContent, type } = this.$state.params;
+
+ this.formValues.FileContent = fileContent;
+ if (type) {
+ this.formValues.Type = +type;
+ }
+
+ try {
+ this.templates = await this.CustomTemplateService.customTemplates(3);
+ } catch (err) {
+ this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
+ }
+
+ this.state.loading = false;
+
+ window.addEventListener('beforeunload', this.onBeforeOnload);
+ });
+ }
+
+ $onDestroy() {
+ window.removeEventListener('beforeunload', this.onBeforeOnload);
+ }
+
+ isEditorDirty() {
+ return this.state.method === 'editor' && this.formValues.FileContent && this.state.isEditorDirty;
+ }
+
+ onBeforeOnload(event) {
+ if (this.isEditorDirty()) {
+ event.preventDefault();
+ event.returnValue = '';
+ }
+ }
+
+ uiCanExit() {
+ if (this.isEditorDirty()) {
+ return this.ModalService.confirmWebEditorDiscard();
+ }
+ }
+}
+
+export default KubeCreateCustomTemplateViewController;
diff --git a/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.html b/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.html
new file mode 100644
index 000000000..36bceed8e
--- /dev/null
+++ b/app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.html
@@ -0,0 +1,71 @@
+
+
+ Custom Templates > Create Custom template
+
+
+
diff --git a/app/kubernetes/custom-templates/kube-custom-templates-view/index.js b/app/kubernetes/custom-templates/kube-custom-templates-view/index.js
new file mode 100644
index 000000000..4c2d1115f
--- /dev/null
+++ b/app/kubernetes/custom-templates/kube-custom-templates-view/index.js
@@ -0,0 +1,6 @@
+import controller from './kube-custom-templates-view.controller.js';
+
+export const kubeCustomTemplatesView = {
+ templateUrl: './kube-custom-templates-view.html',
+ controller,
+};
diff --git a/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js b/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js
new file mode 100644
index 000000000..63f4c228a
--- /dev/null
+++ b/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js
@@ -0,0 +1,79 @@
+import _ from 'lodash-es';
+
+export default class KubeCustomTemplatesViewController {
+ /* @ngInject */
+ constructor($async, $state, Authentication, CustomTemplateService, FormValidator, ModalService, Notifications) {
+ Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, ModalService, Notifications });
+
+ this.state = {
+ selectedTemplate: null,
+ formValidationError: '',
+ actionInProgress: false,
+ };
+
+ this.currentUser = {
+ isAdmin: false,
+ id: null,
+ };
+
+ this.isEditAllowed = this.isEditAllowed.bind(this);
+ this.getTemplates = this.getTemplates.bind(this);
+ this.validateForm = this.validateForm.bind(this);
+ this.confirmDelete = this.confirmDelete.bind(this);
+ this.selectTemplate = this.selectTemplate.bind(this);
+ }
+
+ selectTemplate(template) {
+ this.$state.go('kubernetes.deploy', { templateId: template.Id });
+ }
+
+ isEditAllowed(template) {
+ // todo - check if current user is admin/endpointadmin/owner
+ return this.currentUser.isAdmin || this.currentUser.id === template.CreatedByUserId;
+ }
+
+ getTemplates() {
+ return this.$async(async () => {
+ try {
+ this.templates = await this.CustomTemplateService.customTemplates(3);
+ } catch (err) {
+ this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates');
+ }
+ });
+ }
+
+ validateForm(accessControlData, isAdmin) {
+ this.state.formValidationError = '';
+ const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
+
+ if (error) {
+ this.state.formValidationError = error;
+ return false;
+ }
+ return true;
+ }
+
+ confirmDelete(templateId) {
+ return this.$async(async () => {
+ const confirmed = await this.ModalService.confirmDeletionAsync('Are you sure that you want to delete this template?');
+ if (!confirmed) {
+ return;
+ }
+
+ try {
+ await this.CustomTemplateService.remove(templateId);
+ _.remove(this.templates, { Id: templateId });
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Failed to delete template');
+ }
+ });
+ }
+
+ $onInit() {
+ this.getTemplates();
+
+ this.currentUser.isAdmin = this.Authentication.isAdmin();
+ const user = this.Authentication.getUserDetails();
+ this.currentUser.id = user.ID;
+ }
+}
diff --git a/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.html b/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.html
new file mode 100644
index 000000000..75eae315a
--- /dev/null
+++ b/app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.html
@@ -0,0 +1,25 @@
+
+
+
+
+
+
+ Custom Templates
+
+
+
diff --git a/app/kubernetes/custom-templates/kube-edit-custom-template-view/index.js b/app/kubernetes/custom-templates/kube-edit-custom-template-view/index.js
new file mode 100644
index 000000000..8e143d9c5
--- /dev/null
+++ b/app/kubernetes/custom-templates/kube-edit-custom-template-view/index.js
@@ -0,0 +1,6 @@
+import controller from './kube-edit-custom-template-view.controller.js';
+
+export const kubeEditCustomTemplateView = {
+ templateUrl: './kube-edit-custom-template-view.html',
+ controller,
+};
diff --git a/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.controller.js b/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.controller.js
new file mode 100644
index 000000000..0dc04d1b6
--- /dev/null
+++ b/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.controller.js
@@ -0,0 +1,143 @@
+import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
+import { ResourceControlViewModel } from '@/portainer/models/resourceControl/resourceControl';
+
+class KubeEditCustomTemplateViewController {
+ /* @ngInject */
+ constructor($async, $state, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService) {
+ Object.assign(this, { $async, $state, ModalService, Authentication, CustomTemplateService, FormValidator, Notifications, ResourceControlService });
+
+ this.formValues = null;
+ this.state = {
+ formValidationError: '',
+ isEditorDirty: false,
+ };
+ this.templates = [];
+
+ this.getTemplate = this.getTemplate.bind(this);
+ this.submitAction = this.submitAction.bind(this);
+ this.onChangeFileContent = this.onChangeFileContent.bind(this);
+ this.onBeforeUnload = this.onBeforeUnload.bind(this);
+ }
+
+ getTemplate() {
+ return this.$async(async () => {
+ try {
+ const { id } = this.$state.params;
+
+ const [template, file] = await Promise.all([this.CustomTemplateService.customTemplate(id), this.CustomTemplateService.customTemplateFile(id)]);
+ template.FileContent = file;
+ this.formValues = template;
+ this.oldFileContent = this.formValues.FileContent;
+
+ this.formValues.ResourceControl = new ResourceControlViewModel(template.ResourceControl);
+ this.formValues.AccessControlData = new AccessControlFormData();
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Unable to retrieve custom template data');
+ }
+ });
+ }
+
+ validateForm() {
+ this.state.formValidationError = '';
+
+ if (!this.formValues.FileContent) {
+ this.state.formValidationError = 'Template file content must not be empty';
+ return false;
+ }
+
+ const title = this.formValues.Title;
+ const id = this.$state.params.id;
+
+ const isNotUnique = this.templates.some((template) => template.Title === title && template.Id != id);
+ if (isNotUnique) {
+ this.state.formValidationError = `A template with the name ${title} already exists`;
+ return false;
+ }
+
+ const isAdmin = this.Authentication.isAdmin();
+ const accessControlData = this.formValues.AccessControlData;
+ const error = this.FormValidator.validateAccessControl(accessControlData, isAdmin);
+
+ if (error) {
+ this.state.formValidationError = error;
+ return false;
+ }
+
+ return true;
+ }
+
+ submitAction() {
+ return this.$async(async () => {
+ if (!this.validateForm()) {
+ return;
+ }
+
+ this.actionInProgress = true;
+ try {
+ await this.CustomTemplateService.updateCustomTemplate(this.formValues.Id, this.formValues);
+
+ const userDetails = this.Authentication.getUserDetails();
+ const userId = userDetails.ID;
+ await this.ResourceControlService.applyResourceControl(userId, this.formValues.AccessControlData, this.formValues.ResourceControl);
+
+ this.Notifications.success('Custom template successfully updated');
+ this.state.isEditorDirty = false;
+ this.$state.go('kubernetes.templates.custom');
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Unable to update custom template');
+ } finally {
+ this.actionInProgress = false;
+ }
+ });
+ }
+
+ onChangeFileContent(value) {
+ if (stripSpaces(this.formValues.FileContent) !== stripSpaces(value)) {
+ this.formValues.FileContent = value;
+ this.state.isEditorDirty = true;
+ }
+ }
+
+ async $onInit() {
+ this.$async(async () => {
+ this.getTemplate();
+
+ try {
+ this.templates = await this.CustomTemplateService.customTemplates();
+ } catch (err) {
+ this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
+ }
+
+ window.addEventListener('beforeunload', this.onBeforeUnload);
+ });
+ }
+
+ isEditorDirty() {
+ return this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty;
+ }
+
+ uiCanExit() {
+ if (this.isEditorDirty()) {
+ return this.ModalService.confirmWebEditorDiscard();
+ }
+ }
+
+ onBeforeUnload(event) {
+ if (this.formValues.FileContent !== this.oldFileContent && this.state.isEditorDirty) {
+ event.preventDefault();
+ event.returnValue = '';
+
+ return '';
+ }
+ }
+
+ $onDestroy() {
+ window.removeEventListener('beforeunload', this.onBeforeUnload);
+ }
+}
+
+export default KubeEditCustomTemplateViewController;
+
+function stripSpaces(str = '') {
+ return str.replace(/(\r\n|\n|\r)/gm, '');
+}
diff --git a/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.html b/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.html
new file mode 100644
index 000000000..0d3c15179
--- /dev/null
+++ b/app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.html
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+ Custom templates > {{ $ctrl.formValues.Title }}
+
+
+
diff --git a/app/kubernetes/helpers/stackHelper.js b/app/kubernetes/helpers/stackHelper.js
index 73955862b..c539caf89 100644
--- a/app/kubernetes/helpers/stackHelper.js
+++ b/app/kubernetes/helpers/stackHelper.js
@@ -6,7 +6,7 @@ class KubernetesStackHelper {
const res = _.reduce(
applications,
(acc, app) => {
- if (app.StackName !== '-') {
+ if (app.StackName) {
let stack = _.find(acc, { Name: app.StackName, ResourcePool: app.ResourcePool });
if (!stack) {
stack = new KubernetesStack();
diff --git a/app/kubernetes/models/deploy.js b/app/kubernetes/models/deploy.js
index 34fe609fe..e3405be63 100644
--- a/app/kubernetes/models/deploy.js
+++ b/app/kubernetes/models/deploy.js
@@ -6,9 +6,12 @@ export const KubernetesDeployManifestTypes = Object.freeze({
export const KubernetesDeployBuildMethods = Object.freeze({
GIT: 1,
WEB_EDITOR: 2,
+ CUSTOM_TEMPLATE: 3,
+ URL: 4,
});
export const KubernetesDeployRequestMethods = Object.freeze({
REPOSITORY: 'repository',
STRING: 'string',
+ URL: 'url',
});
diff --git a/app/kubernetes/services/namespaceService.js b/app/kubernetes/services/namespaceService.js
index fa26d5442..df8670b0f 100644
--- a/app/kubernetes/services/namespaceService.js
+++ b/app/kubernetes/services/namespaceService.js
@@ -41,14 +41,11 @@ class KubernetesNamespaceService {
const data = await this.KubernetesNamespaces().get().$promise;
const promises = _.map(data.items, (item) => this.KubernetesNamespaces().status({ id: item.metadata.name }).$promise);
const namespaces = await $allSettled(promises);
- const visibleNamespaces = _.map(namespaces.fulfilled, (item) => {
- if (item.status.phase !== 'Terminating') {
- return KubernetesNamespaceConverter.apiToNamespace(item);
- }
+ const allNamespaces = _.map(namespaces.fulfilled, (item) => {
+ return KubernetesNamespaceConverter.apiToNamespace(item);
});
- const res = _.without(visibleNamespaces, undefined);
- updateNamespaces(res);
- return res;
+ updateNamespaces(allNamespaces);
+ return allNamespaces;
} catch (err) {
throw new PortainerError('Unable to retrieve namespaces', err);
}
diff --git a/app/kubernetes/templates/advancedDeploymentPanel.html b/app/kubernetes/templates/advancedDeploymentPanel.html
deleted file mode 100644
index 6c06e1bf7..000000000
--- a/app/kubernetes/templates/advancedDeploymentPanel.html
+++ /dev/null
@@ -1,14 +0,0 @@
-
-
-
-
- Advanced deployment allows you to deploy any Kubernetes manifest inside your cluster.
-
-
-
-
- Advanced deployment
-
-
-
-
diff --git a/app/kubernetes/views/applications/applications.html b/app/kubernetes/views/applications/applications.html
index ec7916000..9308da6d4 100644
--- a/app/kubernetes/views/applications/applications.html
+++ b/app/kubernetes/views/applications/applications.html
@@ -5,8 +5,6 @@
-
-
diff --git a/app/kubernetes/views/applications/applicationsController.js b/app/kubernetes/views/applications/applicationsController.js
index 3d3d34556..a3138e524 100644
--- a/app/kubernetes/views/applications/applicationsController.js
+++ b/app/kubernetes/views/applications/applicationsController.js
@@ -1,5 +1,3 @@
-require('../../templates/advancedDeploymentPanel.html');
-
import angular from 'angular';
import _ from 'lodash-es';
import KubernetesStackHelper from 'Kubernetes/helpers/stackHelper';
diff --git a/app/kubernetes/views/applications/edit/application.html b/app/kubernetes/views/applications/edit/application.html
index 9a28c64c5..5ea71a2e1 100644
--- a/app/kubernetes/views/applications/edit/application.html
+++ b/app/kubernetes/views/applications/edit/application.html
@@ -26,7 +26,7 @@
|
Stack |
- {{ ctrl.application.StackName }} |
+ {{ ctrl.application.StackName || '-' }} |
Namespace |
@@ -194,21 +194,15 @@
-
-
+
+
Edit this application
Redeploy
@@ -217,12 +211,19 @@
ng-if="!ctrl.isExternalApplication()"
type="button"
class="btn btn-sm btn-primary"
- style="margin-left: 0; margin-bottom: 15px;"
+ style="margin-left: 0;"
ng-click="ctrl.rollbackApplication()"
ng-disabled="ctrl.application.Revisions.length < 2 || ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM"
>
Rollback to previous configuration
+
+ Create template from application
+
diff --git a/app/kubernetes/views/applications/edit/applicationController.js b/app/kubernetes/views/applications/edit/applicationController.js
index 78c603482..935de40aa 100644
--- a/app/kubernetes/views/applications/edit/applicationController.js
+++ b/app/kubernetes/views/applications/edit/applicationController.js
@@ -112,7 +112,8 @@ class KubernetesApplicationController {
KubernetesStackService,
KubernetesPodService,
KubernetesNodeService,
- EndpointProvider
+ EndpointProvider,
+ StackService
) {
this.$async = $async;
this.$state = $state;
@@ -120,6 +121,7 @@ class KubernetesApplicationController {
this.Notifications = Notifications;
this.LocalStorage = LocalStorage;
this.ModalService = ModalService;
+ this.StackService = StackService;
this.KubernetesApplicationService = KubernetesApplicationService;
this.KubernetesEventService = KubernetesEventService;
@@ -200,6 +202,10 @@ class KubernetesApplicationController {
return !rule.Host && !rule.IP ? false : true;
}
+ isStack() {
+ return this.application.StackId;
+ }
+
/**
* ROLLBACK
*/
@@ -323,6 +329,11 @@ class KubernetesApplicationController {
this.placements = computePlacements(nodes, this.application);
this.state.placementWarning = _.find(this.placements, { AcceptsApplication: true }) ? false : true;
+
+ if (application.StackId) {
+ const file = await this.StackService.getStackFile(application.StackId);
+ this.stackFileContent = file;
+ }
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve application details');
} finally {
diff --git a/app/kubernetes/views/configurations/configurations.html b/app/kubernetes/views/configurations/configurations.html
index 4e53089ef..8e55b4fc8 100644
--- a/app/kubernetes/views/configurations/configurations.html
+++ b/app/kubernetes/views/configurations/configurations.html
@@ -5,8 +5,6 @@
-
-
Save configuration
Saving configuration...
diff --git a/app/kubernetes/views/configure/configureController.js b/app/kubernetes/views/configure/configureController.js
index 9213ae4e4..8e41a3848 100644
--- a/app/kubernetes/views/configure/configureController.js
+++ b/app/kubernetes/views/configure/configureController.js
@@ -237,6 +237,10 @@ class KubernetesConfigureController {
}
/* #endregion */
+ restrictDefaultToggledOn() {
+ return this.formValues.RestrictDefaultNamespace && !this.oldFormValues.RestrictDefaultNamespace;
+ }
+
/* #region ON INIT */
async onInit() {
this.state = {
@@ -287,6 +291,8 @@ class KubernetesConfigureController {
ic.NeedsDeletion = false;
return ic;
});
+
+ this.oldFormValues = Object.assign({}, this.formValues);
} catch (err) {
this.Notifications.error('Failure', err, 'Unable to retrieve endpoint configuration');
} finally {
diff --git a/app/kubernetes/views/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html
index eb2ca19d7..fb9d950be 100644
--- a/app/kubernetes/views/deploy/deploy.html
+++ b/app/kubernetes/views/deploy/deploy.html
@@ -23,16 +23,18 @@
-
- Deployment type
-
-
-
Build method
+
+
+ Deployment type
+
+
+
+
+
+
+
+
+
+
+ URL
+
+
+
+ Indicate the URL to the manifest.
+
+
+
+
+
+
Actions
@@ -98,6 +135,10 @@
ng-click="ctrl.deploy()"
button-spinner="ctrl.state.actionInProgress"
data-cy="k8sAppDeploy-deployButton"
+ analytics-on
+ analytics-category="kubernetes"
+ analytics-event="kubernetes-application-advanced-deployment"
+ analytics-properties="ctrl.buildAnalyticsProperties()"
>
Deploy
Deployment in progress...
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js
index 6c2042f9a..898a482e9 100644
--- a/app/kubernetes/views/deploy/deployController.js
+++ b/app/kubernetes/views/deploy/deployController.js
@@ -2,6 +2,8 @@ import angular from 'angular';
import _ from 'lodash-es';
import stripAnsi from 'strip-ansi';
import uuidv4 from 'uuid/v4';
+import PortainerError from 'Portainer/error';
+
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods } from 'Kubernetes/models/deploy';
import { buildOption } from '@/portainer/components/box-selector';
class KubernetesDeployController {
@@ -20,12 +22,14 @@ class KubernetesDeployController {
this.deployOptions = [
buildOption('method_kubernetes', 'fa fa-cubes', 'Kubernetes', 'Kubernetes manifest format', KubernetesDeployManifestTypes.KUBERNETES),
- buildOption('method_compose', 'fa fa-docker', 'Compose', 'docker-compose format', KubernetesDeployManifestTypes.COMPOSE),
+ buildOption('method_compose', 'fab fa-docker', 'Compose', 'docker-compose format', KubernetesDeployManifestTypes.COMPOSE),
];
this.methodOptions = [
buildOption('method_repo', 'fab fa-github', 'Git Repository', 'Use a git repository', KubernetesDeployBuildMethods.GIT),
buildOption('method_editor', 'fa fa-edit', 'Web editor', 'Use our Web editor', KubernetesDeployBuildMethods.WEB_EDITOR),
+ buildOption('method_url', 'fa fa-globe', 'URL', 'Specify a URL to a file', KubernetesDeployBuildMethods.URL),
+ buildOption('method_template', 'fa fa-rocket', 'Custom Template', 'Use a custom template', KubernetesDeployBuildMethods.CUSTOM_TEMPLATE),
];
this.state = {
@@ -35,6 +39,7 @@ class KubernetesDeployController {
activeTab: 0,
viewReady: false,
isEditorDirty: false,
+ templateId: null,
};
this.formValues = {
@@ -54,20 +59,64 @@ class KubernetesDeployController {
this.BuildMethods = KubernetesDeployBuildMethods;
this.endpointId = this.EndpointProvider.endpointID();
- this.onInit = this.onInit.bind(this);
+ this.onChangeTemplateId = this.onChangeTemplateId.bind(this);
this.deployAsync = this.deployAsync.bind(this);
this.onChangeFileContent = this.onChangeFileContent.bind(this);
this.getNamespacesAsync = this.getNamespacesAsync.bind(this);
this.onChangeFormValues = this.onChangeFormValues.bind(this);
+ this.buildAnalyticsProperties = this.buildAnalyticsProperties.bind(this);
+ }
+
+ buildAnalyticsProperties() {
+ const metadata = {
+ type: buildLabel(this.state.BuildMethod),
+ format: formatLabel(this.state.DeployType),
+ role: roleLabel(this.Authentication.isAdmin()),
+ };
+
+ if (this.state.BuildMethod === KubernetesDeployBuildMethods.GIT) {
+ metadata.auth = this.formValues.RepositoryAuthentication;
+ }
+
+ return { metadata };
+
+ function roleLabel(isAdmin) {
+ if (isAdmin) {
+ return 'admin';
+ }
+
+ return 'standard';
+ }
+
+ function buildLabel(buildMethod) {
+ switch (buildMethod) {
+ case KubernetesDeployBuildMethods.GIT:
+ return 'git';
+ case KubernetesDeployBuildMethods.WEB_EDITOR:
+ return 'web-editor';
+ }
+ }
+
+ function formatLabel(format) {
+ switch (format) {
+ case KubernetesDeployManifestTypes.COMPOSE:
+ return 'compose';
+ case KubernetesDeployManifestTypes.KUBERNETES:
+ return 'manifest';
+ }
+ }
}
disableDeploy() {
const isGitFormInvalid =
this.state.BuildMethod === KubernetesDeployBuildMethods.GIT &&
- (!this.formValues.RepositoryURL || !this.formValues.FilePathInRepository || (this.formValues.RepositoryAuthentication && !this.formValues.RepositoryPassword));
- const isWebEditorInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent);
+ (!this.formValues.RepositoryURL || !this.formValues.FilePathInRepository || (this.formValues.RepositoryAuthentication && !this.formValues.RepositoryPassword)) &&
+ _.isEmpty(this.formValues.Namespace);
+ const isWebEditorInvalid =
+ this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent) && _.isEmpty(this.formValues.Namespace);
+ const isURLFormInvalid = this.state.BuildMethod == KubernetesDeployBuildMethods.WEB_EDITOR.URL && _.isEmpty(this.formValues.ManifestURL);
- return isGitFormInvalid || isWebEditorInvalid || _.isEmpty(this.formValues.Namespace) || this.state.actionInProgress;
+ return isGitFormInvalid || isWebEditorInvalid || isURLFormInvalid || this.state.actionInProgress;
}
onChangeFormValues(values) {
@@ -77,6 +126,23 @@ class KubernetesDeployController {
};
}
+ onChangeTemplateId(templateId) {
+ return this.$async(async () => {
+ if (this.state.templateId === templateId) {
+ return;
+ }
+
+ this.state.templateId = templateId;
+
+ try {
+ const fileContent = await this.CustomTemplateService.customTemplateFile(templateId);
+ this.onChangeFileContent(fileContent);
+ } catch (err) {
+ this.Notifications.error('Failure', err, 'Unable to load template file');
+ }
+ });
+ }
+
onChangeFileContent(value) {
this.formValues.EditorContent = value;
this.state.isEditorDirty = true;
@@ -93,20 +159,33 @@ class KubernetesDeployController {
this.state.actionInProgress = true;
try {
- //Analytics
- const metadata = {
- format: this.state.DeployType === this.ManifestDeployTypes.COMPOSE ? 'compose' : 'manifest',
- };
+ let method;
+ let composeFormat = this.state.DeployType === this.ManifestDeployTypes.COMPOSE;
- const method = this.state.BuildMethod === this.BuildMethods.GIT ? KubernetesDeployRequestMethods.REPOSITORY : KubernetesDeployRequestMethods.STRING;
+ switch (this.state.BuildMethod) {
+ case this.BuildMethods.GIT:
+ method = KubernetesDeployRequestMethods.REPOSITORY;
+ break;
+ case this.BuildMethods.WEB_EDITOR:
+ method = KubernetesDeployRequestMethods.STRING;
+ break;
+ case KubernetesDeployBuildMethods.CUSTOM_TEMPLATE:
+ method = KubernetesDeployRequestMethods.STRING;
+ composeFormat = false;
+ break;
+ case this.BuildMethods.URL:
+ method = KubernetesDeployRequestMethods.URL;
+ break;
+ default:
+ throw new PortainerError('Unable to determine build method');
+ }
const payload = {
- ComposeFormat: this.state.DeployType === this.ManifestDeployTypes.COMPOSE,
+ ComposeFormat: composeFormat,
Namespace: this.formValues.Namespace,
};
if (method === KubernetesDeployRequestMethods.REPOSITORY) {
- metadata.type = 'git';
payload.RepositoryURL = this.formValues.RepositoryURL;
payload.RepositoryReferenceName = this.formValues.RepositoryReferenceName;
payload.RepositoryAuthentication = this.formValues.RepositoryAuthentication ? true : false;
@@ -120,20 +199,16 @@ class KubernetesDeployController {
payload.AutoUpdate = {};
if (this.formValues.RepositoryMechanism === `Interval`) {
payload.AutoUpdate.Interval = this.formValues.RepositoryFetchInterval;
- metadata['automatic-updates'] = 'polling';
} else if (this.formValues.RepositoryMechanism === `Webhook`) {
payload.AutoUpdate.Webhook = this.formValues.RepositoryWebhookURL.split('/').reverse()[0];
- metadata['automatic-updates'] = 'webhook';
}
- } else {
- metadata['automatic-updates'] = 'off';
}
- } else {
- metadata.type = 'web-editor';
+ } else if (method === KubernetesDeployRequestMethods.STRING) {
payload.StackFileContent = this.formValues.EditorContent;
+ } else {
+ payload.ManifestURL = this.formValues.ManifestURL;
}
- this.$analytics.eventTrack('kubernetes-application-advanced-deployment', { category: 'kubernetes', metadata: metadata });
await this.StackService.kubernetesDeploy(this.endpointId, method, payload);
this.Notifications.success('Manifest successfully deployed');
@@ -180,33 +255,27 @@ class KubernetesDeployController {
return this.ModalService.confirmWebEditorDiscard();
}
}
- async onInit() {
- this.state = {
- DeployType: KubernetesDeployManifestTypes.KUBERNETES,
- BuildMethod: KubernetesDeployBuildMethods.GIT,
- tabLogsDisabled: true,
- activeTab: 0,
- viewReady: false,
- isEditorDirty: false,
- };
-
- this.ManifestDeployTypes = KubernetesDeployManifestTypes;
- this.BuildMethods = KubernetesDeployBuildMethods;
- this.endpointId = this.EndpointProvider.endpointID();
-
- await this.getNamespaces();
-
- this.state.viewReady = true;
-
- this.$window.onbeforeunload = () => {
- if (this.formValues.EditorContent && this.state.isEditorDirty) {
- return '';
- }
- };
- }
$onInit() {
- return this.$async(this.onInit);
+ return this.$async(async () => {
+ await this.getNamespaces();
+
+ if (this.$state.params.templateId) {
+ const templateId = parseInt(this.$state.params.templateId, 10);
+ if (templateId && !Number.isNaN(templateId)) {
+ this.state.BuildMethod = KubernetesDeployBuildMethods.CUSTOM_TEMPLATE;
+ this.onChangeTemplateId(templateId);
+ }
+ }
+
+ this.state.viewReady = true;
+
+ this.$window.onbeforeunload = () => {
+ if (this.formValues.EditorContent && this.state.isEditorDirty) {
+ return '';
+ }
+ };
+ });
}
$onDestroy() {
diff --git a/app/kubernetes/views/resource-pools/edit/resourcePool.html b/app/kubernetes/views/resource-pools/edit/resourcePool.html
index a4c19c130..90b33db11 100644
--- a/app/kubernetes/views/resource-pools/edit/resourcePool.html
+++ b/app/kubernetes/views/resource-pools/edit/resourcePool.html
@@ -317,16 +317,16 @@
Registries
-