From 29166e1eab2ad5bc955470539ff660fa9d80e225 Mon Sep 17 00:00:00 2001 From: Hui Date: Thu, 10 Jun 2021 12:38:49 +1200 Subject: [PATCH] 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 {