-
+
+
+
+
+
+
+ Git repository
+
+
+
+ You can use the URL of a git repository.
+
+
+
+
+
+ Specify a reference of the repository using the following syntax: branches with
+ refs/heads/branch_name or tags with refs/tags/tag_name. If not specified, will use the default HEAD reference normally
+ the master branch.
+
+
+
+
+
+ Indicate the path to the yaml file from the root of your repository.
+
+
+
+
+
+
+ If your git account has 2FA enabled, you may receive an
+ authentication required error when deploying your stack. In this case, you will need to provide a personal-access token instead of your password.
+
+
+
+
+
+
+
+
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js
index b2695399e..4ba2db976 100644
--- a/app/kubernetes/views/deploy/deployController.js
+++ b/app/kubernetes/views/deploy/deployController.js
@@ -1,7 +1,7 @@
import angular from 'angular';
import _ from 'lodash-es';
import stripAnsi from 'strip-ansi';
-import { KubernetesDeployManifestTypes } from 'Kubernetes/models/deploy';
+import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods } from 'Kubernetes/models/deploy';
class KubernetesDeployController {
/* @ngInject */
@@ -23,7 +23,15 @@ class KubernetesDeployController {
}
disableDeploy() {
- return _.isEmpty(this.formValues.EditorContent) || _.isEmpty(this.formValues.Namespace) || this.state.actionInProgress;
+ const isGitFormInvalid =
+ this.state.BuildMethod === KubernetesDeployBuildMethods.GIT &&
+ (!this.formValues.RepositoryURL ||
+ !this.formValues.RepositoryReferenceName ||
+ !this.formValues.FilePathInRepository ||
+ (this.formValues.RepositoryAuthentication && (!this.formValues.RepositoryUsername || !this.formValues.RepositoryPassword)));
+ const isWebEditorInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent);
+
+ return isGitFormInvalid || isWebEditorInvalid || _.isEmpty(this.formValues.Namespace) || this.state.actionInProgress;
}
async editorUpdateAsync(cm) {
@@ -46,8 +54,28 @@ class KubernetesDeployController {
this.state.actionInProgress = true;
try {
- const compose = this.state.DeployType === this.ManifestDeployTypes.COMPOSE;
- await this.StackService.kubernetesDeploy(this.endpointId, this.formValues.Namespace, this.formValues.EditorContent, compose);
+ const method = this.state.BuildMethod === this.BuildMethods.GIT ? KubernetesDeployRequestMethods.REPOSITORY : KubernetesDeployRequestMethods.STRING;
+
+ const payload = {
+ ComposeFormat: this.state.DeployType === this.ManifestDeployTypes.COMPOSE,
+ Namespace: this.formValues.Namespace,
+ };
+
+ if (method === KubernetesDeployRequestMethods.REPOSITORY) {
+ payload.RepositoryURL = this.formValues.RepositoryURL;
+ payload.RepositoryReferenceName = this.formValues.RepositoryReferenceName;
+ payload.RepositoryAuthentication = this.formValues.RepositoryAuthentication ? true : false;
+ if (payload.RepositoryAuthentication) {
+ payload.RepositoryUsername = this.formValues.RepositoryUsername;
+ payload.RepositoryPassword = this.formValues.RepositoryPassword;
+ }
+ payload.FilePathInRepository = this.formValues.FilePathInRepository;
+ } else {
+ payload.StackFileContent = this.formValues.EditorContent;
+ }
+
+ await this.StackService.kubernetesDeploy(this.endpointId, method, payload);
+
this.Notifications.success('Manifest successfully deployed');
this.state.isEditorDirty = false;
this.$state.go('kubernetes.applications');
@@ -92,10 +120,10 @@ class KubernetesDeployController {
return this.ModalService.confirmWebEditorDiscard();
}
}
-
async onInit() {
this.state = {
DeployType: KubernetesDeployManifestTypes.KUBERNETES,
+ BuildMethod: KubernetesDeployBuildMethods.GIT,
tabLogsDisabled: true,
activeTab: 0,
viewReady: false,
@@ -104,6 +132,7 @@ class KubernetesDeployController {
this.formValues = {};
this.ManifestDeployTypes = KubernetesDeployManifestTypes;
+ this.BuildMethods = KubernetesDeployBuildMethods;
this.endpointId = this.EndpointProvider.endpointID();
await this.getNamespaces();
diff --git a/app/portainer/services/api/stackService.js b/app/portainer/services/api/stackService.js
index 477740725..6a13494c6 100644
--- a/app/portainer/services/api/stackService.js
+++ b/app/portainer/services/api/stackService.js
@@ -322,21 +322,16 @@ angular.module('portainer.app').factory('StackService', [
return action(name, stackFileContent, env, endpointId);
};
- async function kubernetesDeployAsync(endpointId, namespace, content, compose) {
+ async function kubernetesDeployAsync(endpointId, method, payload) {
try {
- const payload = {
- StackFileContent: content,
- ComposeFormat: compose,
- Namespace: namespace,
- };
- await Stack.create({ method: 'undefined', type: 3, endpointId: endpointId }, payload).$promise;
+ await Stack.create({ endpointId: endpointId, method: method, type: 3 }, payload).$promise;
} catch (err) {
throw { err: err };
}
}
- service.kubernetesDeploy = function (endpointId, namespace, content, compose) {
- return $async(kubernetesDeployAsync, endpointId, namespace, content, compose);
+ service.kubernetesDeploy = function (endpointId, method, payload) {
+ return $async(kubernetesDeployAsync, endpointId, method, payload);
};
service.start = start;
From 29166e1eab2ad5bc955470539ff660fa9d80e225 Mon Sep 17 00:00:00 2001
From: Hui
Date: Thu, 10 Jun 2021 12:38:49 +1200
Subject: [PATCH 2/2] feat(stack): add git repo deployment method for k8s
EE-638
---
api/filesystem/filesystem.go | 2 +
.../handler/stacks/create_kubernetes_stack.go | 135 ++++++++++++++++--
.../stacks/create_kubernetes_stack_test.go | 61 ++++++++
api/http/handler/stacks/stack_create.go | 14 +-
4 files changed, 202 insertions(+), 10 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 62dac019d..e73c707ed 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_kubernetes_stack.go b/api/http/handler/stacks/create_kubernetes_stack.go
index 61ca665a5..4b9606093 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,15 +14,27 @@ import (
"github.com/portainer/libhttp/request"
"github.com/portainer/libhttp/response"
portainer "github.com/portainer/portainer/api"
+ "github.com/portainer/portainer/api/filesystem"
)
-type kubernetesStackPayload struct {
+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")
}
@@ -28,20 +44,60 @@ 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.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) {
+ return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
+ }
+ if govalidator.IsNull(payload.FilePathInRepository) {
+ return errors.New("Invalid file path in repository")
+ }
+ return nil
+}
+
type createKubernetesStackResponse struct {
Output string `json:"Output"`
}
-func (handler *Handler) createKubernetesStack(w http.ResponseWriter, r *http.Request, endpoint *portainer.Endpoint) *httperror.HandlerError {
- var payload kubernetesStackPayload
- err := request.DecodeAndValidateJSONPayload(r, &payload)
- if err != nil {
- return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
+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}
}
+ 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{
@@ -51,9 +107,72 @@ 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: string(output),
+ }
+ return response.JSON(w, resp)
+}
+
func (handler *Handler) deployKubernetesStack(endpoint *portainer.Endpoint, data string, composeFormat bool, namespace string) ([]byte, error) {
handler.stackCreationMutex.Lock()
defer handler.stackCreationMutex.Unlock()
return handler.KubernetesDeployer.Deploy(endpoint, data, composeFormat, namespace)
}
+
+func (handler *Handler) cloneManifestContentFromGitRepo(gitInfo *kubernetesGitDeploymentPayload, projectPath string) (string, error) {
+ gitCloneParams := &cloneRepositoryParameters{
+ url: gitInfo.RepositoryURL,
+ referenceName: gitInfo.RepositoryReferenceName,
+ path: projectPath,
+ authentication: gitInfo.RepositoryAuthentication,
+ username: gitInfo.RepositoryUsername,
+ password: gitInfo.RepositoryPassword,
+ }
+ err := handler.cloneGitRepository(gitCloneParams)
+ 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..681578526
--- /dev/null
+++ b/api/http/handler/stacks/create_kubernetes_stack_test.go
@@ -0,0 +1,61 @@
+package stacks
+
+import (
+ "io/ioutil"
+ "os"
+ "path"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+type git struct {
+ content string
+}
+
+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 7da0fd386..bb1bb89fb 100644
--- a/api/http/handler/stacks/stack_create.go
+++ b/api/http/handler/stacks/stack_create.go
@@ -111,10 +111,10 @@ func (handler *Handler) stackCreate(w http.ResponseWriter, r *http.Request) *htt
return handler.createComposeStack(w, r, method, endpoint, tokenData.ID)
case portainer.KubernetesStack:
if tokenData.Role != portainer.AdministratorRole {
- return &httperror.HandlerError{http.StatusForbidden, "Access denied", httperrors.ErrUnauthorized}
+ return &httperror.HandlerError{StatusCode: http.StatusForbidden, Message: "Access denied", Err: httperrors.ErrUnauthorized}
}
- return handler.createKubernetesStack(w, r, endpoint)
+ 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)}
@@ -147,6 +147,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 {