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
pull/5570/head
Chaim Lev-Ari 2021-09-02 08:28:51 +03:00 committed by GitHub
parent a176ec5ace
commit e4fe4f9a43
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 1562 additions and 107 deletions

View File

@ -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 {

View File

@ -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

View File

@ -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=

View File

@ -105,9 +105,10 @@ type customTemplateFromFileContentPayload struct {
Note string `example:"This is my <b>custom</b> 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 <b>custom</b> 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")

View File

@ -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
}

View File

@ -27,9 +27,10 @@ type customTemplateUpdatePayload struct {
Note string `example:"This is my <b>custom</b> 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) {

View File

@ -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) {

112
api/kubernetes/yaml.go Normal file
View File

@ -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
}

493
api/kubernetes/yaml_test.go Normal file
View File

@ -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))
}

View File

@ -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

View File

@ -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 = {

View File

@ -162,7 +162,7 @@
<span class="label label-info image-tag label-margins" ng-if="$ctrl.isSystemNamespace(item)">system</span>
<span class="label label-primary image-tag label-margins" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span>
</td>
<td>{{ item.StackName }}</td>
<td>{{ item.StackName || '-' }}</td>
<td>
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })">{{ item.ResourcePool }}</a>
</td>

View File

@ -91,7 +91,7 @@
<td
><a ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })">{{ item.Name }}</a></td
>
<td>{{ item.StackName }}</td>
<td>{{ item.StackName || '-' }}</td>
<td title="{{ item.Image }}">{{ item.Image | truncate: 64 }}</td>
</tr>
<tr ng-if="!$ctrl.dataset">

View File

@ -114,7 +114,7 @@
<span style="margin-left: 5px;" class="label label-info image-tag" ng-if="$ctrl.isSystemNamespace(item)">system</span>
<span style="margin-left: 5px;" class="label label-primary image-tag" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span>
</td>
<td>{{ item.StackName }}</td>
<td>{{ item.StackName || '-' }}</td>
<td>
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })">{{ item.ResourcePool }}</a>
</td>

View File

@ -106,7 +106,7 @@
<a ui-sref="kubernetes.applications.application({ name: item.Name, namespace: item.ResourcePool })">{{ item.Name }}</a>
<span style="margin-left: 5px;" class="label label-primary image-tag" ng-if="$ctrl.isExternalApplication(item)">external</span>
</td>
<td>{{ item.StackName }}</td>
<td>{{ item.StackName || '-' }}</td>
<td title="{{ item.Image }}"
>{{ item.Image | truncate: 64 }} <span ng-if="item.Containers.length > 1">+ {{ item.Containers.length - 1 }}</span></td
>

View File

@ -8,6 +8,16 @@
Dashboard
</sidebar-menu-item>
<sidebar-menu-item
path="kubernetes.templates.custom"
path-params="{ endpointId: $ctrl.endpointId }"
icon-class="fa-rocket fa-fw"
class-name="sidebar-list"
data-cy="k8sSidebar-customTemplates"
>
Custom Templates
</sidebar-menu-item>
<sidebar-menu-item
path="kubernetes.resourcePools"
path-params="{ endpointId: $ctrl.endpointId }"

View File

@ -14,6 +14,7 @@ import {
KubernetesPortainerApplicationNote,
KubernetesPortainerApplicationOwnerLabel,
KubernetesPortainerApplicationStackNameLabel,
KubernetesPortainerApplicationStackIdLabel,
} from 'Kubernetes/models/application/models';
import { KubernetesServiceTypes } from 'Kubernetes/models/service/models';
import KubernetesResourceReservationHelper from 'Kubernetes/helpers/resourceReservationHelper';
@ -54,10 +55,16 @@ class KubernetesApplicationConverter {
const containers = data.spec.template ? _.without(_.concat(data.spec.template.spec.containers, data.spec.template.spec.initContainers), undefined) : data.spec.containers;
res.Id = data.metadata.uid;
res.Name = data.metadata.name;
res.StackName = data.metadata.labels ? data.metadata.labels[KubernetesPortainerApplicationStackNameLabel] || '-' : '-';
res.ApplicationOwner = data.metadata.labels ? data.metadata.labels[KubernetesPortainerApplicationOwnerLabel] || '' : '';
if (data.metadata.labels) {
const { labels } = data.metadata;
res.StackId = labels[KubernetesPortainerApplicationStackIdLabel] ? parseInt(labels[KubernetesPortainerApplicationStackIdLabel], 10) : null;
res.StackName = labels[KubernetesPortainerApplicationStackNameLabel] || '';
res.ApplicationOwner = labels[KubernetesPortainerApplicationOwnerLabel] || '';
res.ApplicationName = labels[KubernetesPortainerApplicationNameLabel] || res.Name;
}
res.Note = data.metadata.annotations ? data.metadata.annotations[KubernetesPortainerApplicationNote] || '' : '';
res.ApplicationName = data.metadata.labels ? data.metadata.labels[KubernetesPortainerApplicationNameLabel] || res.Name : res.Name;
res.ResourcePool = data.metadata.namespace;
if (containers.length) {
res.Image = containers[0].image;

View File

@ -0,0 +1,61 @@
import angular from 'angular';
import { kubeCustomTemplatesView } from './kube-custom-templates-view';
import { kubeEditCustomTemplateView } from './kube-edit-custom-template-view';
import { kubeCreateCustomTemplateView } from './kube-create-custom-template-view';
export default angular
.module('portainer.kubernetes.custom-templates', [])
.config(config)
.component('kubeCustomTemplatesView', kubeCustomTemplatesView)
.component('kubeEditCustomTemplateView', kubeEditCustomTemplateView)
.component('kubeCreateCustomTemplateView', kubeCreateCustomTemplateView).name;
function config($stateRegistryProvider) {
const templates = {
name: 'kubernetes.templates',
url: '/templates',
abstract: true,
};
const customTemplates = {
name: 'kubernetes.templates.custom',
url: '/custom',
views: {
'content@': {
component: 'kubeCustomTemplatesView',
},
},
};
const customTemplatesNew = {
name: 'kubernetes.templates.custom.new',
url: '/new?fileContent',
views: {
'content@': {
component: 'kubeCreateCustomTemplateView',
},
},
params: {
fileContent: '',
},
};
const customTemplatesEdit = {
name: 'kubernetes.templates.custom.edit',
url: '/:id',
views: {
'content@': {
component: 'kubeEditCustomTemplateView',
},
},
};
$stateRegistryProvider.register(templates);
$stateRegistryProvider.register(customTemplates);
$stateRegistryProvider.register(customTemplatesNew);
$stateRegistryProvider.register(customTemplatesEdit);
}

View File

@ -0,0 +1,6 @@
import controller from './kube-create-custom-template-view.controller.js';
export const kubeCreateCustomTemplateView = {
templateUrl: './kube-create-custom-template-view.html',
controller,
};

View File

@ -0,0 +1,169 @@
import { buildOption } from '@/portainer/components/box-selector';
import { AccessControlFormData } from '@/portainer/components/accessControlForm/porAccessControlFormModel';
class KubeCreateCustomTemplateViewController {
/* @ngInject */
constructor($async, $state, Authentication, CustomTemplateService, FormValidator, ModalService, Notifications, ResourceControlService) {
Object.assign(this, { $async, $state, Authentication, CustomTemplateService, FormValidator, ModalService, Notifications, ResourceControlService });
this.methodOptions = [
buildOption('method_editor', 'fa fa-edit', 'Web editor', 'Use our Web editor', 'editor'),
buildOption('method_upload', 'fa fa-upload', 'Upload', 'Upload from your computer', 'upload'),
];
this.templates = null;
this.state = {
method: 'editor',
actionInProgress: false,
formValidationError: '',
isEditorDirty: false,
};
this.formValues = {
FileContent: '',
File: null,
Title: '',
Description: '',
Note: '',
Logo: '',
AccessControlData: new AccessControlFormData(),
};
this.onChangeFile = this.onChangeFile.bind(this);
this.onChangeFileContent = this.onChangeFileContent.bind(this);
this.onChangeMethod = this.onChangeMethod.bind(this);
this.onBeforeOnload = this.onBeforeOnload.bind(this);
}
onChangeMethod(method) {
this.state.method = method;
}
onChangeFileContent(content) {
this.formValues.FileContent = content;
this.state.isEditorDirty = true;
}
onChangeFile(file) {
this.formValues.File = file;
}
async createCustomTemplate() {
return this.$async(async () => {
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;

View File

@ -0,0 +1,71 @@
<rd-header>
<rd-header-title title-text="Create Custom template"></rd-header-title>
<rd-header-content> <a ui-sref="kubernetes.templates.custom">Custom Templates</a> &gt; Create Custom template </rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="$ctrl.form">
<custom-template-common-fields form-values="$ctrl.formValues"></custom-template-common-fields>
<!-- build-method -->
<div class="col-sm-12 form-section-title">
Build method
</div>
<box-selector radio-name="method" ng-model="$ctrl.state.method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
<web-editor-form
ng-if="$ctrl.state.method === 'editor'"
identifier="template-creation-editor"
value="$ctrl.formValues.FileContent"
on-change="($ctrl.onChangeFileContent)"
ng-required="true"
yml="true"
placeholder="# Define or paste the content of your manifest file here"
>
<editor-description>
<p>Templates allow deploying any kind of Kubernetes resource (Deployment, Secret, ConfigMap...)</p>
<p>
You can get more information about Kubernetes file format in the
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/" target="_blank">official documentation</a>.
</p>
</editor-description>
</web-editor-form>
<file-upload-form ng-if="$ctrl.state.method === 'upload'" file="$ctrl.formValues.File" on-change="($ctrl.onChangeFile)" ng-required="true">
<file-upload-description>
You can upload a Manifest file from your computer.
</file-upload-description>
</file-upload-form>
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
<!-- actions -->
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="$ctrl.state.actionInProgress || $ctrl.form.$invalid || ($ctrl.state.method === 'editor' && !$ctrl.formValues.FileContent)"
ng-click="$ctrl.createCustomTemplate()"
button-spinner="$ctrl.state.actionInProgress"
>
<span ng-hide="$ctrl.state.actionInProgress">Create custom template</span>
<span ng-show="$ctrl.state.actionInProgress">Creation in progress...</span>
</button>
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
{{ $ctrl.state.formValidationError }}
</span>
</div>
</div>
<!-- !actions -->
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -0,0 +1,6 @@
import controller from './kube-custom-templates-view.controller.js';
export const kubeCustomTemplatesView = {
templateUrl: './kube-custom-templates-view.html',
controller,
};

View File

@ -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;
}
}

View File

@ -0,0 +1,25 @@
<rd-header id="view-top">
<rd-header-title title-text="Custom Templates">
<a data-toggle="tooltip" title="Refresh" ui-sref="kubernetes.templates.custom" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content>Custom Templates</rd-header-content>
</rd-header>
<div class="row">
<div class="col-sm-12">
<custom-templates-list
ng-if="$ctrl.templates"
title-text="Templates"
title-icon="fa-rocket"
templates="$ctrl.templates"
table-key="customTemplates"
is-edit-allowed="$ctrl.isEditAllowed"
on-select-click="($ctrl.selectTemplate)"
on-delete-click="($ctrl.confirmDelete)"
create-path="kubernetes.templates.custom.new"
edit-path="kubernetes.templates.custom.edit"
></custom-templates-list>
</div>
</div>

View File

@ -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,
};

View File

@ -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, '');
}

View File

@ -0,0 +1,60 @@
<rd-header>
<rd-header-title title-text="Edit Custom Template">
<a data-toggle="tooltip" title="Refresh" ui-sref="kubernetes.templates.custom.edit({id:$ctrl.formValues.Id})" ui-sref-opts="{reload: true}">
<i class="fa fa-sync" aria-hidden="true"></i>
</a>
</rd-header-title>
<rd-header-content> <a ui-sref="kubernetes.templates.custom">Custom templates</a> &gt; {{ $ctrl.formValues.Title }} </rd-header-content>
</rd-header>
<div class="row" ng-if="$ctrl.formValues">
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="$ctrl.form">
<custom-template-common-fields form-values="$ctrl.formValues"></custom-template-common-fields>
<web-editor-form
identifier="template-editor"
value="$ctrl.formValues.FileContent"
on-change="($ctrl.onChangeFileContent)"
ng-required="true"
yml="true"
placeholder="# Define or paste the content of your manifest file here"
>
<editor-description>
<p>Templates allow deploying any kind of Kubernetes resource (Deployment, Secret, ConfigMap...)</p>
<p>
You can get more information about Kubernetes file format in the
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/" target="_blank">official documentation</a>.
</p>
</editor-description>
</web-editor-form>
<por-access-control-form form-data="$ctrl.formValues.AccessControlData" resource-control="$ctrl.formValues.ResourceControl"></por-access-control-form>
<div class="col-sm-12 form-section-title">
Actions
</div>
<div class="form-group">
<div class="col-sm-12">
<button
type="button"
class="btn btn-primary btn-sm"
ng-disabled="$ctrl.actionInProgress || $ctrl.form.$invalid || !$ctrl.formValues.Title || !$ctrl.formValues.FileContent"
ng-click="$ctrl.submitAction()"
button-spinner="$ctrl.actionInProgress"
>
<span ng-hide="$ctrl.actionInProgress">Update the template</span>
<span ng-show="$ctrl.actionInProgress">Update in progress...</span>
</button>
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
{{ $ctrl.state.formValidationError }}
</span>
</div>
</div>
</form>
</rd-widget-body>
</rd-widget>
</div>
</div>

View File

@ -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();

View File

@ -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';

View File

@ -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({

View File

@ -26,7 +26,7 @@
</tr>
<tr>
<td>Stack</td>
<td>{{ ctrl.application.StackName }}</td>
<td>{{ ctrl.application.StackName || '-' }}</td>
</tr>
<tr>
<td>Namespace</td>
@ -191,21 +191,15 @@
<div class="col-sm-12">
<rd-widget>
<rd-widget-body>
<div ng-if="!ctrl.isSystemNamespace()">
<button
ng-if="!ctrl.isExternalApplication()"
type="button"
class="btn btn-sm btn-primary"
ui-sref="kubernetes.applications.application.edit"
style="margin-left: 0; margin-bottom: 15px;"
>
<div ng-if="!ctrl.isSystemNamespace()" style="margin-bottom: 15px;">
<button ng-if="!ctrl.isExternalApplication()" type="button" class="btn btn-sm btn-primary" ui-sref="kubernetes.applications.application.edit" style="margin-left: 0;">
<i class="fa fa-file-code space-right" aria-hidden="true"></i>Edit this application
</button>
<button
ng-if="ctrl.application.ApplicationType !== ctrl.KubernetesApplicationTypes.POD"
type="button"
class="btn btn-sm btn-primary"
style="margin-left: 0; margin-bottom: 15px;"
style="margin-left: 0;"
ng-click="ctrl.redeployApplication()"
>
<i class="fa fa-redo space-right" aria-hidden="true"></i>Redeploy
@ -214,12 +208,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"
>
<i class="fas fa-history space-right" aria-hidden="true"></i>Rollback to previous configuration
</button>
<a
ng-if="ctrl.isStack() && ctrl.stackFileContent"
class="btn btn-sm btn-primary space-left"
ui-sref="kubernetes.templates.custom.new({fileContent: ctrl.stackFileContent})"
>
<i class="fas fa-plus space-right" aria-hidden="true"></i>Create template from application
</a>
</div>
<!-- ACCESSING APPLICATION -->

View File

@ -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 {

View File

@ -23,16 +23,18 @@
</div>
</div>
<div class="col-sm-12 form-section-title">
Deployment type
</div>
<box-selector radio-name="deploy" ng-model="ctrl.state.DeployType" options="ctrl.deployOptions" data-cy="k8sAppDeploy-deploymentSelector"></box-selector>
<div class="col-sm-12 form-section-title">
Build method
</div>
<box-selector radio-name="method" ng-model="ctrl.state.BuildMethod" options="ctrl.methodOptions" data-cy="k8sAppDeploy-buildSelector"></box-selector>
<div ng-if="ctrl.state.BuildMethod !== ctrl.BuildMethods.CUSTOM_TEMPLATE">
<div class="col-sm-12 form-section-title">
Deployment type
</div>
<box-selector radio-name="deploy" ng-model="ctrl.state.DeployType" options="ctrl.deployOptions" data-cy="k8sAppDeploy-deploymentSelector"></box-selector>
</div>
<!-- repository -->
<div ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.GIT">
<div class="col-sm-12 form-section-title">
@ -62,9 +64,17 @@
</div>
<!-- !repository -->
<custom-template-selector
ng-show="ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE"
new-template-path="kubernetes.templates.custom.new"
stack-type="3"
on-change="(ctrl.onChangeTemplateId)"
value="ctrl.state.templateId"
></custom-template-selector>
<!-- editor -->
<web-editor-form
ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.WEB_EDITOR"
ng-if="ctrl.state.BuildMethod === ctrl.BuildMethods.WEB_EDITOR || (ctrl.state.BuildMethod === ctrl.BuildMethods.CUSTOM_TEMPLATE && ctrl.state.templateId)"
identifier="kubernetes-deploy-editor"
value="ctrl.formValues.EditorContent"
on-change="(ctrl.onChangeFileContent)"

View File

@ -6,10 +6,11 @@ import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, Kubernetes
import { buildOption } from '@/portainer/components/box-selector';
class KubernetesDeployController {
/* @ngInject */
constructor($async, $state, $window, ModalService, Notifications, EndpointProvider, KubernetesResourcePoolService, StackService) {
constructor($async, $state, $window, CustomTemplateService, ModalService, Notifications, EndpointProvider, KubernetesResourcePoolService, StackService) {
this.$async = $async;
this.$state = $state;
this.$window = $window;
this.CustomTemplateService = CustomTemplateService;
this.ModalService = ModalService;
this.Notifications = Notifications;
this.EndpointProvider = EndpointProvider;
@ -18,12 +19,13 @@ 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_template', 'fa fa-rocket', 'Custom Template', 'Use a custom template', KubernetesDeployBuildMethods.CUSTOM_TEMPLATE),
];
this.state = {
@ -33,6 +35,7 @@ class KubernetesDeployController {
activeTab: 0,
viewReady: false,
isEditorDirty: false,
templateId: null,
};
this.formValues = {};
@ -40,7 +43,7 @@ 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);
@ -75,6 +78,23 @@ class KubernetesDeployController {
this.onChangeFormValues({ RepositoryReferenceName: value });
}
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;
@ -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() {

View File

@ -52,7 +52,7 @@
<!-- !icon-url-input -->
<!-- platform-input -->
<div class="form-group">
<div ng-if="$ctrl.showPlatformField" class="form-group">
<label for="platform" class="col-sm-3 col-lg-2 control-label text-left">Platform</label>
<div class="col-sm-9 col-lg-10">
<select class="form-control" ng-model="$ctrl.formValues.Platform" ng-options="+(opt.value) as opt.label for opt in $ctrl.platformTypes"> </select>
@ -61,7 +61,7 @@
<!-- !platform-input -->
<!-- platform-input -->
<div class="form-group">
<div ng-if="$ctrl.showTypeField" class="form-group">
<label for="platform" class="col-sm-3 col-lg-2 control-label text-left">Type</label>
<div class="col-sm-9 col-lg-10">
<select class="form-control" ng-model="$ctrl.formValues.Type" ng-options="+(opt.value) as opt.label for opt in $ctrl.templateTypes"> </select>

View File

@ -5,5 +5,7 @@ angular.module('portainer.app').component('customTemplateCommonFields', {
controller: CustomTemplateCommonFieldsController,
bindings: {
formValues: '=',
showPlatformField: '<',
showTypeField: '<',
},
});

View File

@ -5,9 +5,7 @@
<div class="toolBarTitle"> <i class="fa" ng-class="$ctrl.titleIcon" aria-hidden="true" style="margin-right: 2px;"></i> {{ $ctrl.titleText }} </div>
</div>
<div class="actionBar">
<button type="button" class="btn btn-sm btn-primary" ui-sref="docker.templates.custom.new">
<i class="fa fa-plus space-right" aria-hidden="true"></i>Add Custom Template
</button>
<button type="button" class="btn btn-sm btn-primary" ui-state="$ctrl.createPath"> <i class="fa fa-plus space-right" aria-hidden="true"></i>Add Custom Template </button>
</div>
<div class="searchBar" style="border-top: 2px solid #f6f6f6;">
@ -27,12 +25,12 @@
<template-item
ng-repeat="template in $ctrl.templates | filter:$ctrl.state.textFilter"
model="template"
type-label="{{ template.Type === 1 ? 'swarm' : 'standalone' }}"
type-label="{{ $ctrl.typeLabel(template.Type) }}"
on-select="($ctrl.onSelectClick)"
>
<template-item-actions>
<div ng-if="$ctrl.isEditAllowed(template)" style="display: flex;">
<a ui-sref="docker.templates.custom.edit({id: template.Id})" ng-click="$event.stopPropagation();" class="btn btn-primary btn-xs" style="margin-right: 10px;">
<a ui-state="$ctrl.editPath" ui-state-params="({id: template.Id})" ng-click="$event.stopPropagation();" class="btn btn-primary btn-xs" style="margin-right: 10px;">
Edit
</a>
<button class="btn btn-danger btn-xs" ng-click="$ctrl.onDeleteClick(template.Id); $event.stopPropagation();">Delete</button>

View File

@ -1,7 +1,28 @@
const CUSTOM_TEMPLATES_TYPES = {
STANDALONE: 1,
SWARM: 2,
KUBERNETES: 3,
};
angular.module('portainer.docker').controller('CustomTemplatesListController', function ($scope, $controller, DatatableService) {
angular.extend(this, $controller('GenericDatatableController', { $scope: $scope }));
this.$onInit = function () {
this.typeLabel = typeLabel;
this.$onInit = $onInit;
function typeLabel(type) {
switch (type) {
case CUSTOM_TEMPLATES_TYPES.SWARM:
return 'swarm';
case CUSTOM_TEMPLATES_TYPES.KUBERNETES:
return 'manifest';
case CUSTOM_TEMPLATES_TYPES.STANDALONE:
default:
return 'standalone';
}
}
function $onInit() {
this.setDefaults();
this.prepareTableFromDataset();
@ -32,5 +53,5 @@ angular.module('portainer.docker').controller('CustomTemplatesListController', f
this.settings.open = false;
}
this.onSettingsRepeaterChange();
};
}
});

View File

@ -12,5 +12,7 @@ angular.module('portainer.app').component('customTemplatesList', {
showSwarmStacks: '<',
onDeleteClick: '<',
isEditAllowed: '<',
createPath: '@',
editPath: '@',
},
});

View File

@ -0,0 +1,19 @@
<ng-form class="file-upload-form" name="$ctrl.fileUploadForm">
<div class="col-sm-12 form-section-title">
Upload
</div>
<div class="form-group">
<span class="col-sm-12 text-muted small" ng-transclude="description"> </span>
</div>
<div class="form-group">
<div class="col-sm-12">
<button type="button" class="btn btn-sm btn-primary" ngf-select="$ctrl.onChange($file)" ng-model="$ctrl.file" ng-required="$ctrl.ngRequired" name="file">
Select file
</button>
<span class="space-left">
{{ $ctrl.file.name }}
<i class="fa fa-times red-icon" ng-if="!$ctrl.file" aria-hidden="true"></i>
</span>
</div>
</div>
</ng-form>

View File

@ -0,0 +1,13 @@
export const fileUploadForm = {
templateUrl: './file-upload-form.html',
bindings: {
file: '<',
ngRequired: '<',
onChange: '<',
},
transclude: {
description: '?fileUploadDescription',
},
};

View File

@ -1,5 +1,6 @@
import angular from 'angular';
import { webEditorForm } from './web-editor-form';
import { fileUploadForm } from './file-upload-form';
export default angular.module('portainer.app.components.form', []).component('webEditorForm', webEditorForm).name;
export default angular.module('portainer.app.components.form', []).component('webEditorForm', webEditorForm).component('fileUploadForm', fileUploadForm).name;

View File

@ -18,11 +18,14 @@
{{ $ctrl.model.Title }}
</span>
<span class="space-left blocklist-item-subtitle">
<span>
<span ng-if="$ctrl.model.Type != 3">
<i class="fab fa-linux" aria-hidden="true" ng-if="$ctrl.model.Platform === 1 || $ctrl.model.Platform === 'linux' || !$ctrl.model.Platform"></i>
<span ng-if="!$ctrl.model.Platform"> &amp; </span>
<i class="fab fa-windows" aria-hidden="true" ng-if="$ctrl.model.Platform === 2 || $ctrl.model.Platform === 'windows' || !$ctrl.model.Platform"></i>
</span>
<span ng-if="$ctrl.model.Type === 3">
<i class="fa fa-dharmachakra" aria-hidden="true"></i>
</span>
<span>
{{ $ctrl.typeLabel }}
</span>

View File

@ -8,7 +8,7 @@
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="customTemplateForm">
<custom-template-common-fields form-values="$ctrl.formValues"></custom-template-common-fields>
<custom-template-common-fields form-values="$ctrl.formValues" show-platform-field="true" show-type-field="true"></custom-template-common-fields>
<!-- build-method -->
<div ng-if="!$ctrl.state.fromStack">

View File

@ -175,7 +175,7 @@ class CreateCustomTemplateViewController {
}
try {
this.templates = await this.CustomTemplateService.customTemplates();
this.templates = await this.CustomTemplateService.customTemplates([1, 2]);
} catch (err) {
this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
}

View File

@ -64,6 +64,7 @@
title-icon="fa-rocket"
templates="$ctrl.templates"
table-key="customTemplates"
create-path="docker.templates.custom.new"
is-edit-allowed="$ctrl.isEditAllowed"
on-select-click="($ctrl.selectTemplate)"
on-delete-click="($ctrl.confirmDelete)"

View File

@ -81,16 +81,12 @@ class CustomTemplatesViewController {
return this.currentUser.isAdmin || this.currentUser.id === template.CreatedByUserId;
}
getTemplates(endpointMode) {
return this.$async(this.getTemplatesAsync, endpointMode);
getTemplates() {
return this.$async(this.getTemplatesAsync);
}
async getTemplatesAsync({ provider, role }) {
async getTemplatesAsync() {
try {
let stackType = 2;
if (provider === 'DOCKER_SWARM_MODE' && role === 'MANAGER') {
stackType = 1;
}
this.templates = await this.CustomTemplateService.customTemplates(stackType);
this.templates = await this.CustomTemplateService.customTemplates([1, 2]);
} catch (err) {
this.Notifications.error('Failed loading templates', err, 'Unable to load custom templates');
}
@ -237,7 +233,6 @@ class CustomTemplatesViewController {
case 2:
deployable = endpoint.mode.provider === this.DOCKER_STANDALONE;
break;
}
return deployable;
@ -251,7 +246,7 @@ class CustomTemplatesViewController {
apiVersion,
} = applicationState;
this.getTemplates(endpointMode);
this.getTemplates();
this.getNetworks(endpointMode.provider, apiVersion);
this.currentUser.isAdmin = this.Authentication.isAdmin();

View File

@ -12,7 +12,7 @@
<rd-widget>
<rd-widget-body>
<form class="form-horizontal" name="customTemplateForm">
<custom-template-common-fields form-values="$ctrl.formValues"></custom-template-common-fields>
<custom-template-common-fields form-values="$ctrl.formValues" show-platform-field="true" show-type-field="true"></custom-template-common-fields>
<!-- web-editor -->
<div class="col-sm-12 form-section-title">

View File

@ -112,7 +112,7 @@ class EditCustomTemplateViewController {
this.getTemplate();
try {
this.templates = await this.CustomTemplateService.customTemplates();
this.templates = await this.CustomTemplateService.customTemplates([1, 2]);
} catch (err) {
this.Notifications.error('Failure loading', err, 'Failed loading custom templates');
}