From caa6c150324843294d7dbdfc4197268c139f92cd Mon Sep 17 00:00:00 2001
From: Hui
Date: Thu, 17 Jun 2021 09:47:32 +1200
Subject: [PATCH] feat(k8s): advanced deployment from Git repo EE-447 (#5166)
* feat(stack): UI updates in git repo deployment method for k8s EE-640. (#5097)
* feat(stack): UI updates in git repo deployment method for k8s EE-640.
* feat(stack): supports the combination of GIT + COMPOSE.
* feat(stack): rename variable
* feat(stack): add git repo deployment method for k8s EE-638
* cleanup
* update payload validation rules
* make repo ref optional in frond end
Co-authored-by: fhanportainer <79428273+fhanportainer@users.noreply.github.com>
---
api/filesystem/filesystem.go | 2 +
.../handler/stacks/create_compose_stack.go | 4 +-
.../handler/stacks/create_kubernetes_stack.go | 133 +++++++++++--
.../stacks/create_kubernetes_stack_test.go | 64 ++++++
api/http/handler/stacks/stack_create.go | 17 +-
app/kubernetes/models/deploy.js | 10 +
.../create/createApplication.html | 4 +-
.../views/configure/configureController.js | 2 +-
app/kubernetes/views/deploy/deploy.html | 185 +++++++++++++++---
.../views/deploy/deployController.js | 38 +++-
app/portainer/services/api/stackService.js | 13 +-
11 files changed, 409 insertions(+), 63 deletions(-)
create mode 100644 api/http/handler/stacks/create_kubernetes_stack_test.go
diff --git a/api/filesystem/filesystem.go b/api/filesystem/filesystem.go
index 778c979d8..0a0b6dbbd 100644
--- a/api/filesystem/filesystem.go
+++ b/api/filesystem/filesystem.go
@@ -31,6 +31,8 @@ const (
ComposeStorePath = "compose"
// ComposeFileDefaultName represents the default name of a compose file.
ComposeFileDefaultName = "docker-compose.yml"
+ // ManifestFileDefaultName represents the default name of a k8s manifest file.
+ ManifestFileDefaultName = "k8s-deployment.yml"
// EdgeStackStorePath represents the subfolder where edge stack files are stored in the file store folder.
EdgeStackStorePath = "edge_stacks"
// PrivateKeyFile represents the name on disk of the file containing the private key.
diff --git a/api/http/handler/stacks/create_compose_stack.go b/api/http/handler/stacks/create_compose_stack.go
index 4f8e38c50..0a3b8e526 100644
--- a/api/http/handler/stacks/create_compose_stack.go
+++ b/api/http/handler/stacks/create_compose_stack.go
@@ -237,11 +237,11 @@ func (handler *Handler) createComposeStackFromFileUpload(w http.ResponseWriter,
isUnique, err := handler.checkUniqueName(endpoint, payload.Name, 0, false)
if err != nil {
- return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for name collision", err}
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to check for name collision", Err: err}
}
if !isUnique {
errorMessage := fmt.Sprintf("A stack with the name '%s' already exists", payload.Name)
- return &httperror.HandlerError{http.StatusConflict, errorMessage, errors.New(errorMessage)}
+ return &httperror.HandlerError{StatusCode: http.StatusConflict, Message: errorMessage, Err: errors.New(errorMessage)}
}
stackID := handler.DataStore.Stack().GetNextIdentifier()
diff --git a/api/http/handler/stacks/create_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go
index 7f1904e71..27d0971f7 100644
--- a/api/http/handler/stacks/create_kubernetes_stack.go
+++ b/api/http/handler/stacks/create_kubernetes_stack.go
@@ -2,7 +2,11 @@ package stacks
import (
"errors"
+ "io/ioutil"
"net/http"
+ "path/filepath"
+ "strconv"
+ "time"
"github.com/asaskevich/govalidator"
@@ -10,16 +14,29 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
- endpointutils "github.com/portainer/portainer/api/internal/endpoint"
+ "github.com/portainer/portainer/api/filesystem"
)
-type kubernetesStackPayload struct {
+const defaultReferenceName = "refs/heads/master"
+
+type kubernetesStringDeploymentPayload struct {
ComposeFormat bool
Namespace string
StackFileContent string
}
-func (payload *kubernetesStackPayload) Validate(r *http.Request) error {
+type kubernetesGitDeploymentPayload struct {
+ ComposeFormat bool
+ Namespace string
+ RepositoryURL string
+ RepositoryReferenceName string
+ RepositoryAuthentication bool
+ RepositoryUsername string
+ RepositoryPassword string
+ FilePathInRepository string
+}
+
+func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) error {
if govalidator.IsNull(payload.StackFileContent) {
return errors.New("Invalid stack file content")
}
@@ -29,24 +46,63 @@ func (payload *kubernetesStackPayload) Validate(r *http.Request) error {
return nil
}
+func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
+ if govalidator.IsNull(payload.Namespace) {
+ return errors.New("Invalid namespace")
+ }
+ if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) {
+ return errors.New("Invalid repository URL. Must correspond to a valid URL format")
+ }
+ if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) {
+ return errors.New("Invalid repository credentials. Password must be specified when authentication is enabled")
+ }
+ if govalidator.IsNull(payload.FilePathInRepository) {
+ return errors.New("Invalid file path in repository")
+ }
+ if govalidator.IsNull(payload.RepositoryReferenceName) {
+ payload.RepositoryReferenceName = defaultReferenceName
+ }
+ return nil
+}
+
type createKubernetesStackResponse struct {
Output string `json:"Output"`
}
-func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
- if !endpointutils.IsKubernetesEndpoint(endpoint) {
- return &httperror.HandlerError{http.StatusBadRequest, "Endpoint type does not match", errors.New("Endpoint type does not match")}
+func (handler *Handler) createKubernetesStackFromFileContent(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
+ var payload kubernetesStringDeploymentPayload
+ if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
+ return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
}
- var payload kubernetesStackPayload
- err := request.DecodeAndValidateJSONPayload(r, &payload)
- if err != nil {
- return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
+ stackID := handler.DataStore.Stack().GetNextIdentifier()
+ stack := &portainer.Stack{
+ ID: portainer.StackID(stackID),
+ Type: portainer.KubernetesStack,
+ EndpointID: endpoint.ID,
+ EntryPoint: filesystem.ManifestFileDefaultName,
+ Status: portainer.StackStatusActive,
+ CreationDate: time.Now().Unix(),
}
+ stackFolder := strconv.Itoa(int(stack.ID))
+ projectPath, err := handler.FileService.StoreStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
+ if err != nil {
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist Kubernetes manifest file on disk", Err: err}
+ }
+ stack.ProjectPath = projectPath
+
+ doCleanUp := true
+ defer handler.cleanUp(stack, &doCleanUp)
+
output, err := handler.deployKubernetesStack(endpoint, payload.StackFileContent, payload.ComposeFormat, payload.Namespace)
if err != nil {
- return &httperror.HandlerError{http.StatusInternalServerError, "Unable to deploy Kubernetes stack", err}
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
+ }
+
+ err = handler.DataStore.Stack().CreateStack(stack)
+ if err != nil {
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the Kubernetes stack inside the database", Err: err}
}
resp := &createKubernetesStackResponse{
@@ -56,6 +112,49 @@ func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Req
return response.JSON(w, resp)
}
+func (handler *Handler) createKubernetesStackFromGitRepository(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
+ var payload kubernetesGitDeploymentPayload
+ if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
+ return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid request payload", Err: err}
+ }
+
+ stackID := handler.DataStore.Stack().GetNextIdentifier()
+ stack := &portainer.Stack{
+ ID: portainer.StackID(stackID),
+ Type: portainer.KubernetesStack,
+ EndpointID: endpoint.ID,
+ EntryPoint: payload.FilePathInRepository,
+ Status: portainer.StackStatusActive,
+ CreationDate: time.Now().Unix(),
+ }
+
+ projectPath := handler.FileService.GetStackProjectPath(strconv.Itoa(int(stack.ID)))
+ stack.ProjectPath = projectPath
+
+ doCleanUp := true
+ defer handler.cleanUp(stack, &doCleanUp)
+
+ stackFileContent, err := handler.cloneManifestContentFromGitRepo(&payload, stack.ProjectPath)
+ if err != nil {
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Failed to process manifest from Git repository", Err: err}
+ }
+
+ output, err := handler.deployKubernetesStack(endpoint, stackFileContent, payload.ComposeFormat, payload.Namespace)
+ if err != nil {
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to deploy Kubernetes stack", Err: err}
+ }
+
+ err = handler.DataStore.Stack().CreateStack(stack)
+ if err != nil {
+ return &httperror.HandlerError{StatusCode: http.StatusInternalServerError, Message: "Unable to persist the stack inside the database", Err: err}
+ }
+
+ resp := &createKubernetesStackResponse{
+ Output: output,
+ }
+ return response.JSON(w, resp)
+}
+
func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, stackConfig string, composeFormat bool, namespace string) (string, error) {
handler.stackCreationMutex.Lock()
defer handler.stackCreationMutex.Unlock()
@@ -71,3 +170,15 @@ func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, stac
return handler.KubernetesDeployer.Deploy(endpoint, stackConfig, namespace)
}
+
+func (handler *Handler) cloneManifestContentFromGitRepo(gitInfo *kubernetesGitDeploymentPayload, projectPath string) (string, error) {
+ err := handler.GitService.CloneRepository(projectPath, gitInfo.RepositoryURL, gitInfo.RepositoryReferenceName, gitInfo.RepositoryUsername, gitInfo.RepositoryPassword)
+ if err != nil {
+ return "", err
+ }
+ content, err := ioutil.ReadFile(filepath.Join(projectPath, gitInfo.FilePathInRepository))
+ if err != nil {
+ return "", err
+ }
+ return string(content), nil
+}
diff --git a/api/http/handler/stacks/create_kubernetes_stack_test.go b/api/http/handler/stacks/create_kubernetes_stack_test.go
new file mode 100644
index 000000000..f1b47286e
--- /dev/null
+++ b/api/http/handler/stacks/create_kubernetes_stack_test.go
@@ -0,0 +1,64 @@
+package stacks
+
+import (
+ "io/ioutil"
+ "os"
+ "path"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type git struct {
+ content string
+}
+
+func (g *git) CloneRepository(destination string, repositoryURL, referenceName, username, password string) error {
+ return g.ClonePublicRepository(repositoryURL, referenceName, destination)
+}
+func (g *git) ClonePublicRepository(repositoryURL string, referenceName string, destination string) error {
+ return ioutil.WriteFile(path.Join(destination, "deployment.yml"), []byte(g.content), 0755)
+}
+func (g *git) ClonePrivateRepositoryWithBasicAuth(repositoryURL, referenceName string, destination, username, password string) error {
+ return g.ClonePublicRepository(repositoryURL, referenceName, destination)
+}
+
+func TestCloneAndConvertGitRepoFile(t *testing.T) {
+ dir, err := os.MkdirTemp("", "kube-create-stack")
+ assert.NoError(t, err, "failed to create a tmp dir")
+ defer os.RemoveAll(dir)
+
+ content := `apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: nginx-deployment
+ labels:
+ app: nginx
+ spec:
+ replicas: 3
+ selector:
+ matchLabels:
+ app: nginx
+ template:
+ metadata:
+ labels:
+ app: nginx
+ spec:
+ containers:
+ - name: nginx
+ image: nginx:1.14.2
+ ports:
+ - containerPort: 80`
+
+ h := &Handler{
+ GitService: &git{
+ content: content,
+ },
+ }
+ gitInfo := &kubernetesGitDeploymentPayload{
+ FilePathInRepository: "deployment.yml",
+ }
+ fileContent, err := h.cloneManifestContentFromGitRepo(gitInfo, dir)
+ assert.NoError(t, err, "failed to clone or convert the file from Git repo")
+ assert.Equal(t, content, fileContent)
+}
diff --git a/api/http/handler/stacks/stack_create.go b/api/http/handler/stacks/stack_create.go
index 1919993dd..7693746ca 100644
--- a/api/http/handler/stacks/stack_create.go
+++ b/api/http/handler/stacks/stack_create.go
@@ -14,6 +14,7 @@ import (
portainer "github.com/portainer/portainer/api"
bolterrors "github.com/portainer/portainer/api/bolt/errors"
gittypes "github.com/portainer/portainer/api/git/types"
+ httperrors "github.com/portainer/portainer/api/http/errors"
"github.com/portainer/portainer/api/http/security"
"github.com/portainer/portainer/api/internal/authorization"
"github.com/portainer/portainer/api/internal/endpointutils"
@@ -112,7 +113,11 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt
case portainer.DockerComposeStack:
return handler.createComposeStack(w, r, method, endpoint, tokenData.ID)
case portainer.KubernetesStack:
- return handler.createKubernetesStack(w, r, endpoint)
+ if tokenData.Role != portainer.AdministratorRole {
+ return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied", Err: httperrors.ErrUnauthorized}
+ }
+
+ return handler.createKubernetesStack(w, r, method, endpoint)
}
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: type. Value must be one of: 1 (Swarm stack) or 2 (Compose stack)", errors.New(request.ErrInvalidQueryParameter)}
@@ -145,6 +150,16 @@ func (handler *Handler) createSwarmStack(w http.ResponseWriter, r *http.Request,
return &httperror.HandlerError{http.StatusBadRequest, "Invalid value for query parameter: method. Value must be one of: string, repository or file", errors.New(request.ErrInvalidQueryParameter)}
}
+func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, method string, endpoint *portainer.Endpoint) *httperror.HandlerError {
+ switch method {
+ case "string":
+ return handler.createKubernetesStackFromFileContent(w, r, endpoint)
+ case "repository":
+ return handler.createKubernetesStackFromGitRepository(w, r, endpoint)
+ }
+ return &httperror.HandlerError{StatusCode: http.StatusBadRequest, Message: "Invalid value for query parameter: method. Value must be one of: string or repository", Err: errors.New(request.ErrInvalidQueryParameter)}
+}
+
func (handler *Handler) isValidStackFile(stackFileContent []byte, securitySettings *portainer.EndpointSecuritySettings) error {
composeConfigYAML, err := loader.ParseYAML(stackFileContent)
if err != nil {
diff --git a/app/kubernetes/models/deploy.js b/app/kubernetes/models/deploy.js
index d9e0d8363..34fe609fe 100644
--- a/app/kubernetes/models/deploy.js
+++ b/app/kubernetes/models/deploy.js
@@ -2,3 +2,13 @@ export const KubernetesDeployManifestTypes = Object.freeze({
KUBERNETES: 1,
COMPOSE: 2,
});
+
+export const KubernetesDeployBuildMethods = Object.freeze({
+ GIT: 1,
+ WEB_EDITOR: 2,
+});
+
+export const KubernetesDeployRequestMethods = Object.freeze({
+ REPOSITORY: 'repository',
+ STRING: 'string',
+});
diff --git a/app/kubernetes/views/applications/create/createApplication.html b/app/kubernetes/views/applications/create/createApplication.html
index 13ff1455a..fbd9d3373 100644
--- a/app/kubernetes/views/applications/create/createApplication.html
+++ b/app/kubernetes/views/applications/create/createApplication.html
@@ -207,8 +207,8 @@
Environment variable name is required.
This field must consist of alphabetic characters, digits, '_', '-', or '.', and must
- not start with a digit (e.g. 'my.env-name', or 'MY_ENV.NAME', or 'MyEnvName1'.
This field must consist of alphabetic characters, digits, '_', '-', or '.', and must not
+ start with a digit (e.g. 'my.env-name', or 'MY_ENV.NAME', or 'MyEnvName1'.
-
+
+
- Web editor
+ Build method
-