From e4fe4f9a4323c7f701fbefd1e6adffb0fdb8b627 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 2 Sep 2021 08:28:51 +0300 Subject: [PATCH] feat(kube): introduce custom templates [EE-1125] (#5434) * feat(kube): introduce custom templates refactor(customtemplates): use build option chore(deps): upgrade yaml parser feat(customtemplates): add and edit RC to kube templates fix(kube): show docker icon fix(custom-templates): save rc * fix(kube/templates): route to correct routes --- api/exec/kubernetes_deploy.go | 15 +- api/go.mod | 2 +- api/go.sum | 3 +- .../customtemplates/customtemplate_create.go | 36 +- .../customtemplates/customtemplate_list.go | 55 ++ .../customtemplates/customtemplate_update.go | 11 +- .../handler/stacks/create_kubernetes_stack.go | 37 +- api/kubernetes/yaml.go | 112 ++++ api/kubernetes/yaml_test.go | 493 ++++++++++++++++++ api/portainer.go | 2 +- app/kubernetes/__module.js | 8 +- .../applicationsDatatable.html | 2 +- .../integratedApplicationsDatatable.html | 2 +- .../nodeApplicationsDatatable.html | 2 +- .../resourcePoolApplicationsDatatable.html | 2 +- .../kubernetes-sidebar.html | 10 + app/kubernetes/converters/application.js | 13 +- app/kubernetes/custom-templates/index.js | 61 +++ .../kube-create-custom-template-view/index.js | 6 + ...-create-custom-template-view.controller.js | 169 ++++++ .../kube-create-custom-template-view.html | 71 +++ .../kube-custom-templates-view/index.js | 6 + .../kube-custom-templates-view.controller.js | 79 +++ .../kube-custom-templates-view.html | 25 + .../kube-edit-custom-template-view/index.js | 6 + ...be-edit-custom-template-view.controller.js | 143 +++++ .../kube-edit-custom-template-view.html | 60 +++ app/kubernetes/helpers/stackHelper.js | 2 +- .../models/application/models/constants.js | 1 + app/kubernetes/models/deploy.js | 1 + .../views/applications/edit/application.html | 23 +- .../edit/applicationController.js | 13 +- app/kubernetes/views/deploy/deploy.html | 22 +- .../views/deploy/deployController.js | 71 ++- .../customTemplateCommonFields.html | 4 +- .../custom-template-common-fields/index.js | 2 + .../customTemplatesList.html | 8 +- .../customTemplatesListController.js | 25 +- .../components/custom-templates-list/index.js | 2 + .../file-upload-form/file-upload-form.html | 19 + .../form-components/file-upload-form/index.js | 13 + .../components/form-components/index.js | 3 +- .../template-item/templateItem.html | 5 +- .../createCustomTemplateView.html | 2 +- .../createCustomTemplateViewController.js | 2 +- .../customTemplatesView.html | 1 + .../customTemplatesViewController.js | 15 +- .../editCustomTemplateView.html | 2 +- .../editCustomTemplateViewController.js | 2 +- 49 files changed, 1562 insertions(+), 107 deletions(-) create mode 100644 api/kubernetes/yaml.go create mode 100644 api/kubernetes/yaml_test.go create mode 100644 app/kubernetes/custom-templates/index.js create mode 100644 app/kubernetes/custom-templates/kube-create-custom-template-view/index.js create mode 100644 app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.controller.js create mode 100644 app/kubernetes/custom-templates/kube-create-custom-template-view/kube-create-custom-template-view.html create mode 100644 app/kubernetes/custom-templates/kube-custom-templates-view/index.js create mode 100644 app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.controller.js create mode 100644 app/kubernetes/custom-templates/kube-custom-templates-view/kube-custom-templates-view.html create mode 100644 app/kubernetes/custom-templates/kube-edit-custom-template-view/index.js create mode 100644 app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.controller.js create mode 100644 app/kubernetes/custom-templates/kube-edit-custom-template-view/kube-edit-custom-template-view.html create mode 100644 app/portainer/components/form-components/file-upload-form/file-upload-form.html create mode 100644 app/portainer/components/form-components/file-upload-form/index.js diff --git a/api/exec/kubernetes_deploy.go b/api/exec/kubernetes_deploy.go index 13ae7faab..46a2622d0 100644 --- a/api/exec/kubernetes_deploy.go +++ b/api/exec/kubernetes_deploy.go @@ -5,9 +5,6 @@ import ( "encoding/json" "errors" "fmt" - "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" - "github.com/portainer/portainer/api/http/security" - "github.com/portainer/portainer/api/kubernetes/cli" "io/ioutil" "net/http" "net/url" @@ -17,6 +14,10 @@ import ( "strings" "time" + "github.com/portainer/portainer/api/http/proxy/factory/kubernetes" + "github.com/portainer/portainer/api/http/security" + "github.com/portainer/portainer/api/kubernetes/cli" + portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/crypto" ) @@ -80,7 +81,7 @@ func (deployer *KubernetesDeployer) getToken(request *http.Request, endpoint *po // Otherwise it will use kubectl to deploy the manifest. func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, namespace string) (string, error) { if endpoint.Type == portainer.KubernetesLocalEnvironment { - token, err := deployer.getToken(request, endpoint, true); + token, err := deployer.getToken(request, endpoint, true) if err != nil { return "", err } @@ -179,7 +180,7 @@ func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *port return "", err } - token, err := deployer.getToken(request, endpoint, false); + token, err := deployer.getToken(request, endpoint, false) if err != nil { return "", err } @@ -229,7 +230,7 @@ func (deployer *KubernetesDeployer) Deploy(request *http.Request, endpoint *port } // ConvertCompose leverages the kompose binary to deploy a compose compliant manifest. -func (deployer *KubernetesDeployer) ConvertCompose(data string) ([]byte, error) { +func (deployer *KubernetesDeployer) ConvertCompose(data []byte) ([]byte, error) { command := path.Join(deployer.binaryPath, "kompose") if runtime.GOOS == "windows" { command = path.Join(deployer.binaryPath, "kompose.exe") @@ -241,7 +242,7 @@ func (deployer *KubernetesDeployer) ConvertCompose(data string) ([]byte, error) var stderr bytes.Buffer cmd := exec.Command(command, args...) cmd.Stderr = &stderr - cmd.Stdin = strings.NewReader(data) + cmd.Stdin = bytes.NewReader(data) output, err := cmd.Output() if err != nil { diff --git a/api/go.mod b/api/go.mod index f0e916dcb..8273e9271 100644 --- a/api/go.mod +++ b/api/go.mod @@ -37,7 +37,7 @@ require ( golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 gopkg.in/alecthomas/kingpin.v2 v2.2.6 - gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b k8s.io/api v0.17.2 k8s.io/apimachinery v0.17.2 k8s.io/client-go v0.17.2 diff --git a/api/go.sum b/api/go.sum index 8906e08cf..47f69d704 100644 --- a/api/go.sum +++ b/api/go.sum @@ -387,8 +387,9 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 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_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go index 4de14d3a3..56d5e0278 100644 --- a/api/http/handler/stacks/create_kubernetes_stack.go +++ b/api/http/handler/stacks/create_kubernetes_stack.go @@ -1,7 +1,6 @@ package stacks import ( - "errors" "io/ioutil" "net/http" "path/filepath" @@ -9,12 +8,14 @@ import ( "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" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/filesystem" + k "github.com/portainer/portainer/api/kubernetes" ) const defaultReferenceName = "refs/heads/master" @@ -95,7 +96,12 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit doCleanUp := true defer handler.cleanUp(stack, &doCleanUp) - output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace) + output, err := handler.deployKubernetesStack(r, endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{ + StackID: stackID, + Name: stack.Name, + Owner: stack.CreatedBy, + Kind: "content", + }) if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} } @@ -109,6 +115,8 @@ func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWrit Output: output, } + doCleanUp = false + return response.JSON(w, resp) } @@ -139,7 +147,12 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err} } - output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace) + output, err := handler.deployKubernetesStack(r, endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace, k.KubeAppLabels{ + StackID: stackID, + Name: stack.Name, + Owner: stack.CreatedBy, + Kind: "git", + }) if err != nil { return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err} } @@ -152,23 +165,31 @@ func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWr resp := &createKubernetesStackResponse{ Output: output, } + + doCleanUp = false + return response.JSON(w, resp) } -func (handler *Handler) deployKubernetesStack(request *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string) (string, error) { +func (handler *Handler) deployKubernetesStack(r *http.Request, endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string, appLabels k.KubeAppLabels) (string, error) { handler.stackCreationMutex.Lock() defer handler.stackCreationMutex.Unlock() + manifest := []byte(stackConfig) if composeFormat { - convertedConfig, err := handler.KubernetesDeployer.ConvertCompose(stackConfig) + convertedConfig, err := handler.KubernetesDeployer.ConvertCompose(manifest) if err != nil { - return "", err + return "", errors.Wrap(err, "failed to convert docker compose file to a kube manifest") } - stackConfig = string(convertedConfig) + manifest = convertedConfig } - return handler.KubernetesDeployer.Deploy(request, endpoint, stackConfig, namespace) + manifest, err := k.AddAppLabels(manifest, appLabels) + if err != nil { + return "", errors.Wrap(err, "failed to add application labels") + } + return handler.KubernetesDeployer.Deploy(r, endpoint, string(manifest), namespace) } func (handler *Handler) cloneManifestContentFromGitRepo(gitInfo *kubernetesGitDeploymentPayload, projectPath string) (string, error) { diff --git a/api/kubernetes/yaml.go b/api/kubernetes/yaml.go new file mode 100644 index 000000000..b8e7b1b68 --- /dev/null +++ b/api/kubernetes/yaml.go @@ -0,0 +1,112 @@ +package kubernetes + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" + + "github.com/pkg/errors" + "gopkg.in/yaml.v3" +) + +type KubeAppLabels struct { + StackID int + Name string + Owner string + Kind string +} + +// AddAppLabels adds required labels to "Resource"->metadata->labels. +// It'll add those labels to all Resource (nodes with a kind property exluding a list) it can find in provided yaml. +// Items in the yaml file could either be organised as a list or broken into multi documents. +func AddAppLabels(manifestYaml []byte, appLabels KubeAppLabels) ([]byte, error) { + if bytes.Equal(manifestYaml, []byte("")) { + return manifestYaml, nil + } + + docs := make([][]byte, 0) + yamlDecoder := yaml.NewDecoder(bytes.NewReader(manifestYaml)) + + for { + m := make(map[string]interface{}) + err := yamlDecoder.Decode(&m) + + // if decoded document is empty + if m == nil { + continue + } + + // if there are no more documents in the file + if errors.Is(err, io.EOF) { + break + } + + addResourceLabels(m, appLabels) + + var out bytes.Buffer + yamlEncoder := yaml.NewEncoder(&out) + yamlEncoder.SetIndent(2) + if err := yamlEncoder.Encode(m); err != nil { + return nil, errors.Wrap(err, "failed to marshal yaml manifest") + } + + docs = append(docs, out.Bytes()) + } + + return bytes.Join(docs, []byte("---\n")), nil +} + +func addResourceLabels(yamlDoc interface{}, appLabels KubeAppLabels) { + m, ok := yamlDoc.(map[string]interface{}) + if !ok { + return + } + + kind, ok := m["kind"] + if ok && !strings.EqualFold(kind.(string), "list") { + addLabels(m, appLabels) + return + } + + for _, v := range m { + switch v.(type) { + case map[string]interface{}: + addResourceLabels(v, appLabels) + case []interface{}: + for _, item := range v.([]interface{}) { + addResourceLabels(item, appLabels) + } + } + } +} + +func addLabels(obj map[string]interface{}, appLabels KubeAppLabels) { + metadata := make(map[string]interface{}) + if m, ok := obj["metadata"]; ok { + metadata = m.(map[string]interface{}) + } + + labels := make(map[string]string) + if l, ok := metadata["labels"]; ok { + for k, v := range l.(map[string]interface{}) { + labels[k] = fmt.Sprintf("%v", v) + } + } + + name := appLabels.Name + if appLabels.Name == "" { + if n, ok := metadata["name"]; ok { + name = n.(string) + } + } + + labels["io.portainer.kubernetes.application.stackid"] = strconv.Itoa(appLabels.StackID) + labels["io.portainer.kubernetes.application.name"] = name + labels["io.portainer.kubernetes.application.owner"] = appLabels.Owner + labels["io.portainer.kubernetes.application.kind"] = appLabels.Kind + + metadata["labels"] = labels + obj["metadata"] = metadata +} diff --git a/api/kubernetes/yaml_test.go b/api/kubernetes/yaml_test.go new file mode 100644 index 000000000..5172357f4 --- /dev/null +++ b/api/kubernetes/yaml_test.go @@ -0,0 +1,493 @@ +package kubernetes + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_AddAppLabels(t *testing.T) { + tests := []struct { + name string + input string + wantOutput string + }{ + { + name: "single deployment without labels", + input: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + wantOutput: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + }, + { + name: "single deployment with existing labels", + input: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + wantOutput: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + }, + { + name: "complex kompose output", + input: `apiVersion: v1 +items: + - apiVersion: v1 + kind: Service + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + name: web + spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web + status: + loadBalancer: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + name: redis + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: redis + strategy: {} + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + status: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + name: web + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: web + strategy: + type: Recreate + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + status: {} +kind: List +metadata: {} +`, + wantOutput: `apiVersion: v1 +items: + - apiVersion: v1 + kind: Service + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: web + spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web + status: + loadBalancer: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: redis + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: redis + strategy: {} + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: redis + status: {} + - apiVersion: apps/v1 + kind: Deployment + metadata: + creationTimestamp: null + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: web + spec: + replicas: 1 + selector: + matchLabels: + io.kompose.service: web + strategy: + type: Recreate + template: + metadata: + creationTimestamp: null + labels: + io.kompose.service: web + status: {} +kind: List +metadata: {} +`, + }, + { + name: "multiple items separated by ---", + input: `apiVersion: apps/v1 +kind: Deployment +metadata: + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +--- +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + wantOutput: `apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +--- +apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + foo: bar + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: busybox +spec: + replicas: 3 + selector: + matchLabels: + app: busybox + template: + metadata: + labels: + app: busybox + spec: + containers: + - image: busybox + name: busybox +`, + }, + { + name: "empty", + input: "", + wantOutput: "", + }, + { + name: "no only deployments", + input: `apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +`, + wantOutput: `apiVersion: v1 +kind: Service +metadata: + creationTimestamp: null + labels: + io.kompose.service: web + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: best-name + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 + selector: + io.kompose.service: web +`, + }, + } + + labels := KubeAppLabels{ + StackID: 123, + Name: "best-name", + Owner: "best-owner", + Kind: "git", + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := AddAppLabels([]byte(tt.input), labels) + assert.NoError(t, err) + assert.Equal(t, tt.wantOutput, string(result)) + }) + } +} + +func Test_AddAppLabels_PickingName_WhenLabelNameIsEmpty(t *testing.T) { + labels := KubeAppLabels{ + StackID: 123, + Owner: "best-owner", + Kind: "git", + } + + input := `apiVersion: v1 +kind: Service +metadata: + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 +` + + expected := `apiVersion: v1 +kind: Service +metadata: + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: web + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" + name: web +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 +` + + result, err := AddAppLabels([]byte(input), labels) + assert.NoError(t, err) + assert.Equal(t, expected, string(result)) +} + +func Test_AddAppLabels_PickingName_WhenLabelAndMetadataNameAreEmpty(t *testing.T) { + labels := KubeAppLabels{ + StackID: 123, + Owner: "best-owner", + Kind: "git", + } + + input := `apiVersion: v1 +kind: Service +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 +` + + expected := `apiVersion: v1 +kind: Service +metadata: + labels: + io.portainer.kubernetes.application.kind: git + io.portainer.kubernetes.application.name: "" + io.portainer.kubernetes.application.owner: best-owner + io.portainer.kubernetes.application.stackid: "123" +spec: + ports: + - name: "5000" + port: 5000 + targetPort: 5000 +` + + result, err := AddAppLabels([]byte(input), labels) + assert.NoError(t, err) + assert.Equal(t, expected, string(result)) +} diff --git a/api/portainer.go b/api/portainer.go index 71ff14d70..c99a52df3 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -1243,7 +1243,7 @@ type ( // KubernetesDeployer represents a service to deploy a manifest inside a Kubernetes endpoint KubernetesDeployer interface { Deploy(request *http.Request, endpoint *Endpoint, data string, namespace string) (string, error) - ConvertCompose(data string) ([]byte, error) + ConvertCompose(data []byte) ([]byte, error) } // KubernetesSnapshotter represents a service used to create Kubernetes endpoint snapshots 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..df78df98d 100644 --- a/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html +++ b/app/kubernetes/components/datatables/applications-datatable/applicationsDatatable.html @@ -162,7 +162,7 @@ system external - {{ item.StackName }} + {{ item.StackName || '-' }} {{ item.ResourcePool }} 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/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 + + +
+
+ + +
+ + + +
+ Build method +
+ + + + +

Templates allow deploying any kind of Kubernetes resource (Deployment, Secret, ConfigMap...)

+

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

+
+
+ + + + You can upload a Manifest file from your computer. + + + + + + +
+ Actions +
+
+
+ + + {{ $ctrl.state.formValidationError }} + +
+
+ +
+
+
+
+
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 }} + + +
+
+ + +
+ + + + +

Templates allow deploying any kind of Kubernetes resource (Deployment, Secret, ConfigMap...)

+

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

+
+
+ + + +
+ Actions +
+
+
+ + + {{ $ctrl.state.formValidationError }} + +
+
+
+
+
+
+
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/application/models/constants.js b/app/kubernetes/models/application/models/constants.js index 970cb3557..c94e67f82 100644 --- a/app/kubernetes/models/application/models/constants.js +++ b/app/kubernetes/models/application/models/constants.js @@ -40,6 +40,7 @@ export const KubernetesApplicationQuotaDefaults = { }; export const KubernetesPortainerApplicationStackNameLabel = 'io.portainer.kubernetes.application.stack'; +export const KubernetesPortainerApplicationStackIdLabel = 'io.portainer.kubernetes.application.stackid'; export const KubernetesPortainerApplicationNameLabel = 'io.portainer.kubernetes.application.name'; diff --git a/app/kubernetes/models/deploy.js b/app/kubernetes/models/deploy.js index 34fe609fe..797dee0ea 100644 --- a/app/kubernetes/models/deploy.js +++ b/app/kubernetes/models/deploy.js @@ -6,6 +6,7 @@ export const KubernetesDeployManifestTypes = Object.freeze({ export const KubernetesDeployBuildMethods = Object.freeze({ GIT: 1, WEB_EDITOR: 2, + CUSTOM_TEMPLATE: 3, }); export const KubernetesDeployRequestMethods = Object.freeze({ diff --git a/app/kubernetes/views/applications/edit/application.html b/app/kubernetes/views/applications/edit/application.html index a8c67178d..54a641ca8 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 @@ -191,21 +191,15 @@
-
- + + Create template from application +
diff --git a/app/kubernetes/views/applications/edit/applicationController.js b/app/kubernetes/views/applications/edit/applicationController.js index e47485afc..710571c53 100644 --- a/app/kubernetes/views/applications/edit/applicationController.js +++ b/app/kubernetes/views/applications/edit/applicationController.js @@ -107,7 +107,8 @@ class KubernetesApplicationController { KubernetesStackService, KubernetesPodService, KubernetesNodeService, - EndpointProvider + EndpointProvider, + StackService ) { this.$async = $async; this.$state = $state; @@ -115,6 +116,7 @@ class KubernetesApplicationController { this.Notifications = Notifications; this.LocalStorage = LocalStorage; this.ModalService = ModalService; + this.StackService = StackService; this.KubernetesApplicationService = KubernetesApplicationService; this.KubernetesEventService = KubernetesEventService; @@ -193,6 +195,10 @@ class KubernetesApplicationController { return !rule.Host && !rule.IP ? false : true; } + isStack() { + return this.application.StackId; + } + /** * ROLLBACK */ @@ -308,6 +314,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/deploy/deploy.html b/app/kubernetes/views/deploy/deploy.html index 237acec2c..9f5d71240 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 +
+ +
+
@@ -62,9 +64,17 @@
+ + { + 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; @@ -91,10 +111,20 @@ class KubernetesDeployController { this.state.actionInProgress = true; try { - const method = this.state.BuildMethod === this.BuildMethods.GIT ? KubernetesDeployRequestMethods.REPOSITORY : KubernetesDeployRequestMethods.STRING; + let method = KubernetesDeployRequestMethods.STRING; + let composeFormat = this.state.DeployType === this.ManifestDeployTypes.COMPOSE; + + switch (this.state.BuildMethod) { + case KubernetesDeployBuildMethods.GIT: + method = KubernetesDeployRequestMethods.REPOSITORY; + break; + case KubernetesDeployBuildMethods.CUSTOM_TEMPLATE: + composeFormat = false; + break; + } const payload = { - ComposeFormat: this.state.DeployType === this.ManifestDeployTypes.COMPOSE, + ComposeFormat: composeFormat, Namespace: this.formValues.Namespace, }; @@ -157,20 +187,27 @@ class KubernetesDeployController { return this.ModalService.confirmWebEditorDiscard(); } } - async onInit() { - 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/portainer/components/custom-template-common-fields/customTemplateCommonFields.html b/app/portainer/components/custom-template-common-fields/customTemplateCommonFields.html index 2c1dee51c..255097cea 100644 --- a/app/portainer/components/custom-template-common-fields/customTemplateCommonFields.html +++ b/app/portainer/components/custom-template-common-fields/customTemplateCommonFields.html @@ -52,7 +52,7 @@ -
+
@@ -61,7 +61,7 @@ -
+
diff --git a/app/portainer/components/custom-template-common-fields/index.js b/app/portainer/components/custom-template-common-fields/index.js index 59bd6af33..755313e57 100644 --- a/app/portainer/components/custom-template-common-fields/index.js +++ b/app/portainer/components/custom-template-common-fields/index.js @@ -5,5 +5,7 @@ angular.module('portainer.app').component('customTemplateCommonFields', { controller: CustomTemplateCommonFieldsController, bindings: { formValues: '=', + showPlatformField: '<', + showTypeField: '<', }, }); diff --git a/app/portainer/components/custom-templates-list/customTemplatesList.html b/app/portainer/components/custom-templates-list/customTemplatesList.html index 9b160717c..5d7c9228b 100644 --- a/app/portainer/components/custom-templates-list/customTemplatesList.html +++ b/app/portainer/components/custom-templates-list/customTemplatesList.html @@ -5,9 +5,7 @@
{{ $ctrl.titleText }}
- +