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

- - Portainer uses Kompose to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that not - all the Compose format options are supported by Kompose at the moment. -

-

- You can get more information about Compose file format in the - official documentation. -

-
- -

- - This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...). -

-

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

-
+
+
+
+
+ + +
+
+ + +
+
-
-
- + + + + +
+
+ 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. + +
+
+ +
+ +
+ +
+ +
+
+
+ + + +
+
+ Web editor +
+
+ +

+ + Portainer uses Kompose to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that + not all the Compose format options are supported by Kompose at the moment. +

+

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

+
+ +

+ + This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...). +

+

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

+
+
+ +
+
+ +
diff --git a/app/kubernetes/views/deploy/deployController.js b/app/kubernetes/views/deploy/deployController.js index 67564909b..5fbd0341a 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,14 @@ 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.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 +53,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 +119,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 +131,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 fd9b84871..a26c6c98b 100644 --- a/app/portainer/services/api/stackService.js +++ b/app/portainer/services/api/stackService.js @@ -369,21 +369,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;