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 -