From 426c132f97b245fddddc317f25e73281049caf69 Mon Sep 17 00:00:00 2001 From: Chaim Lev-Ari Date: Thu, 4 May 2023 21:11:19 +0700 Subject: [PATCH] refactor(edge/stacks): separate create by method [EE-4947] (#8898) --- api/http/errors/conflict.go | 20 + api/http/errors/invalidpayload.go | 7 + .../handler/edgestacks/edgestack_create.go | 365 +-------- .../edgestacks/edgestack_create_file.go | 107 +++ .../edgestacks/edgestack_create_git.go | 152 ++++ .../edgestacks/edgestack_create_string.go | 125 +++ .../edgestacks/edgestack_create_test.go | 225 ++++++ .../edgestacks/edgestack_delete_test.go | 104 +++ .../edgestacks/edgestack_inspect_test.go | 39 + .../edgestack_status_delete_test.go | 31 + .../edgestack_status_update_test.go | 151 ++++ api/http/handler/edgestacks/edgestack_test.go | 748 ------------------ .../edgestacks/edgestack_update_test.go | 256 ++++++ api/internal/edge/edgestacks/error.go | 15 - api/internal/edge/edgestacks/service.go | 3 +- 15 files changed, 1225 insertions(+), 1123 deletions(-) create mode 100644 api/http/errors/conflict.go create mode 100644 api/http/handler/edgestacks/edgestack_create_file.go create mode 100644 api/http/handler/edgestacks/edgestack_create_git.go create mode 100644 api/http/handler/edgestacks/edgestack_create_string.go create mode 100644 api/http/handler/edgestacks/edgestack_create_test.go create mode 100644 api/http/handler/edgestacks/edgestack_delete_test.go create mode 100644 api/http/handler/edgestacks/edgestack_inspect_test.go create mode 100644 api/http/handler/edgestacks/edgestack_status_delete_test.go create mode 100644 api/http/handler/edgestacks/edgestack_status_update_test.go create mode 100644 api/http/handler/edgestacks/edgestack_update_test.go delete mode 100644 api/internal/edge/edgestacks/error.go diff --git a/api/http/errors/conflict.go b/api/http/errors/conflict.go new file mode 100644 index 000000000..cc2d66216 --- /dev/null +++ b/api/http/errors/conflict.go @@ -0,0 +1,20 @@ +package errors + +import "errors" + +type ConflictError struct { + msg string +} + +func (e *ConflictError) Error() string { + return e.msg +} + +func NewConflictError(msg string) *ConflictError { + return &ConflictError{msg: msg} +} + +func IsConflictError(err error) bool { + var conflictError *ConflictError + return errors.As(err, &conflictError) +} diff --git a/api/http/errors/invalidpayload.go b/api/http/errors/invalidpayload.go index 2ca040cfd..29a6a750c 100644 --- a/api/http/errors/invalidpayload.go +++ b/api/http/errors/invalidpayload.go @@ -1,5 +1,7 @@ package errors +import "errors" + type InvalidPayloadError struct { msg string } @@ -11,3 +13,8 @@ func (e *InvalidPayloadError) Error() string { func NewInvalidPayloadError(msg string) *InvalidPayloadError { return &InvalidPayloadError{msg: msg} } + +func IsInvalidPayloadError(err error) bool { + var payloadError *InvalidPayloadError + return errors.As(err, &payloadError) +} diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go index 5b515d3d4..43a2053a2 100644 --- a/api/http/handler/edgestacks/edgestack_create.go +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -1,19 +1,14 @@ package edgestacks import ( - "fmt" "net/http" - "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" - gittypes "github.com/portainer/portainer/api/git/types" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/http/security" - edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks" ) func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError { @@ -30,9 +25,8 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) edgeStack, err := handler.createSwarmStack(method, dryrun, tokenData.ID, r) if err != nil { - var payloadError *edgestackservice.InvalidPayloadError switch { - case errors.As(err, &payloadError): + case httperrors.IsInvalidPayloadError(err): return httperror.BadRequest("Invalid payload", err) default: return httperror.InternalServerError("Unable to create Edge stack", err) @@ -46,358 +40,11 @@ func (handler *Handler) createSwarmStack(method string, dryrun bool, userID port switch method { case "string": - return handler.createSwarmStackFromFileContent(r, dryrun) + return handler.createEdgeStackFromFileContent(r, dryrun) case "repository": - return handler.createSwarmStackFromGitRepository(r, dryrun, userID) + return handler.createEdgeStackFromGitRepository(r, dryrun, userID) case "file": - return handler.createSwarmStackFromFileUpload(r, dryrun) + return handler.createEdgeStackFromFileUpload(r, dryrun) } - return nil, edgestackservice.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file") -} - -type swarmStackFromFileContentPayload struct { - // Name of the stack - Name string `example:"myStack" validate:"required"` - // Content of the Stack file - StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"` - // List of identifiers of EdgeGroups - EdgeGroups []portainer.EdgeGroupID `example:"1"` - // Deployment type to deploy this stack - // Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad' - // for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints) - // kubernetes deploy type is enabled only for kubernetes environments(endpoints) - // nomad deploy type is enabled only for nomad environments(endpoints) - DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"` - // List of Registries to use for this stack - Registries []portainer.RegistryID - // Uses the manifest's namespaces instead of the default one - UseManifestNamespaces bool -} - -func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error { - if govalidator.IsNull(payload.Name) { - return edgestackservice.NewInvalidPayloadError("Invalid stack name") - } - if govalidator.IsNull(payload.StackFileContent) { - return edgestackservice.NewInvalidPayloadError("Invalid stack file content") - } - if len(payload.EdgeGroups) == 0 { - return edgestackservice.NewInvalidPayloadError("Edge Groups are mandatory for an Edge stack") - } - return nil -} - -// @id EdgeStackCreateString -// @summary Create an EdgeStack from a text -// @description **Access policy**: administrator -// @tags edge_stacks -// @security ApiKeyAuth -// @security jwt -// @produce json -// @param body body swarmStackFromFileContentPayload true "stack config" -// @param dryrun query string false "if true, will not create an edge stack, but just will check the settings and return a non-persisted edge stack object" -// @success 200 {object} portainer.EdgeStack -// @failure 400 "Bad request" -// @failure 500 "Internal server error" -// @failure 503 "Edge compute features are disabled" -// @router /edge_stacks/create/string [post] -func (handler *Handler) createSwarmStackFromFileContent(r *http.Request, dryrun bool) (*portainer.EdgeStack, error) { - var payload swarmStackFromFileContentPayload - err := request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return nil, err - } - - stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries, payload.UseManifestNamespaces) - if err != nil { - return nil, errors.Wrap(err, "failed to create Edge stack object") - } - - if dryrun { - return stack, nil - } - - return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { - return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, []byte(payload.StackFileContent)) - }) - -} - -func (handler *Handler) storeFileContent(stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) { - if deploymentType == portainer.EdgeStackDeploymentCompose { - composePath = filesystem.ComposeFileDefaultName - - projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, composePath, fileContent) - if err != nil { - return "", "", "", err - } - - manifestPath, err = handler.convertAndStoreKubeManifestIfNeeded(stackFolder, projectPath, composePath, relatedEndpointIds) - if err != nil { - return "", "", "", fmt.Errorf("Failed creating and storing kube manifest: %w", err) - } - - return composePath, manifestPath, projectPath, nil - - } - - hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds) - if err != nil { - return "", "", "", fmt.Errorf("unable to check for existence of docker environment: %w", err) - } - - if hasDockerEndpoint { - return "", "", "", errors.New("edge stack with docker environment cannot be deployed with kubernetes or nomad config") - } - - if deploymentType == portainer.EdgeStackDeploymentKubernetes { - - manifestPath = filesystem.ManifestFileDefaultName - - projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, fileContent) - if err != nil { - return "", "", "", err - } - - return "", manifestPath, projectPath, nil - - } - - errMessage := fmt.Sprintf("invalid deployment type: %d", deploymentType) - return "", "", "", edgestackservice.NewInvalidPayloadError(errMessage) -} - -type swarmStackFromGitRepositoryPayload struct { - // Name of the stack - Name string `example:"myStack" validate:"required"` - // URL of a Git repository hosting the Stack file - RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"` - // Reference name of a Git repository hosting the Stack file - RepositoryReferenceName string `example:"refs/heads/master"` - // Use basic authentication to clone the Git repository - RepositoryAuthentication bool `example:"true"` - // Username used in basic authentication. Required when RepositoryAuthentication is true. - RepositoryUsername string `example:"myGitUsername"` - // Password used in basic authentication. Required when RepositoryAuthentication is true. - RepositoryPassword string `example:"myGitPassword"` - // Path to the Stack file inside the Git repository - FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` - // List of identifiers of EdgeGroups - EdgeGroups []portainer.EdgeGroupID `example:"1"` - // Deployment type to deploy this stack - // Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad' - // for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints) - // kubernetes deploy type is enabled only for kubernetes environments(endpoints) - // nomad deploy type is enabled only for nomad environments(endpoints) - DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"` - // List of Registries to use for this stack - Registries []portainer.RegistryID - // Uses the manifest's namespaces instead of the default one - UseManifestNamespaces bool - // TLSSkipVerify skips SSL verification when cloning the Git repository - TLSSkipVerify bool `example:"false"` -} - -func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error { - if govalidator.IsNull(payload.Name) { - return edgestackservice.NewInvalidPayloadError("Invalid stack name") - } - if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { - return edgestackservice.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format") - } - if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { - return edgestackservice.NewInvalidPayloadError("Invalid repository credentials. Username and password must be specified when authentication is enabled") - } - if govalidator.IsNull(payload.FilePathInRepository) { - switch payload.DeploymentType { - case portainer.EdgeStackDeploymentCompose: - payload.FilePathInRepository = filesystem.ComposeFileDefaultName - case portainer.EdgeStackDeploymentKubernetes: - payload.FilePathInRepository = filesystem.ManifestFileDefaultName - } - } - if len(payload.EdgeGroups) == 0 { - return edgestackservice.NewInvalidPayloadError("Edge Groups are mandatory for an Edge stack") - } - return nil -} - -// @id EdgeStackCreateRepository -// @summary Create an EdgeStack from a git repository -// @description **Access policy**: administrator -// @tags edge_stacks -// @security ApiKeyAuth -// @security jwt -// @produce json -// @param method query string true "Creation Method" Enums(file,string,repository) -// @param body body swarmStackFromGitRepositoryPayload true "stack config" -// @param dryrun query string false "if true, will not create an edge stack, but just will check the settings and return a non-persisted edge stack object" -// @success 200 {object} portainer.EdgeStack -// @failure 400 "Bad request" -// @failure 500 "Internal server error" -// @failure 503 "Edge compute features are disabled" -// @router /edge_stacks/create/repository [post] -func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) { - var payload swarmStackFromGitRepositoryPayload - err := request.DecodeAndValidateJSONPayload(r, &payload) - if err != nil { - return nil, err - } - - stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries, payload.UseManifestNamespaces) - if err != nil { - return nil, errors.Wrap(err, "failed to create edge stack object") - } - - if dryrun { - return stack, nil - } - - repoConfig := gittypes.RepoConfig{ - URL: payload.RepositoryURL, - ReferenceName: payload.RepositoryReferenceName, - ConfigFilePath: payload.FilePathInRepository, - TLSSkipVerify: payload.TLSSkipVerify, - } - - if payload.RepositoryAuthentication { - repoConfig.Authentication = &gittypes.GitAuthentication{ - Username: payload.RepositoryUsername, - Password: payload.RepositoryPassword, - } - } - - return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { - return handler.storeManifestFromGitRepository(stackFolder, relatedEndpointIds, payload.DeploymentType, userID, repoConfig) - }) -} - -type swarmStackFromFileUploadPayload struct { - Name string - StackFileContent []byte - EdgeGroups []portainer.EdgeGroupID - // Deployment type to deploy this stack - // Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad' - // for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints) - // kubernetes deploytype is enabled only for kubernetes environments(endpoints) - // nomad deploytype is enabled only for nomad environments(endpoints) - DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"` - Registries []portainer.RegistryID - // Uses the manifest's namespaces instead of the default one - UseManifestNamespaces bool -} - -func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error { - name, err := request.RetrieveMultiPartFormValue(r, "Name", false) - if err != nil { - return edgestackservice.NewInvalidPayloadError("Invalid stack name") - } - payload.Name = name - - composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") - if err != nil { - return edgestackservice.NewInvalidPayloadError("Invalid Compose file. Ensure that the Compose file is uploaded correctly") - } - payload.StackFileContent = composeFileContent - - var edgeGroups []portainer.EdgeGroupID - err = request.RetrieveMultiPartFormJSONValue(r, "EdgeGroups", &edgeGroups, false) - if err != nil || len(edgeGroups) == 0 { - return edgestackservice.NewInvalidPayloadError("Edge Groups are mandatory for an Edge stack") - } - payload.EdgeGroups = edgeGroups - - deploymentType, err := request.RetrieveNumericMultiPartFormValue(r, "DeploymentType", false) - if err != nil { - return edgestackservice.NewInvalidPayloadError("Invalid deployment type") - } - payload.DeploymentType = portainer.EdgeStackDeploymentType(deploymentType) - - var registries []portainer.RegistryID - err = request.RetrieveMultiPartFormJSONValue(r, "Registries", ®istries, true) - if err != nil { - return errors.New("Invalid registry type") - } - payload.Registries = registries - - useManifestNamespaces, _ := request.RetrieveBooleanMultiPartFormValue(r, "UseManifestNamespaces", true) - payload.UseManifestNamespaces = useManifestNamespaces - - return nil -} - -// @id EdgeStackCreateFile -// @summary Create an EdgeStack from file -// @description **Access policy**: administrator -// @tags edge_stacks -// @security ApiKeyAuth -// @security jwt -// @accept multipart/form-data -// @produce json -// @param Name formData string true "Name of the stack" -// @param file formData file true "Content of the Stack file" -// @param EdgeGroups formData string true "JSON stringified array of Edge Groups ids" -// @param DeploymentType formData int true "deploy type 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad'" -// @param Registries formData string false "JSON stringified array of Registry ids to use for this stack" -// @param UseManifestNamespaces formData bool false "Uses the manifest's namespaces instead of the default one, relevant only for kube environments" -// @param PrePullImage formData bool false "Pre Pull image" -// @param RetryDeploy formData bool false "Retry deploy" -// @param dryrun query string false "if true, will not create an edge stack, but just will check the settings and return a non-persisted edge stack object" -// @success 200 {object} portainer.EdgeStack -// @failure 400 "Bad request" -// @failure 500 "Internal server error" -// @failure 503 "Edge compute features are disabled" -// @router /edge_stacks/create/file [post] -func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request, dryrun bool) (*portainer.EdgeStack, error) { - payload := &swarmStackFromFileUploadPayload{} - err := payload.Validate(r) - if err != nil { - return nil, err - } - - stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries, payload.UseManifestNamespaces) - if err != nil { - return nil, errors.Wrap(err, "failed to create edge stack object") - } - - if dryrun { - return stack, nil - } - - return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { - return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, payload.StackFileContent) - }) -} - -func (handler *Handler) storeManifestFromGitRepository(stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) { - projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder) - repositoryUsername := "" - repositoryPassword := "" - if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" { - repositoryUsername = repositoryConfig.Authentication.Username - repositoryPassword = repositoryConfig.Authentication.Password - } - - err = handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify) - if err != nil { - return "", "", "", err - } - - if deploymentType == portainer.EdgeStackDeploymentCompose { - composePath := repositoryConfig.ConfigFilePath - - manifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, projectPath, composePath, relatedEndpointIds) - if err != nil { - return "", "", "", fmt.Errorf("Failed creating and storing kube manifest: %w", err) - } - - return composePath, manifestPath, projectPath, nil - } - - if deploymentType == portainer.EdgeStackDeploymentKubernetes { - return "", repositoryConfig.ConfigFilePath, projectPath, nil - } - - errMessage := fmt.Sprintf("unknown deployment type: %d", deploymentType) - return "", "", "", edgestackservice.NewInvalidPayloadError(errMessage) + return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file") } diff --git a/api/http/handler/edgestacks/edgestack_create_file.go b/api/http/handler/edgestacks/edgestack_create_file.go new file mode 100644 index 000000000..60f86ffd2 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create_file.go @@ -0,0 +1,107 @@ +package edgestacks + +import ( + "net/http" + + "github.com/pkg/errors" + "github.com/portainer/libhttp/request" + portainer "github.com/portainer/portainer/api" + httperrors "github.com/portainer/portainer/api/http/errors" +) + +type edgeStackFromFileUploadPayload struct { + Name string + StackFileContent []byte + EdgeGroups []portainer.EdgeGroupID + // Deployment type to deploy this stack + // Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad' + // for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints) + // kubernetes deploytype is enabled only for kubernetes environments(endpoints) + // nomad deploytype is enabled only for nomad environments(endpoints) + DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"` + Registries []portainer.RegistryID + // Uses the manifest's namespaces instead of the default one + UseManifestNamespaces bool +} + +func (payload *edgeStackFromFileUploadPayload) Validate(r *http.Request) error { + name, err := request.RetrieveMultiPartFormValue(r, "Name", false) + if err != nil { + return httperrors.NewInvalidPayloadError("Invalid stack name") + } + payload.Name = name + + composeFileContent, _, err := request.RetrieveMultiPartFormFile(r, "file") + if err != nil { + return httperrors.NewInvalidPayloadError("Invalid Compose file. Ensure that the Compose file is uploaded correctly") + } + payload.StackFileContent = composeFileContent + + var edgeGroups []portainer.EdgeGroupID + err = request.RetrieveMultiPartFormJSONValue(r, "EdgeGroups", &edgeGroups, false) + if err != nil || len(edgeGroups) == 0 { + return httperrors.NewInvalidPayloadError("Edge Groups are mandatory for an Edge stack") + } + payload.EdgeGroups = edgeGroups + + deploymentType, err := request.RetrieveNumericMultiPartFormValue(r, "DeploymentType", false) + if err != nil { + return httperrors.NewInvalidPayloadError("Invalid deployment type") + } + payload.DeploymentType = portainer.EdgeStackDeploymentType(deploymentType) + + var registries []portainer.RegistryID + err = request.RetrieveMultiPartFormJSONValue(r, "Registries", ®istries, true) + if err != nil { + return httperrors.NewInvalidPayloadError("Invalid registry type") + } + payload.Registries = registries + + useManifestNamespaces, _ := request.RetrieveBooleanMultiPartFormValue(r, "UseManifestNamespaces", true) + payload.UseManifestNamespaces = useManifestNamespaces + + return nil +} + +// @id EdgeStackCreateFile +// @summary Create an EdgeStack from file +// @description **Access policy**: administrator +// @tags edge_stacks +// @security ApiKeyAuth +// @security jwt +// @accept multipart/form-data +// @produce json +// @param Name formData string true "Name of the stack" +// @param file formData file true "Content of the Stack file" +// @param EdgeGroups formData string true "JSON stringified array of Edge Groups ids" +// @param DeploymentType formData int true "deploy type 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad'" +// @param Registries formData string false "JSON stringified array of Registry ids to use for this stack" +// @param UseManifestNamespaces formData bool false "Uses the manifest's namespaces instead of the default one, relevant only for kube environments" +// @param PrePullImage formData bool false "Pre Pull image" +// @param RetryDeploy formData bool false "Retry deploy" +// @param dryrun query string false "if true, will not create an edge stack, but just will check the settings and return a non-persisted edge stack object" +// @success 200 {object} portainer.EdgeStack +// @failure 400 "Bad request" +// @failure 500 "Internal server error" +// @failure 503 "Edge compute features are disabled" +// @router /edge_stacks/create/file [post] +func (handler *Handler) createEdgeStackFromFileUpload(r *http.Request, dryrun bool) (*portainer.EdgeStack, error) { + payload := &edgeStackFromFileUploadPayload{} + err := payload.Validate(r) + if err != nil { + return nil, err + } + + stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries, payload.UseManifestNamespaces) + if err != nil { + return nil, errors.Wrap(err, "failed to create edge stack object") + } + + if dryrun { + return stack, nil + } + + return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { + return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, payload.StackFileContent) + }) +} diff --git a/api/http/handler/edgestacks/edgestack_create_git.go b/api/http/handler/edgestacks/edgestack_create_git.go new file mode 100644 index 000000000..d006e8fa5 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create_git.go @@ -0,0 +1,152 @@ +package edgestacks + +import ( + "fmt" + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/pkg/errors" + "github.com/portainer/libhttp/request" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + gittypes "github.com/portainer/portainer/api/git/types" + httperrors "github.com/portainer/portainer/api/http/errors" +) + +type edgeStackFromGitRepositoryPayload struct { + // Name of the stack + Name string `example:"myStack" validate:"required"` + // URL of a Git repository hosting the Stack file + RepositoryURL string `example:"https://github.com/openfaas/faas" validate:"required"` + // Reference name of a Git repository hosting the Stack file + RepositoryReferenceName string `example:"refs/heads/master"` + // Use basic authentication to clone the Git repository + RepositoryAuthentication bool `example:"true"` + // Username used in basic authentication. Required when RepositoryAuthentication is true. + RepositoryUsername string `example:"myGitUsername"` + // Password used in basic authentication. Required when RepositoryAuthentication is true. + RepositoryPassword string `example:"myGitPassword"` + // Path to the Stack file inside the Git repository + FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"` + // List of identifiers of EdgeGroups + EdgeGroups []portainer.EdgeGroupID `example:"1"` + // Deployment type to deploy this stack + // Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad' + // for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints) + // kubernetes deploy type is enabled only for kubernetes environments(endpoints) + // nomad deploy type is enabled only for nomad environments(endpoints) + DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"` + // List of Registries to use for this stack + Registries []portainer.RegistryID + // Uses the manifest's namespaces instead of the default one + UseManifestNamespaces bool + // TLSSkipVerify skips SSL verification when cloning the Git repository + TLSSkipVerify bool `example:"false"` +} + +func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return httperrors.NewInvalidPayloadError("Invalid stack name") + } + if govalidator.IsNull(payload.RepositoryURL) || !govalidator.IsURL(payload.RepositoryURL) { + return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format") + } + if payload.RepositoryAuthentication && govalidator.IsNull(payload.RepositoryPassword) { + return httperrors.NewInvalidPayloadError("Invalid repository credentials. Password must be specified when authentication is enabled") + } + if govalidator.IsNull(payload.FilePathInRepository) { + switch payload.DeploymentType { + case portainer.EdgeStackDeploymentCompose: + payload.FilePathInRepository = filesystem.ComposeFileDefaultName + case portainer.EdgeStackDeploymentKubernetes: + payload.FilePathInRepository = filesystem.ManifestFileDefaultName + } + } + if len(payload.EdgeGroups) == 0 { + return httperrors.NewInvalidPayloadError("Invalid edge groups. At least one edge group must be specified") + } + return nil +} + +// @id EdgeStackCreateRepository +// @summary Create an EdgeStack from a git repository +// @description **Access policy**: administrator +// @tags edge_stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param method query string true "Creation Method" Enums(file,string,repository) +// @param body body edgeStackFromGitRepositoryPayload true "stack config" +// @param dryrun query string false "if true, will not create an edge stack, but just will check the settings and return a non-persisted edge stack object" +// @success 200 {object} portainer.EdgeStack +// @failure 400 "Bad request" +// @failure 500 "Internal server error" +// @failure 503 "Edge compute features are disabled" +// @router /edge_stacks/create/repository [post] +func (handler *Handler) createEdgeStackFromGitRepository(r *http.Request, dryrun bool, userID portainer.UserID) (*portainer.EdgeStack, error) { + var payload edgeStackFromGitRepositoryPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return nil, err + } + + stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries, payload.UseManifestNamespaces) + if err != nil { + return nil, errors.Wrap(err, "failed to create edge stack object") + } + + if dryrun { + return stack, nil + } + + repoConfig := gittypes.RepoConfig{ + URL: payload.RepositoryURL, + ReferenceName: payload.RepositoryReferenceName, + ConfigFilePath: payload.FilePathInRepository, + TLSSkipVerify: payload.TLSSkipVerify, + } + + if payload.RepositoryAuthentication { + repoConfig.Authentication = &gittypes.GitAuthentication{ + Username: payload.RepositoryUsername, + Password: payload.RepositoryPassword, + } + } + + return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { + return handler.storeManifestFromGitRepository(stackFolder, relatedEndpointIds, payload.DeploymentType, userID, repoConfig) + }) +} + +func (handler *Handler) storeManifestFromGitRepository(stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) { + projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder) + repositoryUsername := "" + repositoryPassword := "" + if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" { + repositoryUsername = repositoryConfig.Authentication.Username + repositoryPassword = repositoryConfig.Authentication.Password + } + + err = handler.GitService.CloneRepository(projectPath, repositoryConfig.URL, repositoryConfig.ReferenceName, repositoryUsername, repositoryPassword, repositoryConfig.TLSSkipVerify) + if err != nil { + return "", "", "", err + } + + if deploymentType == portainer.EdgeStackDeploymentCompose { + composePath := repositoryConfig.ConfigFilePath + + manifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, projectPath, composePath, relatedEndpointIds) + if err != nil { + return "", "", "", fmt.Errorf("Failed creating and storing kube manifest: %w", err) + } + + return composePath, manifestPath, projectPath, nil + } + + if deploymentType == portainer.EdgeStackDeploymentKubernetes { + return "", repositoryConfig.ConfigFilePath, projectPath, nil + } + + errMessage := fmt.Sprintf("unknown deployment type: %d", deploymentType) + return "", "", "", httperrors.NewInvalidPayloadError(errMessage) +} diff --git a/api/http/handler/edgestacks/edgestack_create_string.go b/api/http/handler/edgestacks/edgestack_create_string.go new file mode 100644 index 000000000..4a4b92cc5 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create_string.go @@ -0,0 +1,125 @@ +package edgestacks + +import ( + "fmt" + "net/http" + + "github.com/asaskevich/govalidator" + "github.com/pkg/errors" + "github.com/portainer/libhttp/request" + portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/filesystem" + httperrors "github.com/portainer/portainer/api/http/errors" +) + +type edgeStackFromStringPayload struct { + // Name of the stack + Name string `example:"myStack" validate:"required"` + // Content of the Stack file + StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"` + // List of identifiers of EdgeGroups + EdgeGroups []portainer.EdgeGroupID `example:"1"` + // Deployment type to deploy this stack + // Valid values are: 0 - 'compose', 1 - 'kubernetes', 2 - 'nomad' + // for compose stacks will use kompose to convert to kubernetes manifest for kubernetes environments(endpoints) + // kubernetes deploy type is enabled only for kubernetes environments(endpoints) + // nomad deploy type is enabled only for nomad environments(endpoints) + DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1,2"` + // List of Registries to use for this stack + Registries []portainer.RegistryID + // Uses the manifest's namespaces instead of the default one + UseManifestNamespaces bool +} + +func (payload *edgeStackFromStringPayload) Validate(r *http.Request) error { + if govalidator.IsNull(payload.Name) { + return httperrors.NewInvalidPayloadError("Invalid stack name") + } + if govalidator.IsNull(payload.StackFileContent) { + return httperrors.NewInvalidPayloadError("Invalid stack file content") + } + if len(payload.EdgeGroups) == 0 { + return httperrors.NewInvalidPayloadError("Edge Groups are mandatory for an Edge stack") + } + return nil +} + +// @id EdgeStackCreateString +// @summary Create an EdgeStack from a text +// @description **Access policy**: administrator +// @tags edge_stacks +// @security ApiKeyAuth +// @security jwt +// @produce json +// @param body body edgeStackFromStringPayload true "stack config" +// @param dryrun query string false "if true, will not create an edge stack, but just will check the settings and return a non-persisted edge stack object" +// @success 200 {object} portainer.EdgeStack +// @failure 400 "Bad request" +// @failure 500 "Internal server error" +// @failure 503 "Edge compute features are disabled" +// @router /edge_stacks/create/string [post] +func (handler *Handler) createEdgeStackFromFileContent(r *http.Request, dryrun bool) (*portainer.EdgeStack, error) { + var payload edgeStackFromStringPayload + err := request.DecodeAndValidateJSONPayload(r, &payload) + if err != nil { + return nil, err + } + + stack, err := handler.edgeStacksService.BuildEdgeStack(payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries, payload.UseManifestNamespaces) + if err != nil { + return nil, errors.Wrap(err, "failed to create Edge stack object") + } + + if dryrun { + return stack, nil + } + + return handler.edgeStacksService.PersistEdgeStack(stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) { + return handler.storeFileContent(stackFolder, payload.DeploymentType, relatedEndpointIds, []byte(payload.StackFileContent)) + }) + +} + +func (handler *Handler) storeFileContent(stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) { + if deploymentType == portainer.EdgeStackDeploymentCompose { + composePath = filesystem.ComposeFileDefaultName + + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, composePath, fileContent) + if err != nil { + return "", "", "", err + } + + manifestPath, err = handler.convertAndStoreKubeManifestIfNeeded(stackFolder, projectPath, composePath, relatedEndpointIds) + if err != nil { + return "", "", "", fmt.Errorf("Failed creating and storing kube manifest: %w", err) + } + + return composePath, manifestPath, projectPath, nil + + } + + hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds) + if err != nil { + return "", "", "", fmt.Errorf("unable to check for existence of docker environment: %w", err) + } + + if hasDockerEndpoint { + return "", "", "", errors.New("edge stack with docker environment cannot be deployed with kubernetes or nomad config") + } + + if deploymentType == portainer.EdgeStackDeploymentKubernetes { + + manifestPath = filesystem.ManifestFileDefaultName + + projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, fileContent) + if err != nil { + return "", "", "", err + } + + return "", manifestPath, projectPath, nil + + } + + errMessage := fmt.Sprintf("invalid deployment type: %d", deploymentType) + return "", "", "", httperrors.NewInvalidPayloadError(errMessage) +} diff --git a/api/http/handler/edgestacks/edgestack_create_test.go b/api/http/handler/edgestacks/edgestack_create_test.go new file mode 100644 index 000000000..26ae247a3 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create_test.go @@ -0,0 +1,225 @@ +package edgestacks + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" +) + +// Create +func TestCreateAndInspect(t *testing.T) { + handler, rawAPIKey, teardown := setupHandler(t) + defer teardown() + + // Create Endpoint, EdgeGroup and EndpointRelation + endpoint := createEndpoint(t, handler.DataStore) + edgeGroup := portainer.EdgeGroup{ + ID: 1, + Name: "EdgeGroup 1", + Dynamic: false, + TagIDs: nil, + Endpoints: []portainer.EndpointID{endpoint.ID}, + PartialMatch: false, + } + + err := handler.DataStore.EdgeGroup().Create(&edgeGroup) + if err != nil { + t.Fatal(err) + } + + endpointRelation := portainer.EndpointRelation{ + EndpointID: endpoint.ID, + EdgeStacks: map[portainer.EdgeStackID]bool{}, + } + + err = handler.DataStore.EndpointRelation().Create(&endpointRelation) + if err != nil { + t.Fatal(err) + } + + payload := edgeStackFromStringPayload{ + Name: "Test Stack", + StackFileContent: "stack content", + EdgeGroups: []portainer.EdgeGroupID{1}, + DeploymentType: portainer.EdgeStackDeploymentCompose, + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + t.Fatal("JSON marshal error:", err) + } + r := bytes.NewBuffer(jsonPayload) + + // Create EdgeStack + req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", r) + if err != nil { + t.Fatal("request error:", err) + } + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + data := portainer.EdgeStack{} + err = json.NewDecoder(rec.Body).Decode(&data) + if err != nil { + t.Fatal("error decoding response:", err) + } + + // Inspect + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + data = portainer.EdgeStack{} + err = json.NewDecoder(rec.Body).Decode(&data) + if err != nil { + t.Fatal("error decoding response:", err) + } + + if payload.Name != data.Name { + t.Fatalf("expected EdgeStack Name %s, found %s", payload.Name, data.Name) + } +} + +func TestCreateWithInvalidPayload(t *testing.T) { + handler, rawAPIKey, teardown := setupHandler(t) + defer teardown() + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + cases := []struct { + Name string + Payload interface{} + ExpectedStatusCode int + Method string + }{ + { + Name: "Invalid method parameter", + Payload: edgeStackFromStringPayload{}, + Method: "invalid", + ExpectedStatusCode: 400, + }, + + { + Name: "Empty edgeStackFromStringPayload with string method", + Payload: edgeStackFromStringPayload{}, + Method: "string", + ExpectedStatusCode: 400, + }, + { + Name: "Empty edgeStackFromStringPayload with repository method", + Payload: edgeStackFromStringPayload{}, + Method: "repository", + ExpectedStatusCode: 400, + }, + { + Name: "Empty edgeStackFromStringPayload with file method", + Payload: edgeStackFromStringPayload{}, + Method: "file", + ExpectedStatusCode: 400, + }, + { + Name: "Duplicated EdgeStack Name", + Payload: edgeStackFromStringPayload{ + Name: edgeStack.Name, + StackFileContent: "content", + EdgeGroups: edgeStack.EdgeGroups, + DeploymentType: edgeStack.DeploymentType, + }, + Method: "string", + ExpectedStatusCode: 500, + }, + { + Name: "Empty EdgeStack Groups", + Payload: edgeStackFromStringPayload{ + Name: edgeStack.Name, + StackFileContent: "content", + EdgeGroups: []portainer.EdgeGroupID{}, + DeploymentType: edgeStack.DeploymentType, + }, + Method: "string", + ExpectedStatusCode: 400, + }, + { + Name: "EdgeStackDeploymentKubernetes with Docker endpoint", + Payload: edgeStackFromStringPayload{ + Name: "Stack name", + StackFileContent: "content", + EdgeGroups: []portainer.EdgeGroupID{1}, + DeploymentType: portainer.EdgeStackDeploymentKubernetes, + }, + Method: "string", + ExpectedStatusCode: 500, + }, + { + Name: "Empty Stack File Content", + Payload: edgeStackFromStringPayload{ + Name: "Stack name", + StackFileContent: "", + EdgeGroups: []portainer.EdgeGroupID{1}, + DeploymentType: portainer.EdgeStackDeploymentCompose, + }, + Method: "string", + ExpectedStatusCode: 400, + }, + { + Name: "Clone Git repository error", + Payload: edgeStackFromGitRepositoryPayload{ + Name: "Stack name", + RepositoryURL: "github.com/portainer/portainer", + RepositoryReferenceName: "ref name", + RepositoryAuthentication: false, + RepositoryUsername: "", + RepositoryPassword: "", + FilePathInRepository: "/file/path", + EdgeGroups: []portainer.EdgeGroupID{1}, + DeploymentType: portainer.EdgeStackDeploymentCompose, + }, + Method: "repository", + ExpectedStatusCode: 500, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + jsonPayload, err := json.Marshal(tc.Payload) + if err != nil { + t.Fatal("JSON marshal error:", err) + } + r := bytes.NewBuffer(jsonPayload) + + // Create EdgeStack + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("/edge_stacks/create/%s", tc.Method), r) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} diff --git a/api/http/handler/edgestacks/edgestack_delete_test.go b/api/http/handler/edgestacks/edgestack_delete_test.go new file mode 100644 index 000000000..dacf94898 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_delete_test.go @@ -0,0 +1,104 @@ +package edgestacks + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" +) + +// Delete +func TestDeleteAndInspect(t *testing.T) { + handler, rawAPIKey, teardown := setupHandler(t) + defer teardown() + + // Create + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + // Inspect + req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + data := portainer.EdgeStack{} + err = json.NewDecoder(rec.Body).Decode(&data) + if err != nil { + t.Fatal("error decoding response:", err) + } + + if data.ID != edgeStack.ID { + t.Fatalf("expected EdgeStackID %d, found %d", int(edgeStack.ID), data.ID) + } + + // Delete + req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code) + } + + // Inspect + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected a %d response, found: %d", http.StatusNotFound, rec.Code) + } +} + +func TestDeleteInvalidEdgeStack(t *testing.T) { + handler, rawAPIKey, teardown := setupHandler(t) + defer teardown() + + cases := []struct { + Name string + URL string + ExpectedStatusCode int + }{ + {Name: "Non-existing EdgeStackID", URL: "/edge_stacks/-1", ExpectedStatusCode: http.StatusNotFound}, + {Name: "Invalid EdgeStackID", URL: "/edge_stacks/aaaaaaa", ExpectedStatusCode: http.StatusBadRequest}, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodDelete, tc.URL, nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} diff --git a/api/http/handler/edgestacks/edgestack_inspect_test.go b/api/http/handler/edgestacks/edgestack_inspect_test.go new file mode 100644 index 000000000..f76797a99 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_inspect_test.go @@ -0,0 +1,39 @@ +package edgestacks + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +// Inspect +func TestInspectInvalidEdgeID(t *testing.T) { + handler, rawAPIKey, teardown := setupHandler(t) + defer teardown() + + cases := []struct { + Name string + EdgeStackID string + ExpectedStatusCode int + }{ + {"Invalid EdgeStackID", "x", 400}, + {"Non-existing EdgeStackID", "5", 404}, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + req, err := http.NewRequest(http.MethodGet, "/edge_stacks/"+tc.EdgeStackID, nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} diff --git a/api/http/handler/edgestacks/edgestack_status_delete_test.go b/api/http/handler/edgestacks/edgestack_status_delete_test.go new file mode 100644 index 000000000..0487282e5 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_status_delete_test.go @@ -0,0 +1,31 @@ +package edgestacks + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" +) + +func TestDeleteStatus(t *testing.T) { + handler, _, teardown := setupHandler(t) + defer teardown() + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d/status/%d", edgeStack.ID, endpoint.ID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } +} diff --git a/api/http/handler/edgestacks/edgestack_status_update_test.go b/api/http/handler/edgestacks/edgestack_status_update_test.go new file mode 100644 index 000000000..77d844e20 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_status_update_test.go @@ -0,0 +1,151 @@ +package edgestacks + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + portainer "github.com/portainer/portainer/api" +) + +// Update Status +func TestUpdateStatusAndInspect(t *testing.T) { + handler, rawAPIKey, teardown := setupHandler(t) + defer teardown() + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + // Update edge stack status + newStatus := portainer.EdgeStackStatusError + payload := updateStatusPayload{ + Error: "test-error", + Status: &newStatus, + EndpointID: endpoint.ID, + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + t.Fatal("request error:", err) + } + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + // Get updated edge stack + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + data := portainer.EdgeStack{} + err = json.NewDecoder(rec.Body).Decode(&data) + if err != nil { + t.Fatal("error decoding response:", err) + } + + if !data.Status[endpoint.ID].Details.Error { + t.Fatalf("expected EdgeStackStatusType %d, found %t", payload.Status, data.Status[endpoint.ID].Details.Error) + } + + if data.Status[endpoint.ID].Error != payload.Error { + t.Fatalf("expected EdgeStackStatusError %s, found %s", payload.Error, data.Status[endpoint.ID].Error) + } + + if data.Status[endpoint.ID].EndpointID != payload.EndpointID { + t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, data.Status[endpoint.ID].EndpointID) + } +} +func TestUpdateStatusWithInvalidPayload(t *testing.T) { + handler, _, teardown := setupHandler(t) + defer teardown() + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + // Update edge stack status + statusError := portainer.EdgeStackStatusError + statusOk := portainer.EdgeStackStatusOk + cases := []struct { + Name string + Payload updateStatusPayload + ExpectedErrorMessage string + ExpectedStatusCode int + }{ + { + "Update with nil Status", + updateStatusPayload{ + Error: "test-error", + Status: nil, + EndpointID: endpoint.ID, + }, + "Invalid status", + 400, + }, + { + "Update with error status and empty error message", + updateStatusPayload{ + Error: "", + Status: &statusError, + EndpointID: endpoint.ID, + }, + "Error message is mandatory when status is error", + 400, + }, + { + "Update with missing EndpointID", + updateStatusPayload{ + Error: "", + Status: &statusOk, + EndpointID: 0, + }, + "Invalid EnvironmentID", + 400, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + jsonPayload, err := json.Marshal(tc.Payload) + if err != nil { + t.Fatal("request error:", err) + } + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} diff --git a/api/http/handler/edgestacks/edgestack_test.go b/api/http/handler/edgestacks/edgestack_test.go index ebc6917b7..28c6de708 100644 --- a/api/http/handler/edgestacks/edgestack_test.go +++ b/api/http/handler/edgestacks/edgestack_test.go @@ -1,12 +1,6 @@ package edgestacks import ( - "bytes" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "reflect" "strconv" "testing" "time" @@ -159,745 +153,3 @@ func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID port return edgeStack } - -// Inspect -func TestInspectInvalidEdgeID(t *testing.T) { - handler, rawAPIKey, teardown := setupHandler(t) - defer teardown() - - cases := []struct { - Name string - EdgeStackID string - ExpectedStatusCode int - }{ - {"Invalid EdgeStackID", "x", 400}, - {"Non-existing EdgeStackID", "5", 404}, - } - - for _, tc := range cases { - t.Run(tc.Name, func(t *testing.T) { - req, err := http.NewRequest(http.MethodGet, "/edge_stacks/"+tc.EdgeStackID, nil) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != tc.ExpectedStatusCode { - t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) - } - }) - } -} - -// Create -func TestCreateAndInspect(t *testing.T) { - handler, rawAPIKey, teardown := setupHandler(t) - defer teardown() - - // Create Endpoint, EdgeGroup and EndpointRelation - endpoint := createEndpoint(t, handler.DataStore) - edgeGroup := portainer.EdgeGroup{ - ID: 1, - Name: "EdgeGroup 1", - Dynamic: false, - TagIDs: nil, - Endpoints: []portainer.EndpointID{endpoint.ID}, - PartialMatch: false, - } - - err := handler.DataStore.EdgeGroup().Create(&edgeGroup) - if err != nil { - t.Fatal(err) - } - - endpointRelation := portainer.EndpointRelation{ - EndpointID: endpoint.ID, - EdgeStacks: map[portainer.EdgeStackID]bool{}, - } - - err = handler.DataStore.EndpointRelation().Create(&endpointRelation) - if err != nil { - t.Fatal(err) - } - - payload := swarmStackFromFileContentPayload{ - Name: "Test Stack", - StackFileContent: "stack content", - EdgeGroups: []portainer.EdgeGroupID{1}, - DeploymentType: portainer.EdgeStackDeploymentCompose, - } - - jsonPayload, err := json.Marshal(payload) - if err != nil { - t.Fatal("JSON marshal error:", err) - } - r := bytes.NewBuffer(jsonPayload) - - // Create EdgeStack - req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", r) - if err != nil { - t.Fatal("request error:", err) - } - req.Header.Add("x-api-key", rawAPIKey) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) - } - - data := portainer.EdgeStack{} - err = json.NewDecoder(rec.Body).Decode(&data) - if err != nil { - t.Fatal("error decoding response:", err) - } - - // Inspect - req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec = httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) - } - - data = portainer.EdgeStack{} - err = json.NewDecoder(rec.Body).Decode(&data) - if err != nil { - t.Fatal("error decoding response:", err) - } - - if payload.Name != data.Name { - t.Fatalf("expected EdgeStack Name %s, found %s", payload.Name, data.Name) - } -} - -func TestCreateWithInvalidPayload(t *testing.T) { - handler, rawAPIKey, teardown := setupHandler(t) - defer teardown() - - endpoint := createEndpoint(t, handler.DataStore) - edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) - - cases := []struct { - Name string - Payload interface{} - ExpectedStatusCode int - Method string - }{ - { - Name: "Invalid method parameter", - Payload: swarmStackFromFileContentPayload{}, - Method: "invalid", - ExpectedStatusCode: 400, - }, - - { - Name: "Empty swarmStackFromFileContentPayload with string method", - Payload: swarmStackFromFileContentPayload{}, - Method: "string", - ExpectedStatusCode: 400, - }, - { - Name: "Empty swarmStackFromFileContentPayload with repository method", - Payload: swarmStackFromFileContentPayload{}, - Method: "repository", - ExpectedStatusCode: 400, - }, - { - Name: "Empty swarmStackFromFileContentPayload with file method", - Payload: swarmStackFromFileContentPayload{}, - Method: "file", - ExpectedStatusCode: 400, - }, - { - Name: "Duplicated EdgeStack Name", - Payload: swarmStackFromFileContentPayload{ - Name: edgeStack.Name, - StackFileContent: "content", - EdgeGroups: edgeStack.EdgeGroups, - DeploymentType: edgeStack.DeploymentType, - }, - Method: "string", - ExpectedStatusCode: 500, - }, - { - Name: "Empty EdgeStack Groups", - Payload: swarmStackFromFileContentPayload{ - Name: edgeStack.Name, - StackFileContent: "content", - EdgeGroups: []portainer.EdgeGroupID{}, - DeploymentType: edgeStack.DeploymentType, - }, - Method: "string", - ExpectedStatusCode: 400, - }, - { - Name: "EdgeStackDeploymentKubernetes with Docker endpoint", - Payload: swarmStackFromFileContentPayload{ - Name: "Stack name", - StackFileContent: "content", - EdgeGroups: []portainer.EdgeGroupID{1}, - DeploymentType: portainer.EdgeStackDeploymentKubernetes, - }, - Method: "string", - ExpectedStatusCode: 500, - }, - { - Name: "Empty Stack File Content", - Payload: swarmStackFromFileContentPayload{ - Name: "Stack name", - StackFileContent: "", - EdgeGroups: []portainer.EdgeGroupID{1}, - DeploymentType: portainer.EdgeStackDeploymentCompose, - }, - Method: "string", - ExpectedStatusCode: 400, - }, - { - Name: "Clone Git respository error", - Payload: swarmStackFromGitRepositoryPayload{ - Name: "Stack name", - RepositoryURL: "github.com/portainer/portainer", - RepositoryReferenceName: "ref name", - RepositoryAuthentication: false, - RepositoryUsername: "", - RepositoryPassword: "", - FilePathInRepository: "/file/path", - EdgeGroups: []portainer.EdgeGroupID{1}, - DeploymentType: portainer.EdgeStackDeploymentCompose, - }, - Method: "repository", - ExpectedStatusCode: 500, - }, - } - - for _, tc := range cases { - t.Run(tc.Name, func(t *testing.T) { - jsonPayload, err := json.Marshal(tc.Payload) - if err != nil { - t.Fatal("JSON marshal error:", err) - } - r := bytes.NewBuffer(jsonPayload) - - // Create EdgeStack - req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("/edge_stacks/create/%s", tc.Method), r) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != tc.ExpectedStatusCode { - t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) - } - }) - } -} - -// Delete -func TestDeleteAndInspect(t *testing.T) { - handler, rawAPIKey, teardown := setupHandler(t) - defer teardown() - - // Create - endpoint := createEndpoint(t, handler.DataStore) - edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) - - // Inspect - req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) - } - - data := portainer.EdgeStack{} - err = json.NewDecoder(rec.Body).Decode(&data) - if err != nil { - t.Fatal("error decoding response:", err) - } - - if data.ID != edgeStack.ID { - t.Fatalf("expected EdgeStackID %d, found %d", int(edgeStack.ID), data.ID) - } - - // Delete - req, err = http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec = httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusNoContent { - t.Fatalf("expected a %d response, found: %d", http.StatusNoContent, rec.Code) - } - - // Inspect - req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec = httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusNotFound { - t.Fatalf("expected a %d response, found: %d", http.StatusNotFound, rec.Code) - } -} - -func TestDeleteInvalidEdgeStack(t *testing.T) { - handler, rawAPIKey, teardown := setupHandler(t) - defer teardown() - - cases := []struct { - Name string - URL string - ExpectedStatusCode int - }{ - {Name: "Non-existing EdgeStackID", URL: "/edge_stacks/-1", ExpectedStatusCode: http.StatusNotFound}, - {Name: "Invalid EdgeStackID", URL: "/edge_stacks/aaaaaaa", ExpectedStatusCode: http.StatusBadRequest}, - } - - for _, tc := range cases { - t.Run(tc.Name, func(t *testing.T) { - req, err := http.NewRequest(http.MethodDelete, tc.URL, nil) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != tc.ExpectedStatusCode { - t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) - } - }) - } -} - -// Update -func TestUpdateAndInspect(t *testing.T) { - handler, rawAPIKey, teardown := setupHandler(t) - defer teardown() - - endpoint := createEndpoint(t, handler.DataStore) - edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) - - // Update edge stack: create new Endpoint, EndpointRelation and EdgeGroup - endpointID := portainer.EndpointID(6) - newEndpoint := createEndpointWithId(t, handler.DataStore, endpointID) - - err := handler.DataStore.Endpoint().Create(&newEndpoint) - if err != nil { - t.Fatal(err) - } - - endpointRelation := portainer.EndpointRelation{ - EndpointID: endpointID, - EdgeStacks: map[portainer.EdgeStackID]bool{ - edgeStack.ID: true, - }, - } - - err = handler.DataStore.EndpointRelation().Create(&endpointRelation) - if err != nil { - t.Fatal(err) - } - - newEdgeGroup := portainer.EdgeGroup{ - ID: 2, - Name: "EdgeGroup 2", - Dynamic: false, - TagIDs: nil, - Endpoints: []portainer.EndpointID{newEndpoint.ID}, - PartialMatch: false, - } - - err = handler.DataStore.EdgeGroup().Create(&newEdgeGroup) - if err != nil { - t.Fatal(err) - } - - newVersion := 238 - payload := updateEdgeStackPayload{ - StackFileContent: "update-test", - Version: &newVersion, - EdgeGroups: append(edgeStack.EdgeGroups, newEdgeGroup.ID), - DeploymentType: portainer.EdgeStackDeploymentCompose, - } - - jsonPayload, err := json.Marshal(payload) - if err != nil { - t.Fatal("request error:", err) - } - - r := bytes.NewBuffer(jsonPayload) - req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) - } - - // Get updated edge stack - req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec = httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) - } - - data := portainer.EdgeStack{} - err = json.NewDecoder(rec.Body).Decode(&data) - if err != nil { - t.Fatal("error decoding response:", err) - } - - if data.Version != *payload.Version { - t.Fatalf("expected EdgeStackID %d, found %d", edgeStack.Version, data.Version) - } - - if data.DeploymentType != payload.DeploymentType { - t.Fatalf("expected DeploymentType %d, found %d", edgeStack.DeploymentType, data.DeploymentType) - } - - if !reflect.DeepEqual(data.EdgeGroups, payload.EdgeGroups) { - t.Fatalf("expected EdgeGroups to be equal") - } -} - -func TestUpdateWithInvalidEdgeGroups(t *testing.T) { - handler, rawAPIKey, teardown := setupHandler(t) - defer teardown() - - endpoint := createEndpoint(t, handler.DataStore) - edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) - - //newEndpoint := createEndpoint(t, handler.DataStore) - newEdgeGroup := portainer.EdgeGroup{ - ID: 2, - Name: "EdgeGroup 2", - Dynamic: false, - TagIDs: nil, - Endpoints: []portainer.EndpointID{8889}, - PartialMatch: false, - } - - handler.DataStore.EdgeGroup().Create(&newEdgeGroup) - - newVersion := 238 - cases := []struct { - Name string - Payload updateEdgeStackPayload - ExpectedStatusCode int - }{ - { - "Update with non-existing EdgeGroupID", - updateEdgeStackPayload{ - StackFileContent: "error-test", - Version: &newVersion, - EdgeGroups: []portainer.EdgeGroupID{9999}, - DeploymentType: edgeStack.DeploymentType, - }, - http.StatusInternalServerError, - }, - { - "Update with invalid EdgeGroup (non-existing Endpoint)", - updateEdgeStackPayload{ - StackFileContent: "error-test", - Version: &newVersion, - EdgeGroups: []portainer.EdgeGroupID{2}, - DeploymentType: edgeStack.DeploymentType, - }, - http.StatusInternalServerError, - }, - { - "Update DeploymentType from Docker to Kubernetes", - updateEdgeStackPayload{ - StackFileContent: "error-test", - Version: &newVersion, - EdgeGroups: []portainer.EdgeGroupID{1}, - DeploymentType: portainer.EdgeStackDeploymentKubernetes, - }, - http.StatusBadRequest, - }, - } - - for _, tc := range cases { - t.Run(tc.Name, func(t *testing.T) { - jsonPayload, err := json.Marshal(tc.Payload) - if err != nil { - t.Fatal("JSON marshal error:", err) - } - - r := bytes.NewBuffer(jsonPayload) - req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != tc.ExpectedStatusCode { - t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) - } - }) - } -} - -func TestUpdateWithInvalidPayload(t *testing.T) { - handler, rawAPIKey, teardown := setupHandler(t) - defer teardown() - - endpoint := createEndpoint(t, handler.DataStore) - edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) - - newVersion := 238 - cases := []struct { - Name string - Payload updateEdgeStackPayload - ExpectedStatusCode int - }{ - { - "Update with empty StackFileContent", - updateEdgeStackPayload{ - StackFileContent: "", - Version: &newVersion, - EdgeGroups: edgeStack.EdgeGroups, - DeploymentType: edgeStack.DeploymentType, - }, - http.StatusBadRequest, - }, - { - "Update with empty EdgeGroups", - updateEdgeStackPayload{ - StackFileContent: "error-test", - Version: &newVersion, - EdgeGroups: []portainer.EdgeGroupID{}, - DeploymentType: edgeStack.DeploymentType, - }, - http.StatusBadRequest, - }, - } - - for _, tc := range cases { - t.Run(tc.Name, func(t *testing.T) { - jsonPayload, err := json.Marshal(tc.Payload) - if err != nil { - t.Fatal("request error:", err) - } - - r := bytes.NewBuffer(jsonPayload) - req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != tc.ExpectedStatusCode { - t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) - } - }) - } -} - -// Update Status -func TestUpdateStatusAndInspect(t *testing.T) { - handler, rawAPIKey, teardown := setupHandler(t) - defer teardown() - - endpoint := createEndpoint(t, handler.DataStore) - edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) - - // Update edge stack status - newStatus := portainer.EdgeStackStatusError - payload := updateStatusPayload{ - Error: "test-error", - Status: &newStatus, - EndpointID: endpoint.ID, - } - - jsonPayload, err := json.Marshal(payload) - if err != nil { - t.Fatal("request error:", err) - } - - r := bytes.NewBuffer(jsonPayload) - req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) - } - - // Get updated edge stack - req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Add("x-api-key", rawAPIKey) - rec = httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) - } - - data := portainer.EdgeStack{} - err = json.NewDecoder(rec.Body).Decode(&data) - if err != nil { - t.Fatal("error decoding response:", err) - } - - if !data.Status[endpoint.ID].Details.Error { - t.Fatalf("expected EdgeStackStatusType %d, found %t", payload.Status, data.Status[endpoint.ID].Details.Error) - } - - if data.Status[endpoint.ID].Error != payload.Error { - t.Fatalf("expected EdgeStackStatusError %s, found %s", payload.Error, data.Status[endpoint.ID].Error) - } - - if data.Status[endpoint.ID].EndpointID != payload.EndpointID { - t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, data.Status[endpoint.ID].EndpointID) - } -} -func TestUpdateStatusWithInvalidPayload(t *testing.T) { - handler, _, teardown := setupHandler(t) - defer teardown() - - endpoint := createEndpoint(t, handler.DataStore) - edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) - - // Update edge stack status - statusError := portainer.EdgeStackStatusError - statusOk := portainer.EdgeStackStatusOk - cases := []struct { - Name string - Payload updateStatusPayload - ExpectedErrorMessage string - ExpectedStatusCode int - }{ - { - "Update with nil Status", - updateStatusPayload{ - Error: "test-error", - Status: nil, - EndpointID: endpoint.ID, - }, - "Invalid status", - 400, - }, - { - "Update with error status and empty error message", - updateStatusPayload{ - Error: "", - Status: &statusError, - EndpointID: endpoint.ID, - }, - "Error message is mandatory when status is error", - 400, - }, - { - "Update with missing EndpointID", - updateStatusPayload{ - Error: "", - Status: &statusOk, - EndpointID: 0, - }, - "Invalid EnvironmentID", - 400, - }, - } - - for _, tc := range cases { - t.Run(tc.Name, func(t *testing.T) { - jsonPayload, err := json.Marshal(tc.Payload) - if err != nil { - t.Fatal("request error:", err) - } - - r := bytes.NewBuffer(jsonPayload) - req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != tc.ExpectedStatusCode { - t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) - } - }) - } -} - -// Delete Status -func TestDeleteStatus(t *testing.T) { - handler, _, teardown := setupHandler(t) - defer teardown() - - endpoint := createEndpoint(t, handler.DataStore) - edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) - - req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("/edge_stacks/%d/status/%d", edgeStack.ID, endpoint.ID), nil) - if err != nil { - t.Fatal("request error:", err) - } - - req.Header.Set(portainer.PortainerAgentEdgeIDHeader, endpoint.EdgeID) - rec := httptest.NewRecorder() - handler.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) - } -} diff --git a/api/http/handler/edgestacks/edgestack_update_test.go b/api/http/handler/edgestacks/edgestack_update_test.go new file mode 100644 index 000000000..874ee1448 --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_update_test.go @@ -0,0 +1,256 @@ +package edgestacks + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "reflect" + "testing" + + portainer "github.com/portainer/portainer/api" +) + +// Update +func TestUpdateAndInspect(t *testing.T) { + handler, rawAPIKey, teardown := setupHandler(t) + defer teardown() + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + // Update edge stack: create new Endpoint, EndpointRelation and EdgeGroup + endpointID := portainer.EndpointID(6) + newEndpoint := createEndpointWithId(t, handler.DataStore, endpointID) + + err := handler.DataStore.Endpoint().Create(&newEndpoint) + if err != nil { + t.Fatal(err) + } + + endpointRelation := portainer.EndpointRelation{ + EndpointID: endpointID, + EdgeStacks: map[portainer.EdgeStackID]bool{ + edgeStack.ID: true, + }, + } + + err = handler.DataStore.EndpointRelation().Create(&endpointRelation) + if err != nil { + t.Fatal(err) + } + + newEdgeGroup := portainer.EdgeGroup{ + ID: 2, + Name: "EdgeGroup 2", + Dynamic: false, + TagIDs: nil, + Endpoints: []portainer.EndpointID{newEndpoint.ID}, + PartialMatch: false, + } + + err = handler.DataStore.EdgeGroup().Create(&newEdgeGroup) + if err != nil { + t.Fatal(err) + } + + newVersion := 238 + payload := updateEdgeStackPayload{ + StackFileContent: "update-test", + Version: &newVersion, + EdgeGroups: append(edgeStack.EdgeGroups, newEdgeGroup.ID), + DeploymentType: portainer.EdgeStackDeploymentCompose, + } + + jsonPayload, err := json.Marshal(payload) + if err != nil { + t.Fatal("request error:", err) + } + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + // Get updated edge stack + req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), nil) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec = httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected a %d response, found: %d", http.StatusOK, rec.Code) + } + + data := portainer.EdgeStack{} + err = json.NewDecoder(rec.Body).Decode(&data) + if err != nil { + t.Fatal("error decoding response:", err) + } + + if data.Version != *payload.Version { + t.Fatalf("expected EdgeStackID %d, found %d", edgeStack.Version, data.Version) + } + + if data.DeploymentType != payload.DeploymentType { + t.Fatalf("expected DeploymentType %d, found %d", edgeStack.DeploymentType, data.DeploymentType) + } + + if !reflect.DeepEqual(data.EdgeGroups, payload.EdgeGroups) { + t.Fatalf("expected EdgeGroups to be equal") + } +} + +func TestUpdateWithInvalidEdgeGroups(t *testing.T) { + handler, rawAPIKey, teardown := setupHandler(t) + defer teardown() + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + //newEndpoint := createEndpoint(t, handler.DataStore) + newEdgeGroup := portainer.EdgeGroup{ + ID: 2, + Name: "EdgeGroup 2", + Dynamic: false, + TagIDs: nil, + Endpoints: []portainer.EndpointID{8889}, + PartialMatch: false, + } + + handler.DataStore.EdgeGroup().Create(&newEdgeGroup) + + newVersion := 238 + cases := []struct { + Name string + Payload updateEdgeStackPayload + ExpectedStatusCode int + }{ + { + "Update with non-existing EdgeGroupID", + updateEdgeStackPayload{ + StackFileContent: "error-test", + Version: &newVersion, + EdgeGroups: []portainer.EdgeGroupID{9999}, + DeploymentType: edgeStack.DeploymentType, + }, + http.StatusInternalServerError, + }, + { + "Update with invalid EdgeGroup (non-existing Endpoint)", + updateEdgeStackPayload{ + StackFileContent: "error-test", + Version: &newVersion, + EdgeGroups: []portainer.EdgeGroupID{2}, + DeploymentType: edgeStack.DeploymentType, + }, + http.StatusInternalServerError, + }, + { + "Update DeploymentType from Docker to Kubernetes", + updateEdgeStackPayload{ + StackFileContent: "error-test", + Version: &newVersion, + EdgeGroups: []portainer.EdgeGroupID{1}, + DeploymentType: portainer.EdgeStackDeploymentKubernetes, + }, + http.StatusBadRequest, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + jsonPayload, err := json.Marshal(tc.Payload) + if err != nil { + t.Fatal("JSON marshal error:", err) + } + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} + +func TestUpdateWithInvalidPayload(t *testing.T) { + handler, rawAPIKey, teardown := setupHandler(t) + defer teardown() + + endpoint := createEndpoint(t, handler.DataStore) + edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) + + newVersion := 238 + cases := []struct { + Name string + Payload updateEdgeStackPayload + ExpectedStatusCode int + }{ + { + "Update with empty StackFileContent", + updateEdgeStackPayload{ + StackFileContent: "", + Version: &newVersion, + EdgeGroups: edgeStack.EdgeGroups, + DeploymentType: edgeStack.DeploymentType, + }, + http.StatusBadRequest, + }, + { + "Update with empty EdgeGroups", + updateEdgeStackPayload{ + StackFileContent: "error-test", + Version: &newVersion, + EdgeGroups: []portainer.EdgeGroupID{}, + DeploymentType: edgeStack.DeploymentType, + }, + http.StatusBadRequest, + }, + } + + for _, tc := range cases { + t.Run(tc.Name, func(t *testing.T) { + jsonPayload, err := json.Marshal(tc.Payload) + if err != nil { + t.Fatal("request error:", err) + } + + r := bytes.NewBuffer(jsonPayload) + req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r) + if err != nil { + t.Fatal("request error:", err) + } + + req.Header.Add("x-api-key", rawAPIKey) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != tc.ExpectedStatusCode { + t.Fatalf("expected a %d response, found: %d", tc.ExpectedStatusCode, rec.Code) + } + }) + } +} diff --git a/api/internal/edge/edgestacks/error.go b/api/internal/edge/edgestacks/error.go deleted file mode 100644 index 3844c9d6f..000000000 --- a/api/internal/edge/edgestacks/error.go +++ /dev/null @@ -1,15 +0,0 @@ -package edgestacks - -type InvalidPayloadError struct { - msg string -} - -func (e *InvalidPayloadError) Error() string { - return e.msg -} - -func NewInvalidPayloadError(errMsg string) *InvalidPayloadError { - return &InvalidPayloadError{ - msg: errMsg, - } -} diff --git a/api/internal/edge/edgestacks/service.go b/api/internal/edge/edgestacks/service.go index 8d0498f8d..878bfd0af 100644 --- a/api/internal/edge/edgestacks/service.go +++ b/api/internal/edge/edgestacks/service.go @@ -9,6 +9,7 @@ import ( "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" "github.com/portainer/portainer/api/dataservices" + httperrors "github.com/portainer/portainer/api/http/errors" "github.com/portainer/portainer/api/internal/edge" edgetypes "github.com/portainer/portainer/api/internal/edge/types" ) @@ -81,7 +82,7 @@ func (service *Service) PersistEdgeStack( relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups) if err != nil { if err == edge.ErrEdgeGroupNotFound { - return nil, NewInvalidPayloadError(err.Error()) + return nil, httperrors.NewInvalidPayloadError(err.Error()) } return nil, fmt.Errorf("unable to persist environment relation in database: %w", err) }