chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
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/http/security"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
method, err := request.RetrieveRouteVariableValue(r, "method")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid query parameter: method", err)
|
||||
}
|
||||
|
||||
dryrun, _ := request.RetrieveBooleanQueryParameter(r, "dryrun", true)
|
||||
|
||||
tokenData, err := security.RetrieveTokenData(r)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve user details from authentication token", err)
|
||||
}
|
||||
|
||||
var edgeStack *portainer.EdgeStack
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
edgeStack, err = handler.createSwarmStack(tx, method, dryrun, tokenData, r)
|
||||
return err
|
||||
}); err != nil {
|
||||
switch {
|
||||
case httperrors.IsInvalidPayloadError(err):
|
||||
return httperror.BadRequest("Invalid payload", err)
|
||||
case httperrors.IsConflictError(err):
|
||||
return httperror.Conflict(err.Error(), err)
|
||||
default:
|
||||
return httperror.InternalServerError("Unable to create Edge stack", err)
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, edgeStack)
|
||||
}
|
||||
|
||||
func (handler *Handler) createSwarmStack(tx dataservices.DataStoreTx, method string, dryrun bool, tokenData *portainer.TokenData, r *http.Request) (*portainer.EdgeStack, error) {
|
||||
switch method {
|
||||
case "string":
|
||||
return handler.createEdgeStackFromFileContent(r, tx, tokenData, dryrun)
|
||||
case "repository":
|
||||
return handler.createEdgeStackFromGitRepository(r, tx, tokenData, dryrun)
|
||||
case "file":
|
||||
return handler.createEdgeStackFromFileUpload(r, tx, tokenData, dryrun)
|
||||
}
|
||||
|
||||
return nil, httperrors.NewInvalidPayloadError("Invalid value for query parameter: method. Value must be one of: string, repository or file")
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
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/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type edgeStackFromFileUploadPayload struct {
|
||||
// Name of the stack
|
||||
// Max length: 255
|
||||
// Name must only contains lowercase characters, numbers, hyphens, or underscores
|
||||
// Name must start with a lowercase character or number
|
||||
// Example: stack-name or stack_123 or stackName
|
||||
Name string
|
||||
StackFileContent []byte
|
||||
EdgeGroups []portainer.EdgeGroupID
|
||||
// Deployment type to deploy this stack
|
||||
// Valid values are: 0 - 'compose', 1 - 'kubernetes'
|
||||
// compose is enabled only for docker environments
|
||||
// kubernetes is enabled only for kubernetes environments
|
||||
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"`
|
||||
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
|
||||
|
||||
if !edge.IsValidEdgeStackName(payload.Name) {
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
|
||||
}
|
||||
|
||||
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)
|
||||
if payload.DeploymentType != portainer.EdgeStackDeploymentCompose && payload.DeploymentType != portainer.EdgeStackDeploymentKubernetes {
|
||||
return httperrors.NewInvalidPayloadError("Invalid deployment type")
|
||||
}
|
||||
|
||||
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. it must only consist of lowercase alphanumeric characters, hyphens, or underscores as well as start with a letter or number"
|
||||
// @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'"
|
||||
// @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, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
payload := &edgeStackFromFileUploadPayload{}
|
||||
if err := payload.Validate(r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stack, err := handler.edgeStacksService.BuildEdgeStack(tx, 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
|
||||
}
|
||||
|
||||
if err := stackutils.ValidateEdgeStackComposeContent(r.Context(), payload.DeploymentType, payload.StackFileContent); err != nil {
|
||||
return nil, httperrors.NewInvalidPayloadError(err.Error())
|
||||
}
|
||||
|
||||
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
|
||||
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
|
||||
|
||||
return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||
return handler.storeFileContent(tx, stackFolder, payload.DeploymentType, relatedEndpointIds, payload.StackFileContent)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/dataservices/source"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
gittypes "github.com/portainer/portainer/api/git/types"
|
||||
"github.com/portainer/portainer/api/gitops/sources"
|
||||
"github.com/portainer/portainer/api/gitops/workflows"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type edgeStackFromGitRepositoryPayload struct {
|
||||
// Name of the stack
|
||||
// Max length: 255
|
||||
// Name must only contains lowercase characters, numbers, hyphens, or underscores
|
||||
// Name must start with a lowercase character or number
|
||||
// Example: stack-name or stack_123 or stackName
|
||||
Name string `example:"stack-name" validate:"required"`
|
||||
// SourceID references an existing Source for git credentials/URL.
|
||||
// When set, the inline URL and authentication fields are ignored.
|
||||
SourceID portainer.SourceID `example:"1"`
|
||||
// Deprecated: Use SourceID instead. URL of a Git repository hosting the Stack file.
|
||||
RepositoryURL string `example:"https://github.com/openfaas/faas"`
|
||||
// Reference name of a Git repository hosting the Stack file
|
||||
RepositoryReferenceName string `example:"refs/heads/master"`
|
||||
// Deprecated: Use SourceID instead. Use basic authentication to clone the Git repository.
|
||||
RepositoryAuthentication bool `example:"true"`
|
||||
// Deprecated: Use SourceID instead. Username used in basic authentication.
|
||||
RepositoryUsername string `example:"myGitUsername"`
|
||||
// Deprecated: Use SourceID instead. Password used in basic authentication.
|
||||
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" validate:"required"`
|
||||
// Deployment type to deploy this stack
|
||||
// Valid values are: 0 - 'compose', 1 - 'kubernetes'
|
||||
// compose is enabled only for docker environments
|
||||
// kubernetes is enabled only for kubernetes environments
|
||||
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
|
||||
// Deprecated: Use SourceID instead. TLSSkipVerify skips SSL verification when cloning the Git repository.
|
||||
TLSSkipVerify bool `example:"false"`
|
||||
}
|
||||
|
||||
func (payload *edgeStackFromGitRepositoryPayload) Validate(r *http.Request) error {
|
||||
if len(payload.Name) == 0 {
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack name")
|
||||
}
|
||||
|
||||
if !edge.IsValidEdgeStackName(payload.Name) {
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
|
||||
}
|
||||
|
||||
if payload.SourceID == 0 {
|
||||
if len(payload.RepositoryURL) == 0 || !validate.IsURL(payload.RepositoryURL) {
|
||||
return httperrors.NewInvalidPayloadError("Invalid repository URL. Must correspond to a valid URL format")
|
||||
}
|
||||
if payload.RepositoryAuthentication && len(payload.RepositoryPassword) == 0 {
|
||||
return httperrors.NewInvalidPayloadError("Invalid repository credentials. Password must be specified when authentication is enabled")
|
||||
}
|
||||
}
|
||||
|
||||
if payload.DeploymentType != portainer.EdgeStackDeploymentCompose && payload.DeploymentType != portainer.EdgeStackDeploymentKubernetes {
|
||||
return httperrors.NewInvalidPayloadError("Invalid deployment type")
|
||||
}
|
||||
|
||||
if len(payload.FilePathInRepository) == 0 {
|
||||
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 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, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
var payload edgeStackFromGitRepositoryPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stack, err := handler.edgeStacksService.BuildEdgeStack(tx, 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
|
||||
}
|
||||
|
||||
securityContext, err := security.RetrieveRestrictedRequestContext(r)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve user info from request context", err)
|
||||
}
|
||||
userContext := source.NewUserContext(securityContext.User, securityContext.UserMemberships)
|
||||
|
||||
repoConfig, httpErr := sources.ResolveRepoConfig(tx, userContext, sources.RepoConfigInput{
|
||||
SourceID: payload.SourceID,
|
||||
ReferenceName: payload.RepositoryReferenceName,
|
||||
ConfigFilePath: payload.FilePathInRepository,
|
||||
RepositoryURL: payload.RepositoryURL,
|
||||
TLSSkipVerify: payload.TLSSkipVerify,
|
||||
RepositoryAuthentication: payload.RepositoryAuthentication,
|
||||
Username: payload.RepositoryUsername,
|
||||
Password: payload.RepositoryPassword,
|
||||
})
|
||||
if httpErr != nil {
|
||||
return nil, httpErr
|
||||
}
|
||||
|
||||
if err := ssrf.CheckURL(r.Context(), repoConfig.URL); err != nil {
|
||||
return nil, errors.Wrap(err, "repository URL blocked by SSRF policy")
|
||||
}
|
||||
|
||||
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
|
||||
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
|
||||
|
||||
return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||
return handler.storeManifestFromGitRepository(context.TODO(), tx, userContext, payload.SourceID, stackFolder, relatedEndpointIds, payload.DeploymentType, tokenData.ID, repoConfig)
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *Handler) storeManifestFromGitRepository(ctx context.Context, tx dataservices.DataStoreTx, userContext source.UserContext, sourceID portainer.SourceID, stackFolder string, relatedEndpointIds []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType, currentUserID portainer.UserID, repositoryConfig gittypes.RepoConfig) (composePath, manifestPath, projectPath string, err error) {
|
||||
if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil {
|
||||
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
|
||||
} else if hasWrongType {
|
||||
return "", "", "", errors.New("edge stack with config do not match the environment type")
|
||||
}
|
||||
|
||||
projectPath = handler.FileService.GetEdgeStackProjectPath(stackFolder)
|
||||
repositoryUsername := ""
|
||||
repositoryPassword := ""
|
||||
if repositoryConfig.Authentication != nil && repositoryConfig.Authentication.Password != "" {
|
||||
repositoryUsername = repositoryConfig.Authentication.Username
|
||||
repositoryPassword = repositoryConfig.Authentication.Password
|
||||
}
|
||||
|
||||
if err := handler.GitService.CloneRepository(
|
||||
ctx,
|
||||
projectPath,
|
||||
repositoryConfig.URL,
|
||||
repositoryConfig.ReferenceName,
|
||||
repositoryUsername,
|
||||
repositoryPassword,
|
||||
repositoryConfig.TLSSkipVerify,
|
||||
); err != nil {
|
||||
if statusErr := workflows.SaveSourceStatus(tx, userContext, sourceID, err); statusErr != nil {
|
||||
return "", "", "", fmt.Errorf("%w (and failed to persist status: %w)", err, statusErr)
|
||||
}
|
||||
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
if err := workflows.SaveSourceStatus(tx, userContext, sourceID, nil); err != nil {
|
||||
return "", "", "", fmt.Errorf("failed to persist source sync status: %w", err)
|
||||
}
|
||||
|
||||
if deploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
return repositoryConfig.ConfigFilePath, "", projectPath, nil
|
||||
}
|
||||
|
||||
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
return "", repositoryConfig.ConfigFilePath, projectPath, nil
|
||||
}
|
||||
|
||||
errMessage := fmt.Sprintf("unknown deployment type: %d", deploymentType)
|
||||
return "", "", "", httperrors.NewInvalidPayloadError(errMessage)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
httperrors "github.com/portainer/portainer/api/http/errors"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
"github.com/portainer/portainer/pkg/edge"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type edgeStackFromStringPayload struct {
|
||||
// Name of the stack
|
||||
// Max length: 255
|
||||
// Name must only contains lowercase characters, numbers, hyphens, or underscores
|
||||
// Name must start with a lowercase character or number
|
||||
// Example: stack-name or stack_123 or stackName
|
||||
Name string `example:"stack-name" 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'
|
||||
// compose is enabled only for docker environments
|
||||
// kubernetes is enabled only for kubernetes environments
|
||||
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 len(payload.Name) == 0 {
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack name")
|
||||
}
|
||||
|
||||
if !edge.IsValidEdgeStackName(payload.Name) {
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack name. Stack name must only consist of lowercase alpha characters, numbers, hyphens, or underscores as well as start with a lowercase character or number")
|
||||
}
|
||||
|
||||
if len(payload.StackFileContent) == 0 {
|
||||
return httperrors.NewInvalidPayloadError("Invalid stack file content")
|
||||
}
|
||||
|
||||
if len(payload.EdgeGroups) == 0 {
|
||||
return httperrors.NewInvalidPayloadError("Edge Groups are mandatory for an Edge stack")
|
||||
}
|
||||
|
||||
if payload.DeploymentType != portainer.EdgeStackDeploymentCompose && payload.DeploymentType != portainer.EdgeStackDeploymentKubernetes {
|
||||
return httperrors.NewInvalidPayloadError("Invalid deployment type")
|
||||
}
|
||||
|
||||
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, tx dataservices.DataStoreTx, tokenData *portainer.TokenData, dryrun bool) (*portainer.EdgeStack, error) {
|
||||
var payload edgeStackFromStringPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
stack, err := handler.edgeStacksService.BuildEdgeStack(tx, payload.Name, payload.DeploymentType, payload.EdgeGroups, payload.Registries, payload.UseManifestNamespaces)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create Edge stack object")
|
||||
}
|
||||
|
||||
stack.CreatedByUserId = fmt.Sprintf("%d", tokenData.ID)
|
||||
stack.CreatedBy = stackutils.SanitizeLabel(tokenData.Username)
|
||||
|
||||
if dryrun {
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
if err := stackutils.ValidateEdgeStackComposeContent(r.Context(), payload.DeploymentType, []byte(payload.StackFileContent)); err != nil {
|
||||
return nil, httperrors.NewInvalidPayloadError(err.Error())
|
||||
}
|
||||
|
||||
return handler.edgeStacksService.PersistEdgeStack(tx, stack, func(stackFolder string, relatedEndpointIds []portainer.EndpointID) (composePath string, manifestPath string, projectPath string, err error) {
|
||||
return handler.storeFileContent(tx, stackFolder, payload.DeploymentType, relatedEndpointIds, []byte(payload.StackFileContent))
|
||||
})
|
||||
}
|
||||
|
||||
func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolder string, deploymentType portainer.EdgeStackDeploymentType, relatedEndpointIds []portainer.EndpointID, fileContent []byte) (composePath, manifestPath, projectPath string, err error) {
|
||||
if hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, deploymentType); err != nil {
|
||||
return "", "", "", fmt.Errorf("unable to check for existence of non fitting environments: %w", err)
|
||||
} else if hasWrongType {
|
||||
return "", "", "", errors.New("edge stack with config do not match the environment type")
|
||||
}
|
||||
|
||||
if deploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
composePath = filesystem.ComposeFileDefaultName
|
||||
|
||||
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, composePath, fileContent)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
|
||||
return composePath, "", projectPath, nil
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/roar"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Create
|
||||
func TestCreateAndInspect(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
// Create Endpoint, EdgeGroup and EndpointRelation
|
||||
endpoint := createEndpoint(t, handler.DataStore)
|
||||
edgeGroup := portainer.EdgeGroup{
|
||||
ID: 1,
|
||||
Name: "EdgeGroup 1",
|
||||
Dynamic: false,
|
||||
TagIDs: nil,
|
||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpoint.ID}),
|
||||
PartialMatch: false,
|
||||
}
|
||||
|
||||
err := handler.DataStore.EdgeGroup().Create(&edgeGroup)
|
||||
require.NoError(t, err)
|
||||
|
||||
endpointRelation := portainer.EndpointRelation{
|
||||
EndpointID: endpoint.ID,
|
||||
EdgeStacks: map[portainer.EdgeStackID]bool{},
|
||||
}
|
||||
|
||||
err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := edgeStackFromStringPayload{
|
||||
Name: "test-stack",
|
||||
StackFileContent: "stack content",
|
||||
EdgeGroups: []portainer.EdgeGroupID{1},
|
||||
DeploymentType: portainer.EdgeStackDeploymentCompose,
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
r := bytes.NewBuffer(jsonPayload)
|
||||
|
||||
// Create EdgeStack
|
||||
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", r)
|
||||
require.NoError(t, 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)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Inspect
|
||||
req, err = http.NewRequest(http.MethodGet, fmt.Sprintf("/edge_stacks/%d", data.ID), nil)
|
||||
require.NoError(t, 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)
|
||||
require.NoError(t, err)
|
||||
|
||||
if payload.Name != data.Name {
|
||||
t.Fatalf("expected EdgeStack Name %s, found %s", payload.Name, data.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateWithInvalidPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
endpoint := createEndpoint(t, handler.DataStore)
|
||||
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
|
||||
|
||||
cases := []struct {
|
||||
Name string
|
||||
Payload any
|
||||
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: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
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, "/edge_stacks/create/"+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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id EdgeStackDelete
|
||||
// @summary Delete an EdgeStack
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_stacks
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @param id path int true "EdgeStack Id"
|
||||
// @success 204
|
||||
// @failure 500
|
||||
// @failure 400
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @router /edge_stacks/{id} [delete]
|
||||
func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid edge stack identifier route variable", err)
|
||||
}
|
||||
|
||||
err = handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
return handler.deleteEdgeStack(tx, portainer.EdgeStackID(edgeStackID))
|
||||
})
|
||||
|
||||
return response.TxEmptyResponse(w, err)
|
||||
}
|
||||
|
||||
func (handler *Handler) deleteEdgeStack(tx dataservices.DataStoreTx, edgeStackID portainer.EdgeStackID) error {
|
||||
edgeStack, err := tx.EdgeStack().EdgeStack(edgeStackID)
|
||||
if handler.DataStore.IsErrObjectNotFound(err) {
|
||||
return httperror.NotFound("Unable to find an edge stack with the specified identifier inside the database", err)
|
||||
} else if err != nil {
|
||||
return httperror.InternalServerError("Unable to find an edge stack with the specified identifier inside the database", err)
|
||||
}
|
||||
|
||||
if err := handler.edgeStacksService.DeleteEdgeStack(tx, edgeStack.ID, edgeStack.EdgeGroups); err != nil {
|
||||
return httperror.InternalServerError("Unable to delete edge stack", err)
|
||||
}
|
||||
|
||||
stackFolder := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(edgeStack.ID)))
|
||||
if err := handler.FileService.RemoveDirectory(stackFolder); err != nil {
|
||||
return httperror.InternalServerError("Unable to remove edge stack project folder", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Delete
|
||||
func TestDeleteAndInspect(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
// 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)
|
||||
require.NoError(t, 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)
|
||||
require.NoError(t, 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)
|
||||
require.NoError(t, 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)
|
||||
require.NoError(t, 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) {
|
||||
t.Parallel()
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteEdgeStack_RemoveProjectFolder(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
edgeGroup := createEdgeGroup(t, handler.DataStore)
|
||||
|
||||
payload := edgeStackFromStringPayload{
|
||||
Name: "test-stack",
|
||||
DeploymentType: portainer.EdgeStackDeploymentCompose,
|
||||
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
|
||||
StackFileContent: "version: '3.7'\nservices:\n test:\n image: test",
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := json.NewEncoder(&buf).Encode(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create
|
||||
req, err := http.NewRequest(http.MethodPost, "/edge_stacks/create/string", &buf)
|
||||
require.NoError(t, 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.StatusNoContent, rec.Code)
|
||||
}
|
||||
|
||||
assert.DirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
|
||||
|
||||
// Delete
|
||||
req, err = http.NewRequest(http.MethodDelete, "/edge_stacks/1", nil)
|
||||
require.NoError(t, 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)
|
||||
}
|
||||
|
||||
assert.NoDirExists(t, handler.FileService.GetEdgeStackProjectPath("1"))
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type stackFileResponse struct {
|
||||
StackFileContent string `json:"StackFileContent"`
|
||||
}
|
||||
|
||||
// @id EdgeStackFile
|
||||
// @summary Fetches the stack file for an EdgeStack
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_stacks
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "EdgeStack Id"
|
||||
// @success 200 {object} stackFileResponse
|
||||
// @failure 500
|
||||
// @failure 400
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @router /edge_stacks/{id}/file [get]
|
||||
func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid edge stack identifier route variable", err)
|
||||
}
|
||||
|
||||
stack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
|
||||
if err != nil {
|
||||
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
fileName := stack.EntryPoint
|
||||
if stack.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
fileName = stack.ManifestPath
|
||||
}
|
||||
|
||||
stackFileContent, err := handler.FileService.GetFileContent(stack.ProjectPath, fileName)
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve stack file from disk", err)
|
||||
}
|
||||
|
||||
return response.JSON(w, &stackFileResponse{StackFileContent: string(stackFileContent)})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
// @id EdgeStackInspect
|
||||
// @summary Inspect an EdgeStack
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_stacks
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "EdgeStack Id"
|
||||
// @success 200 {object} portainer.EdgeStack
|
||||
// @failure 500
|
||||
// @failure 400
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @router /edge_stacks/{id} [get]
|
||||
func (handler *Handler) edgeStackInspect(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
edgeStackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid edge stack identifier route variable", err)
|
||||
}
|
||||
|
||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||
if err != nil {
|
||||
return handlerDBErr(err, "Unable to find an edge stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
if err := fillEdgeStackStatus(handler.DataStore, edgeStack); err != nil {
|
||||
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
|
||||
}
|
||||
|
||||
return response.JSON(w, edgeStack)
|
||||
}
|
||||
|
||||
func fillEdgeStackStatus(tx dataservices.DataStoreTx, edgeStack *portainer.EdgeStack) error {
|
||||
status, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
edgeStack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus, len(status))
|
||||
|
||||
emptyStatus := make([]portainer.EdgeStackDeploymentStatus, 0)
|
||||
|
||||
for _, s := range status {
|
||||
if s.Status == nil {
|
||||
s.Status = emptyStatus
|
||||
}
|
||||
|
||||
edgeStack.Status[s.EndpointID] = portainer.EdgeStackStatus{
|
||||
Status: s.Status,
|
||||
EndpointID: s.EndpointID,
|
||||
DeploymentInfo: s.DeploymentInfo,
|
||||
ReadyRePullImage: s.ReadyRePullImage,
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Inspect
|
||||
func TestInspectInvalidEdgeID(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/slicesx"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type aggregatedStatusesMap map[portainer.EdgeStackStatusType]int
|
||||
|
||||
type SummarizedStatus string
|
||||
|
||||
const (
|
||||
sumStatusUnavailable SummarizedStatus = "Unavailable"
|
||||
sumStatusDeploying SummarizedStatus = "Deploying"
|
||||
sumStatusFailed SummarizedStatus = "Failed"
|
||||
sumStatusPaused SummarizedStatus = "Paused"
|
||||
sumStatusPartiallyRunning SummarizedStatus = "PartiallyRunning"
|
||||
sumStatusCompleted SummarizedStatus = "Completed"
|
||||
sumStatusRunning SummarizedStatus = "Running"
|
||||
)
|
||||
|
||||
type edgeStackStatusSummary struct {
|
||||
AggregatedStatus aggregatedStatusesMap
|
||||
Status SummarizedStatus
|
||||
Reason string
|
||||
}
|
||||
|
||||
type edgeStackListResponseItem struct {
|
||||
portainer.EdgeStack
|
||||
StatusSummary edgeStackStatusSummary
|
||||
}
|
||||
|
||||
// @id EdgeStackList
|
||||
// @summary Fetches the list of EdgeStacks
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_stacks
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param summarizeStatuses query boolean false "will summarize the statuses"
|
||||
// @success 200 {array} portainer.EdgeStack
|
||||
// @failure 500
|
||||
// @failure 400
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @router /edge_stacks [get]
|
||||
func (handler *Handler) edgeStackList(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
summarizeStatuses, _ := request.RetrieveBooleanQueryParameter(r, "summarizeStatuses", true)
|
||||
|
||||
edgeStacks, err := handler.DataStore.EdgeStack().EdgeStacks()
|
||||
if err != nil {
|
||||
return httperror.InternalServerError("Unable to retrieve edge stacks from the database", err)
|
||||
}
|
||||
|
||||
res := make([]edgeStackListResponseItem, len(edgeStacks))
|
||||
|
||||
for i := range edgeStacks {
|
||||
res[i].EdgeStack = edgeStacks[i]
|
||||
|
||||
if summarizeStatuses {
|
||||
if err := fillStatusSummary(handler.DataStore, &res[i]); err != nil {
|
||||
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
|
||||
}
|
||||
} else if err := fillEdgeStackStatus(handler.DataStore, &res[i].EdgeStack); err != nil {
|
||||
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
|
||||
}
|
||||
}
|
||||
|
||||
return response.JSON(w, res)
|
||||
}
|
||||
|
||||
func fillStatusSummary(tx dataservices.DataStoreTx, edgeStack *edgeStackListResponseItem) error {
|
||||
statuses, err := tx.EdgeStackStatus().ReadAll(edgeStack.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
aggregated := make(aggregatedStatusesMap)
|
||||
|
||||
for _, envStatus := range statuses {
|
||||
for _, status := range envStatus.Status {
|
||||
aggregated[status.Type]++
|
||||
}
|
||||
}
|
||||
|
||||
status, reason := SummarizeStatuses(statuses, edgeStack.NumDeployments)
|
||||
|
||||
edgeStack.StatusSummary = edgeStackStatusSummary{
|
||||
AggregatedStatus: aggregated,
|
||||
Status: status,
|
||||
Reason: reason,
|
||||
}
|
||||
|
||||
edgeStack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func SummarizeStatuses(statuses []portainer.EdgeStackStatusForEnv, numDeployments int) (SummarizedStatus, string) {
|
||||
if numDeployments == 0 {
|
||||
return sumStatusUnavailable, "Your edge stack is currently unavailable due to the absence of an available environment in your edge group"
|
||||
}
|
||||
|
||||
allStatuses := slicesx.FlatMap(statuses, func(x portainer.EdgeStackStatusForEnv) []portainer.EdgeStackDeploymentStatus {
|
||||
return x.Status
|
||||
})
|
||||
|
||||
lastStatuses := slicesx.Map(
|
||||
slicesx.Filter(
|
||||
statuses,
|
||||
func(s portainer.EdgeStackStatusForEnv) bool {
|
||||
return len(s.Status) > 0
|
||||
},
|
||||
),
|
||||
func(x portainer.EdgeStackStatusForEnv) portainer.EdgeStackDeploymentStatus {
|
||||
return x.Status[len(x.Status)-1]
|
||||
},
|
||||
)
|
||||
|
||||
if len(lastStatuses) == 0 {
|
||||
return sumStatusDeploying, ""
|
||||
}
|
||||
|
||||
if allFailed := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
|
||||
return s.Type == portainer.EdgeStackStatusError
|
||||
}); allFailed {
|
||||
return sumStatusFailed, ""
|
||||
}
|
||||
|
||||
if hasPaused := slicesx.Some(allStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
|
||||
return s.Type == portainer.EdgeStackStatusPausedDeploying
|
||||
}); hasPaused {
|
||||
return sumStatusPaused, ""
|
||||
}
|
||||
|
||||
if len(lastStatuses) < numDeployments {
|
||||
return sumStatusDeploying, ""
|
||||
}
|
||||
|
||||
hasDeploying := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusDeploying })
|
||||
hasRunning := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusRunning })
|
||||
hasFailed := slicesx.Some(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusError })
|
||||
|
||||
if hasRunning && hasFailed && !hasDeploying {
|
||||
return sumStatusPartiallyRunning, ""
|
||||
}
|
||||
|
||||
if allCompleted := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool { return s.Type == portainer.EdgeStackStatusCompleted }); allCompleted {
|
||||
return sumStatusCompleted, ""
|
||||
}
|
||||
|
||||
if allRunning := slicesx.Every(lastStatuses, func(s portainer.EdgeStackDeploymentStatus) bool {
|
||||
return s.Type == portainer.EdgeStackStatusRunning
|
||||
}); allRunning {
|
||||
return sumStatusRunning, ""
|
||||
}
|
||||
|
||||
return sumStatusDeploying, ""
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
)
|
||||
|
||||
type updateStatusPayload struct {
|
||||
Error string
|
||||
Status *portainer.EdgeStackStatusType
|
||||
EndpointID portainer.EndpointID
|
||||
Time int64
|
||||
Version int
|
||||
}
|
||||
|
||||
func (payload *updateStatusPayload) Validate(r *http.Request) error {
|
||||
if payload.Status == nil {
|
||||
return errors.New("invalid status")
|
||||
}
|
||||
|
||||
if payload.EndpointID == 0 {
|
||||
return errors.New("invalid EnvironmentID")
|
||||
}
|
||||
|
||||
if *payload.Status == portainer.EdgeStackStatusError && len(payload.Error) == 0 {
|
||||
return errors.New("error message is mandatory when status is error")
|
||||
}
|
||||
|
||||
if payload.Time == 0 {
|
||||
payload.Time = time.Now().Unix()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id EdgeStackStatusUpdate
|
||||
// @summary Update an EdgeStack status
|
||||
// @description Authorized only if the request is done by an Edge Environment(Endpoint)
|
||||
// @tags edge_stacks
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "EdgeStack Id"
|
||||
// @param body body updateStatusPayload true "EdgeStack status payload"
|
||||
// @success 200 {object} portainer.EdgeStack
|
||||
// @failure 500
|
||||
// @failure 400
|
||||
// @failure 404
|
||||
// @failure 403
|
||||
// @router /edge_stacks/{id}/status [put]
|
||||
func (handler *Handler) edgeStackStatusUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid stack identifier route variable", err)
|
||||
}
|
||||
|
||||
var payload updateStatusPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", fmt.Errorf("edge polling error: %w. Environment ID: %d", err, payload.EndpointID))
|
||||
}
|
||||
|
||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(payload.EndpointID)
|
||||
if err != nil {
|
||||
return handlerDBErr(fmt.Errorf("unable to find the environment from the database: %w. Environment ID: %d", err, payload.EndpointID), "unable to find the environment")
|
||||
}
|
||||
|
||||
if err := handler.requestBouncer.AuthorizedEdgeEndpointOperation(r, endpoint); err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", fmt.Errorf("unauthorized edge endpoint operation: %w. Environment ID: %d", err, payload.EndpointID))
|
||||
}
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
var err error
|
||||
stack, err = tx.EdgeStack().EdgeStack(portainer.EdgeStackID(stackID))
|
||||
if err != nil {
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return httperror.InternalServerError("Unable to retrieve Edge stack from the database", err)
|
||||
}
|
||||
|
||||
if err := handler.updateEdgeStackStatus(tx, stack, stack.ID, payload); err != nil {
|
||||
return httperror.InternalServerError("Unable to update Edge stack status", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return response.TxErrorResponse(err)
|
||||
}
|
||||
|
||||
if ok, _ := strconv.ParseBool(r.Header.Get("X-Portainer-No-Body")); ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil {
|
||||
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateEdgeStackStatus(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, stackID portainer.EdgeStackID, payload updateStatusPayload) error {
|
||||
if payload.Version > 0 && payload.Version < stack.Version {
|
||||
return nil
|
||||
}
|
||||
|
||||
status := *payload.Status
|
||||
|
||||
deploymentStatus := portainer.EdgeStackDeploymentStatus{
|
||||
Type: status,
|
||||
Error: payload.Error,
|
||||
Time: payload.Time,
|
||||
}
|
||||
|
||||
if deploymentStatus.Type == portainer.EdgeStackStatusRemoved {
|
||||
return tx.EdgeStackStatus().Delete(stackID, payload.EndpointID)
|
||||
}
|
||||
|
||||
environmentStatus, err := tx.EdgeStackStatus().Read(stackID, payload.EndpointID)
|
||||
if err != nil && !tx.IsErrObjectNotFound(err) {
|
||||
return err
|
||||
} else if tx.IsErrObjectNotFound(err) {
|
||||
environmentStatus = &portainer.EdgeStackStatusForEnv{
|
||||
EndpointID: payload.EndpointID,
|
||||
Status: []portainer.EdgeStackDeploymentStatus{},
|
||||
}
|
||||
}
|
||||
|
||||
if containsStatus := slices.ContainsFunc(environmentStatus.Status, func(e portainer.EdgeStackDeploymentStatus) bool {
|
||||
return e.Type == deploymentStatus.Type
|
||||
}); !containsStatus {
|
||||
environmentStatus.Status = append(environmentStatus.Status, deploymentStatus)
|
||||
}
|
||||
|
||||
return tx.EdgeStackStatus().Update(stackID, payload.EndpointID, environmentStatus)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Update Status
|
||||
func TestUpdateStatusAndInspect(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
|
||||
r := bytes.NewBuffer(jsonPayload)
|
||||
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
|
||||
require.NoError(t, 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)
|
||||
require.NoError(t, 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)
|
||||
}
|
||||
|
||||
updatedStack := portainer.EdgeStack{}
|
||||
err = json.NewDecoder(rec.Body).Decode(&updatedStack)
|
||||
require.NoError(t, err)
|
||||
|
||||
endpointStatus, ok := updatedStack.Status[payload.EndpointID]
|
||||
require.True(t, ok)
|
||||
|
||||
lastStatus := endpointStatus.Status[len(endpointStatus.Status)-1]
|
||||
|
||||
if len(endpointStatus.Status) == len(edgeStack.Status[payload.EndpointID].Status) {
|
||||
t.Fatal("expected status array to be updated")
|
||||
}
|
||||
|
||||
if lastStatus.Type != *payload.Status {
|
||||
t.Fatalf("expected EdgeStackStatusType %d, found %d", *payload.Status, lastStatus.Type)
|
||||
}
|
||||
|
||||
if endpointStatus.EndpointID != payload.EndpointID {
|
||||
t.Fatalf("expected EndpointID %d, found %d", payload.EndpointID, endpointStatus.EndpointID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStatusWithInvalidPayload(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler, _ := setupHandler(t)
|
||||
|
||||
endpoint := createEndpoint(t, handler.DataStore)
|
||||
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
|
||||
|
||||
// Update edge stack status
|
||||
statusError := portainer.EdgeStackStatusError
|
||||
statusOk := portainer.EdgeStackStatusDeploymentReceived
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
|
||||
r := bytes.NewBuffer(jsonPayload)
|
||||
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d/status", edgeStack.ID), r)
|
||||
require.NoError(t, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/apikey"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
"github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
"github.com/portainer/portainer/api/jwt"
|
||||
"github.com/portainer/portainer/api/roar"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Helpers
|
||||
func setupHandler(t *testing.T) (*Handler, string) {
|
||||
t.Helper()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, true)
|
||||
|
||||
jwtService, err := jwt.NewService("1h", store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user := &portainer.User{ID: 2, Username: "admin", Role: portainer.AdministratorRole}
|
||||
if err := store.User().Create(user); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
apiKeyService := apikey.NewAPIKeyService(store.APIKeyRepository(), store.User())
|
||||
rawAPIKey, _, err := apiKeyService.GenerateApiKey(*user, "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
fs, err := filesystem.NewService(t.TempDir(), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
handler := NewHandler(
|
||||
security.NewRequestBouncer(t.Context(), store, jwtService, apiKeyService),
|
||||
store,
|
||||
edgestacks.NewService(store),
|
||||
)
|
||||
|
||||
handler.FileService = fs
|
||||
|
||||
settings, err := handler.DataStore.Settings().Settings()
|
||||
require.NoError(t, err)
|
||||
|
||||
settings.EnableEdgeComputeFeatures = true
|
||||
|
||||
err = handler.DataStore.Settings().UpdateSettings(settings)
|
||||
require.NoError(t, err)
|
||||
|
||||
handler.GitService = testhelpers.NewGitService(errors.New("Clone error"), "git-service-id")
|
||||
|
||||
return handler, rawAPIKey
|
||||
}
|
||||
|
||||
func createEndpointWithId(t *testing.T, store dataservices.DataStore, endpointID portainer.EndpointID) portainer.Endpoint {
|
||||
t.Helper()
|
||||
|
||||
endpoint := portainer.Endpoint{
|
||||
ID: endpointID,
|
||||
Name: "test-endpoint-" + strconv.Itoa(int(endpointID)),
|
||||
Type: portainer.EdgeAgentOnDockerEnvironment,
|
||||
URL: "https://portainer.io:9443",
|
||||
EdgeID: "edge-id",
|
||||
LastCheckInDate: time.Now().Unix(),
|
||||
}
|
||||
|
||||
err := store.Endpoint().Create(&endpoint)
|
||||
require.NoError(t, err)
|
||||
|
||||
return endpoint
|
||||
}
|
||||
|
||||
func createEndpoint(t *testing.T, store dataservices.DataStore) portainer.Endpoint {
|
||||
return createEndpointWithId(t, store, 5)
|
||||
}
|
||||
|
||||
func createEdgeStack(t *testing.T, store dataservices.DataStore, endpointID portainer.EndpointID) portainer.EdgeStack {
|
||||
t.Helper()
|
||||
|
||||
edgeGroup := portainer.EdgeGroup{
|
||||
ID: 1,
|
||||
Name: "EdgeGroup 1",
|
||||
Dynamic: false,
|
||||
TagIDs: nil,
|
||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{endpointID}),
|
||||
PartialMatch: false,
|
||||
}
|
||||
|
||||
err := store.EdgeGroup().Create(&edgeGroup)
|
||||
require.NoError(t, err)
|
||||
|
||||
edgeStackID := portainer.EdgeStackID(14)
|
||||
edgeStack := portainer.EdgeStack{
|
||||
ID: edgeStackID,
|
||||
Name: "test-edge-stack-" + strconv.Itoa(int(edgeStackID)),
|
||||
CreationDate: time.Now().Unix(),
|
||||
EdgeGroups: []portainer.EdgeGroupID{edgeGroup.ID},
|
||||
ProjectPath: "/project/path",
|
||||
EntryPoint: "entrypoint",
|
||||
Version: 237,
|
||||
ManifestPath: "/manifest/path",
|
||||
DeploymentType: portainer.EdgeStackDeploymentKubernetes,
|
||||
}
|
||||
|
||||
endpointRelation := portainer.EndpointRelation{
|
||||
EndpointID: endpointID,
|
||||
EdgeStacks: map[portainer.EdgeStackID]bool{
|
||||
edgeStack.ID: true,
|
||||
},
|
||||
}
|
||||
|
||||
err = store.EdgeStack().Create(edgeStack.ID, &edgeStack)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = store.EndpointRelation().Create(&endpointRelation)
|
||||
require.NoError(t, err)
|
||||
|
||||
return edgeStack
|
||||
}
|
||||
|
||||
func createEdgeGroup(t *testing.T, store dataservices.DataStore) portainer.EdgeGroup {
|
||||
edgeGroup := portainer.EdgeGroup{
|
||||
ID: 1,
|
||||
Name: "EdgeGroup 1",
|
||||
}
|
||||
|
||||
err := store.EdgeGroup().Create(&edgeGroup)
|
||||
require.NoError(t, err)
|
||||
|
||||
return edgeGroup
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/edge"
|
||||
"github.com/portainer/portainer/api/set"
|
||||
"github.com/portainer/portainer/api/stacks/stackutils"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type updateEdgeStackPayload struct {
|
||||
StackFileContent string
|
||||
UpdateVersion bool
|
||||
EdgeGroups []portainer.EdgeGroupID
|
||||
DeploymentType portainer.EdgeStackDeploymentType
|
||||
// Uses the manifest's namespaces instead of the default one
|
||||
UseManifestNamespaces bool
|
||||
}
|
||||
|
||||
func (payload *updateEdgeStackPayload) Validate(r *http.Request) error {
|
||||
if payload.StackFileContent == "" {
|
||||
return errors.New("invalid stack file content")
|
||||
}
|
||||
|
||||
if len(payload.EdgeGroups) == 0 {
|
||||
return errors.New("edge Groups are mandatory for an Edge stack")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// @id EdgeStackUpdate
|
||||
// @summary Update an EdgeStack
|
||||
// @description **Access policy**: administrator
|
||||
// @tags edge_stacks
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @accept json
|
||||
// @produce json
|
||||
// @param id path int true "EdgeStack Id"
|
||||
// @param body body updateEdgeStackPayload true "EdgeStack data"
|
||||
// @success 200 {object} portainer.EdgeStack
|
||||
// @failure 500
|
||||
// @failure 400
|
||||
// @failure 503 "Edge compute features are disabled"
|
||||
// @router /edge_stacks/{id} [put]
|
||||
func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
stackID, err := request.RetrieveNumericRouteVariableValue(r, "id")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid stack identifier route variable", err)
|
||||
}
|
||||
|
||||
var payload updateEdgeStackPayload
|
||||
if err := request.DecodeAndValidateJSONPayload(r, &payload); err != nil {
|
||||
return httperror.BadRequest("Invalid request payload", err)
|
||||
}
|
||||
|
||||
if err := stackutils.ValidateEdgeStackComposeContent(r.Context(), payload.DeploymentType, []byte(payload.StackFileContent)); err != nil {
|
||||
return httperror.BadRequest("Stack file contains a URL blocked by the SSRF policy", err)
|
||||
}
|
||||
|
||||
var stack *portainer.EdgeStack
|
||||
if err := handler.DataStore.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
stack, err = handler.updateEdgeStack(tx, portainer.EdgeStackID(stackID), payload)
|
||||
return err
|
||||
}); err != nil {
|
||||
return response.TxErrorResponse(err)
|
||||
}
|
||||
|
||||
if err := fillEdgeStackStatus(handler.DataStore, stack); err != nil {
|
||||
return handlerDBErr(err, "Unable to retrieve edge stack status from the database")
|
||||
}
|
||||
|
||||
return response.JSON(w, stack)
|
||||
}
|
||||
|
||||
func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID portainer.EdgeStackID, payload updateEdgeStackPayload) (*portainer.EdgeStack, error) {
|
||||
stack, err := tx.EdgeStack().EdgeStack(stackID)
|
||||
if err != nil {
|
||||
return nil, handlerDBErr(err, "Unable to find a stack with the specified identifier inside the database")
|
||||
}
|
||||
|
||||
relationConfig, err := edge.FetchEndpointRelationsConfig(tx)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve environments relations config from database", err)
|
||||
}
|
||||
|
||||
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
|
||||
}
|
||||
|
||||
groupsIds := stack.EdgeGroups
|
||||
if payload.EdgeGroups != nil {
|
||||
newRelated, _, err := handler.handleChangeEdgeGroups(tx, stack, payload.EdgeGroups, relatedEndpointIds, relationConfig)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to handle edge groups change", err)
|
||||
}
|
||||
|
||||
groupsIds = payload.EdgeGroups
|
||||
relatedEndpointIds = newRelated
|
||||
|
||||
}
|
||||
|
||||
hasWrongType, err := hasWrongEnvironmentType(tx.Endpoint(), relatedEndpointIds, payload.DeploymentType)
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("unable to check for existence of non fitting environments: %w", err)
|
||||
}
|
||||
if hasWrongType {
|
||||
return nil, httperror.BadRequest("edge stack with config do not match the environment type", nil)
|
||||
}
|
||||
|
||||
stack.NumDeployments = len(relatedEndpointIds)
|
||||
|
||||
stack.UseManifestNamespaces = payload.UseManifestNamespaces
|
||||
|
||||
stack.EdgeGroups = groupsIds
|
||||
|
||||
if payload.UpdateVersion {
|
||||
if err := handler.updateStackVersion(tx, stack, payload.DeploymentType, []byte(payload.StackFileContent), "", relatedEndpointIds); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to update stack version", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.EdgeStack().UpdateEdgeStack(stack.ID, stack); err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
|
||||
}
|
||||
|
||||
return stack, nil
|
||||
}
|
||||
|
||||
func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edgeStack *portainer.EdgeStack, newEdgeGroupsIDs []portainer.EdgeGroupID, oldRelatedEnvironmentIDs []portainer.EndpointID, relationConfig *edge.EndpointRelationsConfig) ([]portainer.EndpointID, set.Set[portainer.EndpointID], error) {
|
||||
newRelatedEnvironmentIDs, err := edge.EdgeStackRelatedEndpoints(newEdgeGroupsIDs, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
|
||||
if err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||
}
|
||||
|
||||
oldRelatedEnvironmentsSet := set.ToSet(oldRelatedEnvironmentIDs)
|
||||
newRelatedEnvironmentsSet := set.ToSet(newRelatedEnvironmentIDs)
|
||||
|
||||
relatedEnvironmentsToAdd := newRelatedEnvironmentsSet.Difference(oldRelatedEnvironmentsSet)
|
||||
relatedEnvironmentsToRemove := oldRelatedEnvironmentsSet.Difference(newRelatedEnvironmentsSet)
|
||||
|
||||
if len(relatedEnvironmentsToRemove) > 0 {
|
||||
if err := tx.EndpointRelation().RemoveEndpointRelationsForEdgeStack(relatedEnvironmentsToRemove.Keys(), edgeStack.ID); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to remove edge stack relations from the database")
|
||||
}
|
||||
}
|
||||
|
||||
if len(relatedEnvironmentsToAdd) > 0 {
|
||||
if err := tx.EndpointRelation().AddEndpointRelationsForEdgeStack(relatedEnvironmentsToAdd.Keys(), edgeStack); err != nil {
|
||||
return nil, nil, errors.WithMessage(err, "Unable to add edge stack relations to the database")
|
||||
}
|
||||
}
|
||||
|
||||
return newRelatedEnvironmentIDs, relatedEnvironmentsToAdd, nil
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/roar"
|
||||
|
||||
"github.com/segmentio/encoding/json"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Update
|
||||
func TestUpdateAndInspect(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
|
||||
endpointRelation := portainer.EndpointRelation{
|
||||
EndpointID: endpointID,
|
||||
EdgeStacks: map[portainer.EdgeStackID]bool{
|
||||
edgeStack.ID: true,
|
||||
},
|
||||
}
|
||||
|
||||
err = handler.DataStore.EndpointRelation().Create(&endpointRelation)
|
||||
require.NoError(t, err)
|
||||
|
||||
newEdgeGroup := portainer.EdgeGroup{
|
||||
ID: 2,
|
||||
Name: "EdgeGroup 2",
|
||||
Dynamic: false,
|
||||
TagIDs: nil,
|
||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{newEndpoint.ID}),
|
||||
PartialMatch: false,
|
||||
}
|
||||
|
||||
err = handler.DataStore.EdgeGroup().Create(&newEdgeGroup)
|
||||
require.NoError(t, err)
|
||||
|
||||
payload := updateEdgeStackPayload{
|
||||
StackFileContent: "update-test",
|
||||
UpdateVersion: true,
|
||||
EdgeGroups: append(edgeStack.EdgeGroups, newEdgeGroup.ID),
|
||||
DeploymentType: portainer.EdgeStackDeploymentCompose,
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
r := bytes.NewBuffer(jsonPayload)
|
||||
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
|
||||
require.NoError(t, 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)
|
||||
require.NoError(t, 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)
|
||||
}
|
||||
|
||||
updatedStack := portainer.EdgeStack{}
|
||||
err = json.NewDecoder(rec.Body).Decode(&updatedStack)
|
||||
require.NoError(t, err)
|
||||
|
||||
if payload.UpdateVersion && updatedStack.Version != edgeStack.Version+1 {
|
||||
t.Fatalf("expected EdgeStack version %d, found %d", edgeStack.Version+1, updatedStack.Version+1)
|
||||
}
|
||||
|
||||
if updatedStack.DeploymentType != payload.DeploymentType {
|
||||
t.Fatalf("expected DeploymentType %d, found %d", edgeStack.DeploymentType, updatedStack.DeploymentType)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(updatedStack.EdgeGroups, payload.EdgeGroups) {
|
||||
t.Fatalf("expected EdgeGroups to be equal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
|
||||
t.Parallel()
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
endpoint := createEndpoint(t, handler.DataStore)
|
||||
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
|
||||
|
||||
newEdgeGroup := portainer.EdgeGroup{
|
||||
ID: 2,
|
||||
Name: "EdgeGroup 2",
|
||||
Dynamic: false,
|
||||
TagIDs: nil,
|
||||
EndpointIDs: roar.FromSlice([]portainer.EndpointID{8889}),
|
||||
PartialMatch: false,
|
||||
}
|
||||
|
||||
err := handler.DataStore.EdgeGroup().Create(&newEdgeGroup)
|
||||
require.NoError(t, err)
|
||||
|
||||
cases := []struct {
|
||||
Name string
|
||||
Payload updateEdgeStackPayload
|
||||
ExpectedStatusCode int
|
||||
}{
|
||||
{
|
||||
"Update with non-existing EdgeGroupID",
|
||||
updateEdgeStackPayload{
|
||||
StackFileContent: "error-test",
|
||||
UpdateVersion: true,
|
||||
EdgeGroups: []portainer.EdgeGroupID{9999},
|
||||
DeploymentType: edgeStack.DeploymentType,
|
||||
},
|
||||
http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
"Update with invalid EdgeGroup (non-existing Endpoint)",
|
||||
updateEdgeStackPayload{
|
||||
StackFileContent: "error-test",
|
||||
UpdateVersion: true,
|
||||
EdgeGroups: []portainer.EdgeGroupID{2},
|
||||
DeploymentType: edgeStack.DeploymentType,
|
||||
},
|
||||
http.StatusInternalServerError,
|
||||
},
|
||||
{
|
||||
"Update DeploymentType from Docker to Kubernetes",
|
||||
updateEdgeStackPayload{
|
||||
StackFileContent: "error-test",
|
||||
UpdateVersion: true,
|
||||
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) {
|
||||
t.Parallel()
|
||||
handler, rawAPIKey := setupHandler(t)
|
||||
|
||||
endpoint := createEndpoint(t, handler.DataStore)
|
||||
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
|
||||
|
||||
cases := []struct {
|
||||
Name string
|
||||
Payload updateEdgeStackPayload
|
||||
ExpectedStatusCode int
|
||||
}{
|
||||
{
|
||||
"Update with empty StackFileContent",
|
||||
updateEdgeStackPayload{
|
||||
StackFileContent: "",
|
||||
UpdateVersion: true,
|
||||
EdgeGroups: edgeStack.EdgeGroups,
|
||||
DeploymentType: edgeStack.DeploymentType,
|
||||
},
|
||||
http.StatusBadRequest,
|
||||
},
|
||||
{
|
||||
"Update with empty EdgeGroups",
|
||||
updateEdgeStackPayload{
|
||||
StackFileContent: "error-test",
|
||||
UpdateVersion: true,
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
|
||||
r := bytes.NewBuffer(jsonPayload)
|
||||
req, err := http.NewRequest(http.MethodPut, fmt.Sprintf("/edge_stacks/%d", edgeStack.ID), r)
|
||||
require.NoError(t, 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||
)
|
||||
|
||||
func hasKubeEndpoint(endpointService dataservices.EndpointService, endpointIDs []portainer.EndpointID) (bool, error) {
|
||||
return hasEndpointPredicate(endpointService, endpointIDs, endpointutils.IsKubernetesEndpoint)
|
||||
}
|
||||
|
||||
func hasDockerEndpoint(endpointService dataservices.EndpointService, endpointIDs []portainer.EndpointID) (bool, error) {
|
||||
return hasEndpointPredicate(endpointService, endpointIDs, endpointutils.IsDockerEndpoint)
|
||||
}
|
||||
|
||||
func hasEndpointPredicate(endpointService dataservices.EndpointService, endpointIDs []portainer.EndpointID, predicate func(*portainer.Endpoint) bool) (bool, error) {
|
||||
for _, endpointID := range endpointIDs {
|
||||
endpoint, err := endpointService.Endpoint(endpointID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to retrieve environment from database: %w", err)
|
||||
}
|
||||
|
||||
if predicate(endpoint) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func hasWrongEnvironmentType(endpointService dataservices.EndpointService, endpointIDs []portainer.EndpointID, deploymentType portainer.EdgeStackDeploymentType) (bool, error) {
|
||||
return hasEndpointPredicate(endpointService, endpointIDs, func(e *portainer.Endpoint) bool {
|
||||
switch deploymentType {
|
||||
case portainer.EdgeStackDeploymentKubernetes:
|
||||
return !endpointutils.IsKubernetesEndpoint(e)
|
||||
case portainer.EdgeStackDeploymentCompose:
|
||||
return !endpointutils.IsDockerEndpoint(e)
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_hasKubeEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
endpoints := []portainer.Endpoint{
|
||||
{ID: 1, Type: portainer.DockerEnvironment},
|
||||
{ID: 2, Type: portainer.AgentOnDockerEnvironment},
|
||||
{ID: 3, Type: portainer.AzureEnvironment},
|
||||
{ID: 4, Type: portainer.EdgeAgentOnDockerEnvironment},
|
||||
{ID: 5, Type: portainer.KubernetesLocalEnvironment},
|
||||
{ID: 6, Type: portainer.AgentOnKubernetesEnvironment},
|
||||
{ID: 7, Type: portainer.EdgeAgentOnKubernetesEnvironment},
|
||||
}
|
||||
|
||||
datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints(endpoints))
|
||||
|
||||
tests := []struct {
|
||||
endpointIds []portainer.EndpointID
|
||||
expected bool
|
||||
}{
|
||||
{endpointIds: []portainer.EndpointID{1}, expected: false},
|
||||
{endpointIds: []portainer.EndpointID{2}, expected: false},
|
||||
{endpointIds: []portainer.EndpointID{3}, expected: false},
|
||||
{endpointIds: []portainer.EndpointID{4}, expected: false},
|
||||
{endpointIds: []portainer.EndpointID{5}, expected: true},
|
||||
{endpointIds: []portainer.EndpointID{6}, expected: true},
|
||||
{endpointIds: []portainer.EndpointID{7}, expected: true},
|
||||
{endpointIds: []portainer.EndpointID{7, 2}, expected: true},
|
||||
{endpointIds: []portainer.EndpointID{6, 4, 1}, expected: true},
|
||||
{endpointIds: []portainer.EndpointID{1, 2, 3}, expected: false},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
ans, err := hasKubeEndpoint(datastore.Endpoint(), test.endpointIds)
|
||||
require.NoError(t, err, "hasKubeEndpoint shouldn't fail")
|
||||
|
||||
assert.Equal(t, test.expected, ans, "hasKubeEndpoint expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_hasKubeEndpoint_failWhenEndpointDontExist(t *testing.T) {
|
||||
t.Parallel()
|
||||
datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{}))
|
||||
|
||||
_, err := hasKubeEndpoint(datastore.Endpoint(), []portainer.EndpointID{1})
|
||||
require.Error(t, err, "hasKubeEndpoint should fail")
|
||||
}
|
||||
|
||||
func Test_hasDockerEndpoint(t *testing.T) {
|
||||
t.Parallel()
|
||||
endpoints := []portainer.Endpoint{
|
||||
{ID: 1, Type: portainer.DockerEnvironment},
|
||||
{ID: 2, Type: portainer.AgentOnDockerEnvironment},
|
||||
{ID: 3, Type: portainer.AzureEnvironment},
|
||||
{ID: 4, Type: portainer.EdgeAgentOnDockerEnvironment},
|
||||
{ID: 5, Type: portainer.KubernetesLocalEnvironment},
|
||||
{ID: 6, Type: portainer.AgentOnKubernetesEnvironment},
|
||||
{ID: 7, Type: portainer.EdgeAgentOnKubernetesEnvironment},
|
||||
}
|
||||
|
||||
datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints(endpoints))
|
||||
|
||||
tests := []struct {
|
||||
endpointIds []portainer.EndpointID
|
||||
expected bool
|
||||
}{
|
||||
{endpointIds: []portainer.EndpointID{1}, expected: true},
|
||||
{endpointIds: []portainer.EndpointID{2}, expected: true},
|
||||
{endpointIds: []portainer.EndpointID{3}, expected: false},
|
||||
{endpointIds: []portainer.EndpointID{4}, expected: true},
|
||||
{endpointIds: []portainer.EndpointID{5}, expected: false},
|
||||
{endpointIds: []portainer.EndpointID{6}, expected: false},
|
||||
{endpointIds: []portainer.EndpointID{7}, expected: false},
|
||||
{endpointIds: []portainer.EndpointID{7, 2}, expected: true},
|
||||
{endpointIds: []portainer.EndpointID{6, 4, 1}, expected: true},
|
||||
{endpointIds: []portainer.EndpointID{1, 2, 3}, expected: true},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
|
||||
ans, err := hasDockerEndpoint(datastore.Endpoint(), test.endpointIds)
|
||||
require.NoError(t, err, "hasDockerEndpoint shouldn't fail")
|
||||
|
||||
assert.Equal(t, test.expected, ans, "hasDockerEndpoint expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_hasDockerEndpoint_failWhenEndpointDontExist(t *testing.T) {
|
||||
t.Parallel()
|
||||
datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{}))
|
||||
|
||||
_, err := hasDockerEndpoint(datastore.Endpoint(), []portainer.EndpointID{1})
|
||||
require.Error(t, err, "hasDockerEndpoint should fail")
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
"github.com/portainer/portainer/api/http/security"
|
||||
edgestackservice "github.com/portainer/portainer/api/internal/edge/edgestacks"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// Handler is the HTTP handler used to handle environment(endpoint) group operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
requestBouncer security.BouncerService
|
||||
DataStore dataservices.DataStore
|
||||
FileService portainer.FileService
|
||||
GitService portainer.GitService
|
||||
edgeStacksService *edgestackservice.Service
|
||||
KubernetesDeployer portainer.KubernetesDeployer
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
||||
func NewHandler(bouncer security.BouncerService, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
|
||||
h := &Handler{
|
||||
Router: mux.NewRouter(),
|
||||
requestBouncer: bouncer,
|
||||
DataStore: dataStore,
|
||||
edgeStacksService: edgeStacksService,
|
||||
}
|
||||
|
||||
h.Handle("/edge_stacks/create/{method}",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackCreate)))).Methods(http.MethodPost)
|
||||
h.Handle("/edge_stacks",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackList)))).Methods(http.MethodGet)
|
||||
h.Handle("/edge_stacks/{id}",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackInspect)))).Methods(http.MethodGet)
|
||||
h.Handle("/edge_stacks/{id}",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackUpdate)))).Methods(http.MethodPut)
|
||||
h.Handle("/edge_stacks/{id}",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackDelete)))).Methods(http.MethodDelete)
|
||||
h.Handle("/edge_stacks/{id}/file",
|
||||
bouncer.AdminAccess(bouncer.EdgeComputeOperation(httperror.LoggerHandler(h.edgeStackFile)))).Methods(http.MethodGet)
|
||||
h.Handle("/edge_stacks/{id}/status",
|
||||
bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusUpdate))).Methods(http.MethodPut)
|
||||
|
||||
edgeStackStatusRouter := h.NewRoute().Subrouter()
|
||||
edgeStackStatusRouter.Use(middlewares.WithEndpoint(h.DataStore.Endpoint(), "endpoint_id"))
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
func handlerDBErr(err error, msg string) *httperror.HandlerError {
|
||||
httpErr := httperror.InternalServerError(msg, err)
|
||||
|
||||
if dataservices.IsErrObjectNotFound(err) {
|
||||
httpErr.StatusCode = http.StatusNotFound
|
||||
}
|
||||
|
||||
return httpErr
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package edgestacks
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (handler *Handler) updateStackVersion(tx dataservices.DataStoreTx, stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte, oldGitHash string, relatedEnvironmentsIDs []portainer.EndpointID) error {
|
||||
stack.Version++
|
||||
|
||||
if err := tx.EdgeStackStatus().Clear(stack.ID, relatedEnvironmentsIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return handler.storeStackFile(stack, deploymentType, config)
|
||||
}
|
||||
|
||||
func (handler *Handler) storeStackFile(stack *portainer.EdgeStack, deploymentType portainer.EdgeStackDeploymentType, config []byte) error {
|
||||
if deploymentType != stack.DeploymentType {
|
||||
// deployment type was changed - need to delete all old files
|
||||
if err := handler.FileService.RemoveDirectory(stack.ProjectPath); err != nil {
|
||||
log.Warn().Err(err).Msg("Unable to clear old files")
|
||||
}
|
||||
|
||||
stack.EntryPoint = ""
|
||||
stack.ManifestPath = ""
|
||||
stack.DeploymentType = deploymentType
|
||||
}
|
||||
|
||||
stackFolder := strconv.Itoa(int(stack.ID))
|
||||
entryPoint := ""
|
||||
if deploymentType == portainer.EdgeStackDeploymentCompose {
|
||||
if stack.EntryPoint == "" {
|
||||
stack.EntryPoint = filesystem.ComposeFileDefaultName
|
||||
}
|
||||
|
||||
entryPoint = stack.EntryPoint
|
||||
}
|
||||
|
||||
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
if stack.ManifestPath == "" {
|
||||
stack.ManifestPath = filesystem.ManifestFileDefaultName
|
||||
}
|
||||
|
||||
entryPoint = stack.ManifestPath
|
||||
}
|
||||
|
||||
if _, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, config); err != nil {
|
||||
return fmt.Errorf("unable to persist updated Compose file with version on disk: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user