mirror of https://github.com/portainer/portainer
feat(edgestacks): support kubernetes edge stacks (#5276) [EE-393]
parent
79ca51c92e
commit
5c8450c4c0
|
@ -1,6 +1,7 @@
|
||||||
package edgegroups
|
package edgegroups
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
@ -10,7 +11,8 @@ import (
|
||||||
|
|
||||||
type decoratedEdgeGroup struct {
|
type decoratedEdgeGroup struct {
|
||||||
portainer.EdgeGroup
|
portainer.EdgeGroup
|
||||||
HasEdgeStack bool `json:"HasEdgeStack"`
|
HasEdgeStack bool `json:"HasEdgeStack"`
|
||||||
|
EndpointTypes []portainer.EndpointType
|
||||||
}
|
}
|
||||||
|
|
||||||
// @id EdgeGroupList
|
// @id EdgeGroupList
|
||||||
|
@ -46,17 +48,25 @@ func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *h
|
||||||
decoratedEdgeGroups := []decoratedEdgeGroup{}
|
decoratedEdgeGroups := []decoratedEdgeGroup{}
|
||||||
for _, orgEdgeGroup := range edgeGroups {
|
for _, orgEdgeGroup := range edgeGroups {
|
||||||
edgeGroup := decoratedEdgeGroup{
|
edgeGroup := decoratedEdgeGroup{
|
||||||
EdgeGroup: orgEdgeGroup,
|
EdgeGroup: orgEdgeGroup,
|
||||||
|
EndpointTypes: []portainer.EndpointType{},
|
||||||
}
|
}
|
||||||
if edgeGroup.Dynamic {
|
if edgeGroup.Dynamic {
|
||||||
endpoints, err := handler.getEndpointsByTags(edgeGroup.TagIDs, edgeGroup.PartialMatch)
|
endpointIDs, err := handler.getEndpointsByTags(edgeGroup.TagIDs, edgeGroup.PartialMatch)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments and environment groups for Edge group", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments and environment groups for Edge group", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeGroup.Endpoints = endpoints
|
edgeGroup.Endpoints = endpointIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
endpointTypes, err := getEndpointTypes(handler.DataStore.Endpoint(), edgeGroup.Endpoints)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve endpoint types for Edge group", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeGroup.EndpointTypes = endpointTypes
|
||||||
|
|
||||||
edgeGroup.HasEdgeStack = usedEdgeGroups[edgeGroup.ID]
|
edgeGroup.HasEdgeStack = usedEdgeGroups[edgeGroup.ID]
|
||||||
|
|
||||||
decoratedEdgeGroups = append(decoratedEdgeGroups, edgeGroup)
|
decoratedEdgeGroups = append(decoratedEdgeGroups, edgeGroup)
|
||||||
|
@ -64,3 +74,22 @@ func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *h
|
||||||
|
|
||||||
return response.JSON(w, decoratedEdgeGroups)
|
return response.JSON(w, decoratedEdgeGroups)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getEndpointTypes(endpointService portainer.EndpointService, endpointIds []portainer.EndpointID) ([]portainer.EndpointType, error) {
|
||||||
|
typeSet := map[portainer.EndpointType]bool{}
|
||||||
|
for _, endpointID := range endpointIds {
|
||||||
|
endpoint, err := endpointService.Endpoint(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed fetching endpoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
typeSet[endpoint.Type] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointTypes := make([]portainer.EndpointType, 0, len(typeSet))
|
||||||
|
for endpointType := range typeSet {
|
||||||
|
endpointTypes = append(endpointTypes, endpointType)
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpointTypes, nil
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,53 @@
|
||||||
|
package edgegroups
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_getEndpointTypes(t *testing.T) {
|
||||||
|
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 []portainer.EndpointType
|
||||||
|
}{
|
||||||
|
{endpointIds: []portainer.EndpointID{1}, expected: []portainer.EndpointType{portainer.DockerEnvironment}},
|
||||||
|
{endpointIds: []portainer.EndpointID{2}, expected: []portainer.EndpointType{portainer.AgentOnDockerEnvironment}},
|
||||||
|
{endpointIds: []portainer.EndpointID{3}, expected: []portainer.EndpointType{portainer.AzureEnvironment}},
|
||||||
|
{endpointIds: []portainer.EndpointID{4}, expected: []portainer.EndpointType{portainer.EdgeAgentOnDockerEnvironment}},
|
||||||
|
{endpointIds: []portainer.EndpointID{5}, expected: []portainer.EndpointType{portainer.KubernetesLocalEnvironment}},
|
||||||
|
{endpointIds: []portainer.EndpointID{6}, expected: []portainer.EndpointType{portainer.AgentOnKubernetesEnvironment}},
|
||||||
|
{endpointIds: []portainer.EndpointID{7}, expected: []portainer.EndpointType{portainer.EdgeAgentOnKubernetesEnvironment}},
|
||||||
|
{endpointIds: []portainer.EndpointID{7, 2}, expected: []portainer.EndpointType{portainer.EdgeAgentOnKubernetesEnvironment, portainer.AgentOnDockerEnvironment}},
|
||||||
|
{endpointIds: []portainer.EndpointID{6, 4, 1}, expected: []portainer.EndpointType{portainer.AgentOnKubernetesEnvironment, portainer.EdgeAgentOnDockerEnvironment, portainer.DockerEnvironment}},
|
||||||
|
{endpointIds: []portainer.EndpointID{1, 2, 3}, expected: []portainer.EndpointType{portainer.DockerEnvironment, portainer.AgentOnDockerEnvironment, portainer.AzureEnvironment}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
ans, err := getEndpointTypes(datastore.Endpoint(), test.endpointIds)
|
||||||
|
assert.NoError(t, err, "getEndpointTypes shouldn't fail")
|
||||||
|
|
||||||
|
assert.ElementsMatch(t, test.expected, ans, "getEndpointTypes expected to return %b for %v, but returned %b", test.expected, test.endpointIds, ans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_getEndpointTypes_failWhenEndpointDontExist(t *testing.T) {
|
||||||
|
datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{}))
|
||||||
|
|
||||||
|
_, err := getEndpointTypes(datastore.Endpoint(), []portainer.EndpointID{1})
|
||||||
|
assert.Error(t, err, "getEndpointTypes should fail")
|
||||||
|
}
|
|
@ -2,6 +2,7 @@ package edgestacks
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -42,37 +43,6 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Edge stack", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to create Edge stack", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoints, err := handler.DataStore.Endpoint().Endpoints()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
relatedEndpoints, err := edge.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, endpoints, endpointGroups, edgeGroups)
|
|
||||||
|
|
||||||
for _, endpointID := range relatedEndpoints {
|
|
||||||
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find environment relation in database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
relation.EdgeStacks[edgeStack.ID] = true
|
|
||||||
|
|
||||||
err = handler.DataStore.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist environment relation in database", err}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.JSON(w, edgeStack)
|
return response.JSON(w, edgeStack)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -95,6 +65,11 @@ type swarmStackFromFileContentPayload struct {
|
||||||
StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"`
|
StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"`
|
||||||
// List of identifiers of EdgeGroups
|
// List of identifiers of EdgeGroups
|
||||||
EdgeGroups []portainer.EdgeGroupID `example:"1"`
|
EdgeGroups []portainer.EdgeGroupID `example:"1"`
|
||||||
|
// Deployment type to deploy this stack
|
||||||
|
// Valid values are: 0 - 'compose', 1 - 'kubernetes'
|
||||||
|
// for compose stacks will use kompose to convert to kubernetes manifest for kubernetes endpoints
|
||||||
|
// kubernetes deploytype is enabled only for kubernetes endpoints
|
||||||
|
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error {
|
func (payload *swarmStackFromFileContentPayload) Validate(r *http.Request) error {
|
||||||
|
@ -124,21 +99,64 @@ func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*porta
|
||||||
|
|
||||||
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
||||||
stack := &portainer.EdgeStack{
|
stack := &portainer.EdgeStack{
|
||||||
ID: portainer.EdgeStackID(stackID),
|
ID: portainer.EdgeStackID(stackID),
|
||||||
Name: payload.Name,
|
Name: payload.Name,
|
||||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
DeploymentType: payload.DeploymentType,
|
||||||
CreationDate: time.Now().Unix(),
|
CreationDate: time.Now().Unix(),
|
||||||
EdgeGroups: payload.EdgeGroups,
|
EdgeGroups: payload.EdgeGroups,
|
||||||
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
|
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
|
||||||
Version: 1,
|
Version: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to find environment relations in database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to persist environment relation in database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
if stack.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
||||||
if err != nil {
|
stack.EntryPoint = filesystem.ComposeFileDefaultName
|
||||||
return nil, err
|
|
||||||
|
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stack.ProjectPath = projectPath
|
||||||
|
|
||||||
|
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to check for existence of docker endpoint: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasDockerEndpoint {
|
||||||
|
return nil, fmt.Errorf("edge stack with docker endpoint cannot be deployed with kubernetes config")
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.ManifestPath = filesystem.ManifestFileDefaultName
|
||||||
|
|
||||||
|
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.ProjectPath = projectPath
|
||||||
|
}
|
||||||
|
|
||||||
|
err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Unable to update endpoint relations: %w", err)
|
||||||
}
|
}
|
||||||
stack.ProjectPath = projectPath
|
|
||||||
|
|
||||||
err = handler.DataStore.EdgeStack().CreateEdgeStack(stack)
|
err = handler.DataStore.EdgeStack().CreateEdgeStack(stack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -162,9 +180,14 @@ type swarmStackFromGitRepositoryPayload struct {
|
||||||
// Password used in basic authentication. Required when RepositoryAuthentication is true.
|
// Password used in basic authentication. Required when RepositoryAuthentication is true.
|
||||||
RepositoryPassword string `example:"myGitPassword"`
|
RepositoryPassword string `example:"myGitPassword"`
|
||||||
// Path to the Stack file inside the Git repository
|
// Path to the Stack file inside the Git repository
|
||||||
ComposeFilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
FilePathInRepository string `example:"docker-compose.yml" default:"docker-compose.yml"`
|
||||||
// List of identifiers of EdgeGroups
|
// List of identifiers of EdgeGroups
|
||||||
EdgeGroups []portainer.EdgeGroupID `example:"1"`
|
EdgeGroups []portainer.EdgeGroupID `example:"1"`
|
||||||
|
// Deployment type to deploy this stack
|
||||||
|
// Valid values are: 0 - 'compose', 1 - 'kubernetes'
|
||||||
|
// for compose stacks will use kompose to convert to kubernetes manifest for kubernetes endpoints
|
||||||
|
// kubernetes deploytype is enabled only for kubernetes endpoints
|
||||||
|
DeploymentType portainer.EdgeStackDeploymentType `example:"0" enums:"0,1"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error {
|
func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) error {
|
||||||
|
@ -177,8 +200,8 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err
|
||||||
if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) {
|
if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) {
|
||||||
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
|
return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled")
|
||||||
}
|
}
|
||||||
if govalidator.IsNull(payload.ComposeFilePathInRepository) {
|
if govalidator.IsNull(payload.FilePathInRepository) {
|
||||||
payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName
|
payload.FilePathInRepository = filesystem.ComposeFileDefaultName
|
||||||
}
|
}
|
||||||
if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 {
|
if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 {
|
||||||
return errors.New("Edge Groups are mandatory for an Edge stack")
|
return errors.New("Edge Groups are mandatory for an Edge stack")
|
||||||
|
@ -200,13 +223,13 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por
|
||||||
|
|
||||||
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
||||||
stack := &portainer.EdgeStack{
|
stack := &portainer.EdgeStack{
|
||||||
ID: portainer.EdgeStackID(stackID),
|
ID: portainer.EdgeStackID(stackID),
|
||||||
Name: payload.Name,
|
Name: payload.Name,
|
||||||
EntryPoint: payload.ComposeFilePathInRepository,
|
CreationDate: time.Now().Unix(),
|
||||||
CreationDate: time.Now().Unix(),
|
EdgeGroups: payload.EdgeGroups,
|
||||||
EdgeGroups: payload.EdgeGroups,
|
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
|
||||||
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
|
DeploymentType: payload.DeploymentType,
|
||||||
Version: 1,
|
Version: 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID)))
|
projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID)))
|
||||||
|
@ -219,11 +242,37 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por
|
||||||
repositoryPassword = ""
|
repositoryPassword = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed fetching relations config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to retrieve related endpoints: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
|
err = handler.GitService.CloneRepository(projectPath, payload.RepositoryURL, payload.RepositoryReferenceName, repositoryUsername, repositoryPassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if stack.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
||||||
|
stack.EntryPoint = payload.FilePathInRepository
|
||||||
|
|
||||||
|
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
stack.ManifestPath = payload.FilePathInRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Unable to update endpoint relations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
err = handler.DataStore.EdgeStack().CreateEdgeStack(stack)
|
err = handler.DataStore.EdgeStack().CreateEdgeStack(stack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -236,6 +285,7 @@ type swarmStackFromFileUploadPayload struct {
|
||||||
Name string
|
Name string
|
||||||
StackFileContent []byte
|
StackFileContent []byte
|
||||||
EdgeGroups []portainer.EdgeGroupID
|
EdgeGroups []portainer.EdgeGroupID
|
||||||
|
DeploymentType portainer.EdgeStackDeploymentType
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error {
|
func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error {
|
||||||
|
@ -257,6 +307,13 @@ func (payload *swarmStackFromFileUploadPayload) Validate(r *http.Request) error
|
||||||
return errors.New("Edge Groups are mandatory for an Edge stack")
|
return errors.New("Edge Groups are mandatory for an Edge stack")
|
||||||
}
|
}
|
||||||
payload.EdgeGroups = edgeGroups
|
payload.EdgeGroups = edgeGroups
|
||||||
|
|
||||||
|
deploymentType, err := request.RetrieveNumericMultiPartFormValue(r, "DeploymentType", true)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("Invalid deployment type")
|
||||||
|
}
|
||||||
|
payload.DeploymentType = portainer.EdgeStackDeploymentType(deploymentType)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -274,21 +331,54 @@ func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portai
|
||||||
|
|
||||||
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
stackID := handler.DataStore.EdgeStack().GetNextIdentifier()
|
||||||
stack := &portainer.EdgeStack{
|
stack := &portainer.EdgeStack{
|
||||||
ID: portainer.EdgeStackID(stackID),
|
ID: portainer.EdgeStackID(stackID),
|
||||||
Name: payload.Name,
|
Name: payload.Name,
|
||||||
EntryPoint: filesystem.ComposeFileDefaultName,
|
DeploymentType: payload.DeploymentType,
|
||||||
CreationDate: time.Now().Unix(),
|
CreationDate: time.Now().Unix(),
|
||||||
EdgeGroups: payload.EdgeGroups,
|
EdgeGroups: payload.EdgeGroups,
|
||||||
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
|
Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus),
|
||||||
Version: 1,
|
Version: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed fetching relations config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to retrieve related endpoints: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
if stack.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
||||||
if err != nil {
|
stack.EntryPoint = filesystem.ComposeFileDefaultName
|
||||||
return nil, err
|
|
||||||
|
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stack.ProjectPath = projectPath
|
||||||
|
|
||||||
|
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Failed creating and storing kube manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
stack.ManifestPath = filesystem.ManifestFileDefaultName
|
||||||
|
|
||||||
|
projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
stack.ProjectPath = projectPath
|
||||||
|
}
|
||||||
|
|
||||||
|
err = updateEndpointRelations(handler.DataStore.EndpointRelation(), stack.ID, relatedEndpointIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("Unable to update endpoint relations: %w", err)
|
||||||
}
|
}
|
||||||
stack.ProjectPath = projectPath
|
|
||||||
|
|
||||||
err = handler.DataStore.EdgeStack().CreateEdgeStack(stack)
|
err = handler.DataStore.EdgeStack().CreateEdgeStack(stack)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -311,3 +401,22 @@ func (handler *Handler) validateUniqueName(name string) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// updateEndpointRelations adds a relation between the Edge Stack to the related endpoints
|
||||||
|
func updateEndpointRelations(endpointRelationService portainer.EndpointRelationService, edgeStackID portainer.EdgeStackID, relatedEndpointIds []portainer.EndpointID) error {
|
||||||
|
for _, endpointID := range relatedEndpointIds {
|
||||||
|
relation, err := endpointRelationService.EndpointRelation(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to find endpoint relation in database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
relation.EdgeStacks[edgeStackID] = true
|
||||||
|
|
||||||
|
err = endpointRelationService.UpdateEndpointRelation(endpointID, relation)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to persist endpoint relation in database: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,38 @@
|
||||||
|
package edgestacks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_updateEndpointRelation_successfulRuns(t *testing.T) {
|
||||||
|
edgeStackID := portainer.EdgeStackID(5)
|
||||||
|
endpointRelations := []portainer.EndpointRelation{
|
||||||
|
{EndpointID: 1, EdgeStacks: map[portainer.EdgeStackID]bool{}},
|
||||||
|
{EndpointID: 2, EdgeStacks: map[portainer.EdgeStackID]bool{}},
|
||||||
|
{EndpointID: 3, EdgeStacks: map[portainer.EdgeStackID]bool{}},
|
||||||
|
{EndpointID: 4, EdgeStacks: map[portainer.EdgeStackID]bool{}},
|
||||||
|
{EndpointID: 5, EdgeStacks: map[portainer.EdgeStackID]bool{}},
|
||||||
|
}
|
||||||
|
|
||||||
|
relatedIds := []portainer.EndpointID{2, 3}
|
||||||
|
|
||||||
|
dataStore := testhelpers.NewDatastore(testhelpers.WithEndpointRelations(endpointRelations))
|
||||||
|
|
||||||
|
err := updateEndpointRelations(dataStore.EndpointRelation(), edgeStackID, relatedIds)
|
||||||
|
|
||||||
|
assert.NoError(t, err, "updateEndpointRelations should not fail")
|
||||||
|
|
||||||
|
relatedSet := map[portainer.EndpointID]bool{}
|
||||||
|
for _, relationID := range relatedIds {
|
||||||
|
relatedSet[relationID] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, relation := range endpointRelations {
|
||||||
|
shouldBeRelated := relatedSet[relation.EndpointID]
|
||||||
|
assert.Equal(t, shouldBeRelated, relation.EdgeStacks[edgeStackID])
|
||||||
|
}
|
||||||
|
}
|
|
@ -42,24 +42,17 @@ func (handler *Handler) edgeStackDelete(w http.ResponseWriter, r *http.Request)
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the edge stack from the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to remove the edge stack from the database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoints, err := handler.DataStore.Endpoint().Endpoints()
|
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments relations config from database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups()
|
relatedEndpointIds, err := edge.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related environments from database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups()
|
for _, endpointID := range relatedEndpointIds {
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
relatedEndpoints, err := edge.EdgeStackRelatedEndpoints(edgeStack.EdgeGroups, endpoints, endpointGroups, edgeGroups)
|
|
||||||
|
|
||||||
for _, endpointID := range relatedEndpoints {
|
|
||||||
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID)
|
relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find environment relation in database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find environment relation in database", err}
|
||||||
|
|
|
@ -41,7 +41,12 @@ func (handler *Handler) edgeStackFile(w http.ResponseWriter, r *http.Request) *h
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, stack.EntryPoint))
|
fileName := stack.EntryPoint
|
||||||
|
if stack.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||||
|
fileName = stack.ManifestPath
|
||||||
|
}
|
||||||
|
|
||||||
|
stackFileContent, err := handler.FileService.GetFileContent(path.Join(stack.ProjectPath, fileName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err}
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,24 +5,24 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/asaskevich/govalidator"
|
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
"github.com/portainer/libhttp/request"
|
"github.com/portainer/libhttp/request"
|
||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||||
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
"github.com/portainer/portainer/api/internal/edge"
|
||||||
)
|
)
|
||||||
|
|
||||||
type updateEdgeStackPayload struct {
|
type updateEdgeStackPayload struct {
|
||||||
StackFileContent string
|
StackFileContent string
|
||||||
Version *int
|
Version *int
|
||||||
Prune *bool
|
|
||||||
EdgeGroups []portainer.EdgeGroupID
|
EdgeGroups []portainer.EdgeGroupID
|
||||||
|
DeploymentType portainer.EdgeStackDeploymentType
|
||||||
}
|
}
|
||||||
|
|
||||||
func (payload *updateEdgeStackPayload) Validate(r *http.Request) error {
|
func (payload *updateEdgeStackPayload) Validate(r *http.Request) error {
|
||||||
if govalidator.IsNull(payload.StackFileContent) {
|
if payload.StackFileContent == "" {
|
||||||
return errors.New("Invalid stack file content")
|
return errors.New("Invalid stack file content")
|
||||||
}
|
}
|
||||||
if payload.EdgeGroups != nil && len(payload.EdgeGroups) == 0 {
|
if payload.EdgeGroups != nil && len(payload.EdgeGroups) == 0 {
|
||||||
|
@ -64,33 +64,23 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
||||||
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
return &httperror.HandlerError{http.StatusBadRequest, "Invalid request payload", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "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 &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related environments from database", err}
|
||||||
|
}
|
||||||
|
|
||||||
if payload.EdgeGroups != nil {
|
if payload.EdgeGroups != nil {
|
||||||
endpoints, err := handler.DataStore.Endpoint().Endpoints()
|
newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, relationConfig.endpoints, relationConfig.endpointGroups, relationConfig.edgeGroups)
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environments from database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
endpointGroups, err := handler.DataStore.EndpointGroup().EndpointGroups()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve environment groups from database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
edgeGroups, err := handler.DataStore.EdgeGroup().EdgeGroups()
|
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge groups from database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
oldRelated, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, endpoints, endpointGroups, edgeGroups)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related environments from database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related environments from database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, endpoints, endpointGroups, edgeGroups)
|
oldRelatedSet := EndpointSet(relatedEndpointIds)
|
||||||
if err != nil {
|
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related environments from database", err}
|
|
||||||
}
|
|
||||||
|
|
||||||
oldRelatedSet := EndpointSet(oldRelated)
|
|
||||||
newRelatedSet := EndpointSet(newRelated)
|
newRelatedSet := EndpointSet(newRelated)
|
||||||
|
|
||||||
endpointsToRemove := map[portainer.EndpointID]bool{}
|
endpointsToRemove := map[portainer.EndpointID]bool{}
|
||||||
|
@ -136,17 +126,55 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request)
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.EdgeGroups = payload.EdgeGroups
|
stack.EdgeGroups = payload.EdgeGroups
|
||||||
|
relatedEndpointIds = newRelated
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Prune != nil {
|
if stack.DeploymentType != payload.DeploymentType {
|
||||||
stack.Prune = *payload.Prune
|
// deployment type was changed - need to delete the old file
|
||||||
|
err = handler.FileService.RemoveDirectory(stack.ProjectPath)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to clear old files", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
stack.EntryPoint = ""
|
||||||
|
stack.ManifestPath = ""
|
||||||
|
stack.DeploymentType = payload.DeploymentType
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
if payload.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
||||||
if err != nil {
|
if stack.EntryPoint == "" {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err}
|
stack.EntryPoint = filesystem.ComposeFileDefaultName
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = handler.convertAndStoreKubeManifestIfNeeded(stack, relatedEndpointIds)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to convert and persist updated Kubernetes manifest file on disk", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
if stack.ManifestPath == "" {
|
||||||
|
stack.ManifestPath = filesystem.ManifestFileDefaultName
|
||||||
|
}
|
||||||
|
|
||||||
|
hasDockerEndpoint, err := hasDockerEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to check for existence of docker endpoint", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasDockerEndpoint {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Edge stack with docker endpoint cannot be deployed with kubernetes config", err}
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
|
||||||
|
if err != nil {
|
||||||
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to persist updated Compose file on disk", err}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.Version != nil && *payload.Version != stack.Version {
|
if payload.Version != nil && *payload.Version != stack.Version {
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
package edgestacks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func hasKubeEndpoint(endpointService portainer.EndpointService, endpointIDs []portainer.EndpointID) (bool, error) {
|
||||||
|
return hasEndpointPredicate(endpointService, endpointIDs, endpointutils.IsKubernetesEndpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasDockerEndpoint(endpointService portainer.EndpointService, endpointIDs []portainer.EndpointID) (bool, error) {
|
||||||
|
return hasEndpointPredicate(endpointService, endpointIDs, endpointutils.IsDockerEndpoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasEndpointPredicate(endpointService portainer.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 endpoint from database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if predicate(endpoint) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type endpointRelationsConfig struct {
|
||||||
|
endpoints []portainer.Endpoint
|
||||||
|
endpointGroups []portainer.EndpointGroup
|
||||||
|
edgeGroups []portainer.EdgeGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchEndpointRelationsConfig(dataStore portainer.DataStore) (*endpointRelationsConfig, error) {
|
||||||
|
endpoints, err := dataStore.Endpoint().Endpoints()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to retrieve environments from database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointGroups, err := dataStore.EndpointGroup().EndpointGroups()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to retrieve environment groups from database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeGroups, err := dataStore.EdgeGroup().EdgeGroups()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to retrieve edge groups from database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &endpointRelationsConfig{
|
||||||
|
endpoints: endpoints,
|
||||||
|
endpointGroups: endpointGroups,
|
||||||
|
edgeGroups: edgeGroups,
|
||||||
|
}, nil
|
||||||
|
}
|
|
@ -0,0 +1,99 @@
|
||||||
|
package edgestacks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/internal/testhelpers"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Test_hasKubeEndpoint(t *testing.T) {
|
||||||
|
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)
|
||||||
|
assert.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) {
|
||||||
|
datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{}))
|
||||||
|
|
||||||
|
_, err := hasKubeEndpoint(datastore.Endpoint(), []portainer.EndpointID{1})
|
||||||
|
assert.Error(t, err, "hasKubeEndpoint should fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_hasDockerEndpoint(t *testing.T) {
|
||||||
|
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)
|
||||||
|
assert.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) {
|
||||||
|
datastore := testhelpers.NewDatastore(testhelpers.WithEndpoints([]portainer.Endpoint{}))
|
||||||
|
|
||||||
|
_, err := hasDockerEndpoint(datastore.Endpoint(), []portainer.EndpointID{1})
|
||||||
|
assert.Error(t, err, "hasDockerEndpoint should fail")
|
||||||
|
}
|
|
@ -1,21 +1,26 @@
|
||||||
package edgestacks
|
package edgestacks
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
httperror "github.com/portainer/libhttp/error"
|
httperror "github.com/portainer/libhttp/error"
|
||||||
"github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/http/security"
|
"github.com/portainer/portainer/api/http/security"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler is the HTTP handler used to handle endpoint group operations.
|
// Handler is the HTTP handler used to handle endpoint group operations.
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
*mux.Router
|
*mux.Router
|
||||||
requestBouncer *security.RequestBouncer
|
requestBouncer *security.RequestBouncer
|
||||||
DataStore portainer.DataStore
|
DataStore portainer.DataStore
|
||||||
FileService portainer.FileService
|
FileService portainer.FileService
|
||||||
GitService portainer.GitService
|
GitService portainer.GitService
|
||||||
|
KubernetesDeployer portainer.KubernetesDeployer
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a handler to manage endpoint group operations.
|
// NewHandler creates a handler to manage endpoint group operations.
|
||||||
|
@ -40,3 +45,34 @@ func NewHandler(bouncer *security.RequestBouncer) *Handler {
|
||||||
bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusUpdate))).Methods(http.MethodPut)
|
bouncer.PublicAccess(httperror.LoggerHandler(h.edgeStackStatusUpdate))).Methods(http.MethodPut)
|
||||||
return h
|
return h
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) convertAndStoreKubeManifestIfNeeded(edgeStack *portainer.EdgeStack, relatedEndpointIds []portainer.EndpointID) error {
|
||||||
|
hasKubeEndpoint, err := hasKubeEndpoint(handler.DataStore.Endpoint(), relatedEndpointIds)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to check if edge stack has kube endpoints: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasKubeEndpoint {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
composeConfig, err := handler.FileService.GetFileContent(path.Join(edgeStack.ProjectPath, edgeStack.EntryPoint))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unable to retrieve Compose file from disk: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
kompose, err := handler.KubernetesDeployer.ConvertCompose(composeConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed converting compose file to kubernetes manifest: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
komposeFileName := filesystem.ManifestFileDefaultName
|
||||||
|
_, err = handler.FileService.StoreEdgeStackFileFromBytes(strconv.Itoa(int(edgeStack.ID)), komposeFileName, kompose)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to store kube manifest file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
edgeStack.ManifestPath = komposeFileName
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
package endpointedge
|
package endpointedge
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
|
@ -8,11 +9,11 @@ import (
|
||||||
"github.com/portainer/libhttp/request"
|
"github.com/portainer/libhttp/request"
|
||||||
"github.com/portainer/libhttp/response"
|
"github.com/portainer/libhttp/response"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
"github.com/portainer/portainer/api/bolt/errors"
|
bolterrors "github.com/portainer/portainer/api/bolt/errors"
|
||||||
|
"github.com/portainer/portainer/api/internal/endpointutils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type configResponse struct {
|
type configResponse struct {
|
||||||
Prune bool
|
|
||||||
StackFileContent string
|
StackFileContent string
|
||||||
Name string
|
Name string
|
||||||
}
|
}
|
||||||
|
@ -36,7 +37,7 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
endpoint, err := handler.DataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
|
||||||
if err == errors.ErrObjectNotFound {
|
if err == bolterrors.ErrObjectNotFound {
|
||||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err}
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an environment with the specified identifier inside the database", err}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an environment with the specified identifier inside the database", err}
|
||||||
|
@ -53,19 +54,33 @@ func (handler *Handler) endpointEdgeStackInspect(w http.ResponseWriter, r *http.
|
||||||
}
|
}
|
||||||
|
|
||||||
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
edgeStack, err := handler.DataStore.EdgeStack().EdgeStack(portainer.EdgeStackID(edgeStackID))
|
||||||
if err == errors.ErrObjectNotFound {
|
if err == bolterrors.ErrObjectNotFound {
|
||||||
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge stack with the specified identifier inside the database", err}
|
return &httperror.HandlerError{http.StatusNotFound, "Unable to find an edge stack with the specified identifier inside the database", err}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find an edge stack with the specified identifier inside the database", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFileContent, err := handler.FileService.GetFileContent(path.Join(edgeStack.ProjectPath, edgeStack.EntryPoint))
|
fileName := edgeStack.EntryPoint
|
||||||
|
if endpointutils.IsDockerEndpoint(endpoint) {
|
||||||
|
if fileName == "" {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Docker is not supported by this stack", errors.New("Docker is not supported by this stack")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if endpointutils.IsKubernetesEndpoint(endpoint) {
|
||||||
|
fileName = edgeStack.ManifestPath
|
||||||
|
|
||||||
|
if fileName == "" {
|
||||||
|
return &httperror.HandlerError{http.StatusBadRequest, "Kubernetes is not supported by this stack", errors.New("Kubernetes is not supported by this stack")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stackFileContent, err := handler.FileService.GetFileContent(path.Join(edgeStack.ProjectPath, fileName))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err}
|
return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err}
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.JSON(w, configResponse{
|
return response.JSON(w, configResponse{
|
||||||
Prune: edgeStack.Prune,
|
|
||||||
StackFileContent: string(stackFileContent),
|
StackFileContent: string(stackFileContent),
|
||||||
Name: edgeStack.Name,
|
Name: edgeStack.Name,
|
||||||
})
|
})
|
||||||
|
|
|
@ -130,6 +130,7 @@ func (server *Server) Start() error {
|
||||||
edgeStacksHandler.DataStore = server.DataStore
|
edgeStacksHandler.DataStore = server.DataStore
|
||||||
edgeStacksHandler.FileService = server.FileService
|
edgeStacksHandler.FileService = server.FileService
|
||||||
edgeStacksHandler.GitService = server.GitService
|
edgeStacksHandler.GitService = server.GitService
|
||||||
|
edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer
|
||||||
|
|
||||||
var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer)
|
var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer)
|
||||||
edgeTemplatesHandler.DataStore = server.DataStore
|
edgeTemplatesHandler.DataStore = server.DataStore
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
package endpointutils
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type isEndpointTypeTest struct {
|
||||||
|
endpointType portainer.EndpointType
|
||||||
|
expected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_IsDockerEndpoint(t *testing.T) {
|
||||||
|
tests := []isEndpointTypeTest{
|
||||||
|
{endpointType: portainer.DockerEnvironment, expected: true},
|
||||||
|
{endpointType: portainer.AgentOnDockerEnvironment, expected: true},
|
||||||
|
{endpointType: portainer.AzureEnvironment, expected: false},
|
||||||
|
{endpointType: portainer.EdgeAgentOnDockerEnvironment, expected: true},
|
||||||
|
{endpointType: portainer.KubernetesLocalEnvironment, expected: false},
|
||||||
|
{endpointType: portainer.AgentOnKubernetesEnvironment, expected: false},
|
||||||
|
{endpointType: portainer.EdgeAgentOnKubernetesEnvironment, expected: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
ans := IsDockerEndpoint(&portainer.Endpoint{Type: test.endpointType})
|
||||||
|
assert.Equal(t, test.expected, ans)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_IsKubernetesEndpoint(t *testing.T) {
|
||||||
|
tests := []isEndpointTypeTest{
|
||||||
|
{endpointType: portainer.DockerEnvironment, expected: false},
|
||||||
|
{endpointType: portainer.AgentOnDockerEnvironment, expected: false},
|
||||||
|
{endpointType: portainer.AzureEnvironment, expected: false},
|
||||||
|
{endpointType: portainer.EdgeAgentOnDockerEnvironment, expected: false},
|
||||||
|
{endpointType: portainer.KubernetesLocalEnvironment, expected: true},
|
||||||
|
{endpointType: portainer.AgentOnKubernetesEnvironment, expected: true},
|
||||||
|
{endpointType: portainer.EdgeAgentOnKubernetesEnvironment, expected: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
ans := IsKubernetesEndpoint(&portainer.Endpoint{Type: test.endpointType})
|
||||||
|
assert.Equal(t, test.expected, ans)
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/bolt/errors"
|
||||||
)
|
)
|
||||||
|
|
||||||
type datastore struct {
|
type datastore struct {
|
||||||
|
@ -127,3 +128,106 @@ func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption {
|
||||||
d.edgeJob = &stubEdgeJobService{jobs: js}
|
d.edgeJob = &stubEdgeJobService{jobs: js}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type stubEndpointRelationService struct {
|
||||||
|
relations []portainer.EndpointRelation
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubEndpointRelationService) EndpointRelations() ([]portainer.EndpointRelation, error) {
|
||||||
|
return s.relations, nil
|
||||||
|
}
|
||||||
|
func (s *stubEndpointRelationService) EndpointRelation(ID portainer.EndpointID) (*portainer.EndpointRelation, error) {
|
||||||
|
for _, relation := range s.relations {
|
||||||
|
if relation.EndpointID == ID {
|
||||||
|
return &relation, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.ErrObjectNotFound
|
||||||
|
}
|
||||||
|
func (s *stubEndpointRelationService) CreateEndpointRelation(EndpointRelation *portainer.EndpointRelation) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *stubEndpointRelationService) UpdateEndpointRelation(ID portainer.EndpointID, relation *portainer.EndpointRelation) error {
|
||||||
|
for i, r := range s.relations {
|
||||||
|
if r.EndpointID == ID {
|
||||||
|
s.relations[i] = *relation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *stubEndpointRelationService) DeleteEndpointRelation(ID portainer.EndpointID) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
func (s *stubEndpointRelationService) GetNextIdentifier() int { return 0 }
|
||||||
|
|
||||||
|
// WithEndpointRelations option will instruct datastore to return provided jobs
|
||||||
|
func WithEndpointRelations(relations []portainer.EndpointRelation) datastoreOption {
|
||||||
|
return func(d *datastore) {
|
||||||
|
d.endpointRelation = &stubEndpointRelationService{relations: relations}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type stubEndpointService struct {
|
||||||
|
endpoints []portainer.Endpoint
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubEndpointService) Endpoint(ID portainer.EndpointID) (*portainer.Endpoint, error) {
|
||||||
|
for _, endpoint := range s.endpoints {
|
||||||
|
if endpoint.ID == ID {
|
||||||
|
return &endpoint, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.ErrObjectNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubEndpointService) Endpoints() ([]portainer.Endpoint, error) {
|
||||||
|
return s.endpoints, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubEndpointService) CreateEndpoint(endpoint *portainer.Endpoint) error {
|
||||||
|
s.endpoints = append(s.endpoints, *endpoint)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubEndpointService) UpdateEndpoint(ID portainer.EndpointID, endpoint *portainer.Endpoint) error {
|
||||||
|
for i, e := range s.endpoints {
|
||||||
|
if e.ID == ID {
|
||||||
|
s.endpoints[i] = *endpoint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubEndpointService) DeleteEndpoint(ID portainer.EndpointID) error {
|
||||||
|
endpoints := []portainer.Endpoint{}
|
||||||
|
|
||||||
|
for _, endpoint := range s.endpoints {
|
||||||
|
if endpoint.ID != ID {
|
||||||
|
endpoints = append(endpoints, endpoint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
s.endpoints = endpoints
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubEndpointService) Synchronize(toCreate []*portainer.Endpoint, toUpdate []*portainer.Endpoint, toDelete []*portainer.Endpoint) error {
|
||||||
|
panic("not implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubEndpointService) GetNextIdentifier() int {
|
||||||
|
return len(s.endpoints)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithEndpoints option will instruct datastore to return provided endpoints
|
||||||
|
func WithEndpoints(endpoints []portainer.Endpoint) datastoreOption {
|
||||||
|
return func(d *datastore) {
|
||||||
|
d.endpoint = &stubEndpointService{endpoints: endpoints}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -194,17 +194,23 @@ type (
|
||||||
//EdgeStack represents an edge stack
|
//EdgeStack represents an edge stack
|
||||||
EdgeStack struct {
|
EdgeStack struct {
|
||||||
// EdgeStack Identifier
|
// EdgeStack Identifier
|
||||||
ID EdgeStackID `json:"Id" example:"1"`
|
ID EdgeStackID `json:"Id" example:"1"`
|
||||||
Name string `json:"Name"`
|
Name string `json:"Name"`
|
||||||
Status map[EndpointID]EdgeStackStatus `json:"Status"`
|
Status map[EndpointID]EdgeStackStatus `json:"Status"`
|
||||||
CreationDate int64 `json:"CreationDate"`
|
CreationDate int64 `json:"CreationDate"`
|
||||||
EdgeGroups []EdgeGroupID `json:"EdgeGroups"`
|
EdgeGroups []EdgeGroupID `json:"EdgeGroups"`
|
||||||
ProjectPath string `json:"ProjectPath"`
|
ProjectPath string `json:"ProjectPath"`
|
||||||
EntryPoint string `json:"EntryPoint"`
|
EntryPoint string `json:"EntryPoint"`
|
||||||
Version int `json:"Version"`
|
Version int `json:"Version"`
|
||||||
Prune bool `json:"Prune"`
|
ManifestPath string
|
||||||
|
DeploymentType EdgeStackDeploymentType
|
||||||
|
|
||||||
|
// Deprecated
|
||||||
|
Prune bool `json:"Prune"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EdgeStackDeploymentType int
|
||||||
|
|
||||||
//EdgeStackID represents an edge stack id
|
//EdgeStackID represents an edge stack id
|
||||||
EdgeStackID int
|
EdgeStackID int
|
||||||
|
|
||||||
|
@ -1502,6 +1508,13 @@ const (
|
||||||
CustomTemplatePlatformWindows
|
CustomTemplatePlatformWindows
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// EdgeStackDeploymentCompose represent an edge stack deployed using a compose file
|
||||||
|
EdgeStackDeploymentCompose EdgeStackDeploymentType = iota
|
||||||
|
// EdgeStackDeploymentKubernetes represent an edge stack deployed using a kubernetes manifest file
|
||||||
|
EdgeStackDeploymentKubernetes
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
_ EdgeStackStatusType = iota
|
_ EdgeStackStatusType = iota
|
||||||
//StatusOk represents a successfully deployed edge stack
|
//StatusOk represents a successfully deployed edge stack
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
angular.module('portainer.edge', []).config(function config($stateRegistryProvider) {
|
import edgeStackModule from './views/edge-stacks';
|
||||||
|
|
||||||
|
angular.module('portainer.edge', [edgeStackModule]).config(function config($stateRegistryProvider) {
|
||||||
const edge = {
|
const edge = {
|
||||||
name: 'edge',
|
name: 'edge',
|
||||||
url: '/edge',
|
url: '/edge',
|
||||||
|
|
|
@ -1,4 +1,12 @@
|
||||||
<ui-select multiple ng-model="$ctrl.model" close-on-select="false" data-cy="edgeGroupCreate-edgeGroupsSelector">
|
<!-- on-select/on-remove are called with model because ui-select uses 2-way-binding -->
|
||||||
|
<ui-select
|
||||||
|
multiple
|
||||||
|
ng-model="$ctrl.model"
|
||||||
|
close-on-select="false"
|
||||||
|
on-select="$ctrl.onChange($ctrl.model)"
|
||||||
|
on-remove="$ctrl.onChange($ctrl.model)"
|
||||||
|
data-cy="edgeGroupCreate-edgeGroupsSelector"
|
||||||
|
>
|
||||||
<ui-select-match placeholder="Select one or multiple group(s)">
|
<ui-select-match placeholder="Select one or multiple group(s)">
|
||||||
<span>
|
<span>
|
||||||
{{ $item.Name }}
|
{{ $item.Name }}
|
||||||
|
|
|
@ -3,7 +3,8 @@ import angular from 'angular';
|
||||||
angular.module('portainer.edge').component('edgeGroupsSelector', {
|
angular.module('portainer.edge').component('edgeGroupsSelector', {
|
||||||
templateUrl: './edgeGroupsSelector.html',
|
templateUrl: './edgeGroupsSelector.html',
|
||||||
bindings: {
|
bindings: {
|
||||||
model: '=',
|
model: '<',
|
||||||
items: '<',
|
items: '<',
|
||||||
|
onChange: '<',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
export default class EdgeStackDeploymentTypeSelectorController {
|
||||||
|
/* @ngInject */
|
||||||
|
constructor() {
|
||||||
|
this.deploymentOptions = [
|
||||||
|
{ id: 'deployment_compose', icon: 'fab fa-docker', label: 'Compose', description: 'docker-compose format', value: 0 },
|
||||||
|
{
|
||||||
|
id: 'deployment_kube',
|
||||||
|
icon: 'fa fa-cubes',
|
||||||
|
label: 'Kubernetes',
|
||||||
|
description: 'Kubernetes manifest format',
|
||||||
|
value: 1,
|
||||||
|
disabled: () => {
|
||||||
|
return this.hasDockerEndpoint();
|
||||||
|
},
|
||||||
|
tooltip: () => {
|
||||||
|
return this.hasDockerEndpoint() ? 'Cannot use this option with Edge Docker endpoints' : '';
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,4 @@
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Deployment type
|
||||||
|
</div>
|
||||||
|
<box-selector radio-name="deploymentType" ng-model="$ctrl.value" options="$ctrl.deploymentOptions" on-change="($ctrl.onChange)"></box-selector>
|
|
@ -0,0 +1,15 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
import controller from './edge-stack-deployment-type-selector.controller.js';
|
||||||
|
|
||||||
|
export const edgeStackDeploymentTypeSelector = {
|
||||||
|
templateUrl: './edge-stack-deployment-type-selector.html',
|
||||||
|
controller,
|
||||||
|
|
||||||
|
bindings: {
|
||||||
|
value: '<',
|
||||||
|
onChange: '<',
|
||||||
|
hasDockerEndpoint: '<',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
angular.module('portainer.edge').component('edgeStackDeploymentTypeSelector', edgeStackDeploymentTypeSelector);
|
|
@ -4,52 +4,70 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<edge-groups-selector model="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups"></edge-groups-selector>
|
<edge-groups-selector model="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- web-editor -->
|
<div class="form-group" ng-if="!$ctrl.validateEndpointsForDeployment()">
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Web editor
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<span class="col-sm-12 text-muted small">
|
|
||||||
You can get more information about Compose file format in the
|
|
||||||
<a href="https://docs.docker.com/compose/compose-file/" target="_blank">
|
|
||||||
official documentation
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<code-editor
|
<div class="small text-muted space-right text-warning">
|
||||||
value="$ctrl.model.StackFileContent"
|
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i>
|
||||||
identifier="stack-creation-editor"
|
One or more of the selected Edge group contains Edge Docker endpoints that cannot be used with a Kubernetes Edge stack.
|
||||||
placeholder="# Define or paste the content of your docker-compose file here"
|
</div>
|
||||||
yml="true"
|
|
||||||
on-change="($ctrl.editorUpdate)"
|
|
||||||
></code-editor>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- !web-editor -->
|
|
||||||
|
|
||||||
<div class="col-sm-12 form-section-title">
|
<edge-stack-deployment-type-selector
|
||||||
Options
|
value="$ctrl.model.DeploymentType"
|
||||||
</div>
|
has-docker-endpoint="$ctrl.hasDockerEndpoint"
|
||||||
<div class="form-group">
|
on-change="($ctrl.onChangeDeploymentType)"
|
||||||
|
></edge-stack-deployment-type-selector>
|
||||||
|
|
||||||
|
<div class="form-group" ng-if="$ctrl.model.DeploymentType === 0 && $ctrl.hasKubeEndpoint()">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<label for="EnablePrune" class="control-label text-left">
|
<div class="small text-muted space-right">
|
||||||
Prune services
|
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i>
|
||||||
<portainer-tooltip position="bottom" message="Prune services that are not longer referenced in the stack file."></portainer-tooltip>
|
Portainer uses <a href="https://kompose.io/" target="_blank">Kompose</a> to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that not all the
|
||||||
</label>
|
Compose format options are supported by Kompose at the moment.
|
||||||
<label class="switch" style="margin-left: 20px;">
|
</div>
|
||||||
<input type="checkbox" name="EnablePrune" ng-model="$ctrl.model.Prune" />
|
|
||||||
<i></i>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<web-editor-form
|
||||||
|
ng-if="$ctrl.model.DeploymentType === 0"
|
||||||
|
value="$ctrl.model.StackFileContent"
|
||||||
|
yml="true"
|
||||||
|
identifier="compose-editor"
|
||||||
|
placeholder="# Define or paste the content of your docker-compose file here"
|
||||||
|
on-change="($ctrl.onChangeComposeConfig)"
|
||||||
|
>
|
||||||
|
<editor-description>
|
||||||
|
<div>
|
||||||
|
You can get more information about Compose file format in the
|
||||||
|
<a href="https://docs.docker.com/compose/compose-file/" target="_blank">
|
||||||
|
official documentation
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</editor-description>
|
||||||
|
</web-editor-form>
|
||||||
|
|
||||||
|
<web-editor-form
|
||||||
|
ng-if="$ctrl.model.DeploymentType === 1"
|
||||||
|
value="$ctrl.model.StackFileContent"
|
||||||
|
yml="true"
|
||||||
|
identifier="kube-manifest-editor"
|
||||||
|
placeholder="# Define or paste the content of your manifest here"
|
||||||
|
on-change="($ctrl.onChangeKubeManifest)"
|
||||||
|
>
|
||||||
|
<editor-description>
|
||||||
|
<p>
|
||||||
|
You can get more information about Kubernetes file format in the
|
||||||
|
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/" target="_blank">official documentation</a>.
|
||||||
|
</p>
|
||||||
|
</editor-description>
|
||||||
|
</web-editor-form>
|
||||||
|
|
||||||
<!-- actions -->
|
<!-- actions -->
|
||||||
<div class="col-sm-12 form-section-title">
|
<div class="col-sm-12 form-section-title">
|
||||||
Actions
|
Actions
|
||||||
|
@ -59,9 +77,7 @@
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm"
|
class="btn btn-primary btn-sm"
|
||||||
ng-disabled="$ctrl.actionInProgress
|
ng-disabled="$ctrl.actionInProgress || !$ctrl.isFormValid()"
|
||||||
|| !$ctrl.model.EdgeGroups.length
|
|
||||||
|| !$ctrl.model.StackFileContent"
|
|
||||||
ng-click="$ctrl.submitAction()"
|
ng-click="$ctrl.submitAction()"
|
||||||
button-spinner="$ctrl.actionInProgress"
|
button-spinner="$ctrl.actionInProgress"
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,13 +1,83 @@
|
||||||
|
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
|
||||||
|
|
||||||
export class EditEdgeStackFormController {
|
export class EditEdgeStackFormController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor() {
|
constructor() {
|
||||||
this.editorUpdate = this.editorUpdate.bind(this);
|
this.state = {
|
||||||
|
endpointTypes: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
this.fileContents = {
|
||||||
|
0: '',
|
||||||
|
1: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
this.onChangeGroups = this.onChangeGroups.bind(this);
|
||||||
|
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||||
|
this.onChangeComposeConfig = this.onChangeComposeConfig.bind(this);
|
||||||
|
this.onChangeKubeManifest = this.onChangeKubeManifest.bind(this);
|
||||||
|
this.hasDockerEndpoint = this.hasDockerEndpoint.bind(this);
|
||||||
|
this.hasKubeEndpoint = this.hasKubeEndpoint.bind(this);
|
||||||
|
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
||||||
|
this.removeLineBreaks = this.removeLineBreaks.bind(this);
|
||||||
|
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
editorUpdate(cm) {
|
hasKubeEndpoint() {
|
||||||
if (this.model.StackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== cm.getValue().replace(/(\r\n|\n|\r)/gm, '')) {
|
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment);
|
||||||
this.model.StackFileContent = cm.getValue();
|
}
|
||||||
|
|
||||||
|
hasDockerEndpoint() {
|
||||||
|
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnDockerEnvironment);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeGroups(groups) {
|
||||||
|
this.model.EdgeGroups = groups;
|
||||||
|
|
||||||
|
this.checkEndpointTypes(groups);
|
||||||
|
}
|
||||||
|
|
||||||
|
isFormValid() {
|
||||||
|
return this.model.EdgeGroups.length && this.model.StackFileContent && this.validateEndpointsForDeployment();
|
||||||
|
}
|
||||||
|
|
||||||
|
checkEndpointTypes(groups) {
|
||||||
|
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
||||||
|
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeLineBreaks(value) {
|
||||||
|
return value.replace(/(\r\n|\n|\r)/gm, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeFileContent(type, value) {
|
||||||
|
const oldValue = this.fileContents[type];
|
||||||
|
if (this.removeLineBreaks(oldValue) !== this.removeLineBreaks(value)) {
|
||||||
|
this.model.StackFileContent = value;
|
||||||
|
this.fileContents[type] = value;
|
||||||
this.isEditorDirty = true;
|
this.isEditorDirty = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onChangeKubeManifest(value) {
|
||||||
|
this.onChangeFileContent(1, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeComposeConfig(value) {
|
||||||
|
this.onChangeFileContent(0, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeDeploymentType(deploymentType) {
|
||||||
|
this.model.DeploymentType = deploymentType;
|
||||||
|
|
||||||
|
this.model.StackFileContent = this.fileContents[deploymentType];
|
||||||
|
}
|
||||||
|
|
||||||
|
validateEndpointsForDeployment() {
|
||||||
|
return this.model.DeploymentType == 0 || !this.hasDockerEndpoint();
|
||||||
|
}
|
||||||
|
|
||||||
|
$onInit() {
|
||||||
|
this.checkEndpointTypes(this.model.EdgeGroups);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -49,7 +49,7 @@ export class EdgeGroupFormController {
|
||||||
async getDynamicEndpointsAsync() {
|
async getDynamicEndpointsAsync() {
|
||||||
const { pageNumber, limit, search } = this.endpoints.state;
|
const { pageNumber, limit, search } = this.endpoints.state;
|
||||||
const start = (pageNumber - 1) * limit + 1;
|
const start = (pageNumber - 1) * limit + 1;
|
||||||
const query = { search, types: [4], tagIds: this.model.TagIds, tagsPartialMatch: this.model.PartialMatch };
|
const query = { search, types: [4, 7], tagIds: this.model.TagIds, tagsPartialMatch: this.model.PartialMatch };
|
||||||
|
|
||||||
const response = await this.EndpointService.endpoints(start, limit, query);
|
const response = await this.EndpointService.endpoints(start, limit, query);
|
||||||
|
|
||||||
|
|
|
@ -25,15 +25,10 @@ angular.module('portainer.edge').factory('EdgeStackService', function EdgeStackS
|
||||||
};
|
};
|
||||||
|
|
||||||
service.updateStack = async function updateStack(id, stack) {
|
service.updateStack = async function updateStack(id, stack) {
|
||||||
return EdgeStacks.update({ id }, stack);
|
return EdgeStacks.update({ id }, stack).$promise;
|
||||||
};
|
};
|
||||||
|
|
||||||
service.createStackFromFileContent = async function createStackFromFileContent(name, stackFileContent, edgeGroups) {
|
service.createStackFromFileContent = async function createStackFromFileContent(payload) {
|
||||||
var payload = {
|
|
||||||
Name: name,
|
|
||||||
StackFileContent: stackFileContent,
|
|
||||||
EdgeGroups: edgeGroups,
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
return await EdgeStacks.create({ method: 'string' }, payload).$promise;
|
return await EdgeStacks.create({ method: 'string' }, payload).$promise;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -41,27 +36,28 @@ angular.module('portainer.edge').factory('EdgeStackService', function EdgeStackS
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
service.createStackFromFileUpload = async function createStackFromFileUpload(name, stackFile, edgeGroups) {
|
service.createStackFromFileUpload = async function createStackFromFileUpload(payload, file) {
|
||||||
try {
|
try {
|
||||||
return await FileUploadService.createEdgeStack(name, stackFile, edgeGroups);
|
return await FileUploadService.createEdgeStack(payload, file);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw { msg: 'Unable to create the stack', err };
|
throw { msg: 'Unable to create the stack', err };
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
service.createStackFromGitRepository = async function createStackFromGitRepository(name, repositoryOptions, edgeGroups) {
|
service.createStackFromGitRepository = async function createStackFromGitRepository(payload, repositoryOptions) {
|
||||||
var payload = {
|
|
||||||
Name: name,
|
|
||||||
RepositoryURL: repositoryOptions.RepositoryURL,
|
|
||||||
RepositoryReferenceName: repositoryOptions.RepositoryReferenceName,
|
|
||||||
ComposeFilePathInRepository: repositoryOptions.ComposeFilePathInRepository,
|
|
||||||
RepositoryAuthentication: repositoryOptions.RepositoryAuthentication,
|
|
||||||
RepositoryUsername: repositoryOptions.RepositoryUsername,
|
|
||||||
RepositoryPassword: repositoryOptions.RepositoryPassword,
|
|
||||||
EdgeGroups: edgeGroups,
|
|
||||||
};
|
|
||||||
try {
|
try {
|
||||||
return await EdgeStacks.create({ method: 'repository' }, payload).$promise;
|
return await EdgeStacks.create(
|
||||||
|
{ method: 'repository' },
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
RepositoryURL: repositoryOptions.RepositoryURL,
|
||||||
|
RepositoryReferenceName: repositoryOptions.RepositoryReferenceName,
|
||||||
|
FilePathInRepository: repositoryOptions.FilePathInRepository,
|
||||||
|
RepositoryAuthentication: repositoryOptions.RepositoryAuthentication,
|
||||||
|
RepositoryUsername: repositoryOptions.RepositoryUsername,
|
||||||
|
RepositoryPassword: repositoryOptions.RepositoryPassword,
|
||||||
|
}
|
||||||
|
).$promise;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw { msg: 'Unable to create the stack', err };
|
throw { msg: 'Unable to create the stack', err };
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,18 +1,12 @@
|
||||||
export class EditEdgeGroupController {
|
export class EditEdgeGroupController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor(EdgeGroupService, GroupService, TagService, Notifications, $state, $async, EndpointService) {
|
constructor(EdgeGroupService, GroupService, TagService, Notifications, $state, $async) {
|
||||||
this.EdgeGroupService = EdgeGroupService;
|
this.EdgeGroupService = EdgeGroupService;
|
||||||
this.GroupService = GroupService;
|
this.GroupService = GroupService;
|
||||||
this.TagService = TagService;
|
this.TagService = TagService;
|
||||||
this.Notifications = Notifications;
|
this.Notifications = Notifications;
|
||||||
this.$state = $state;
|
this.$state = $state;
|
||||||
this.$async = $async;
|
this.$async = $async;
|
||||||
this.EndpointService = EndpointService;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
actionInProgress: false,
|
|
||||||
loaded: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.updateGroup = this.updateGroup.bind(this);
|
this.updateGroup = this.updateGroup.bind(this);
|
||||||
this.updateGroupAsync = this.updateGroupAsync.bind(this);
|
this.updateGroupAsync = this.updateGroupAsync.bind(this);
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import _ from 'lodash-es';
|
export default class CreateEdgeStackViewController {
|
||||||
|
|
||||||
export class CreateEdgeStackViewController {
|
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor($state, $window, ModalService, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async) {
|
constructor($state, $window, ModalService, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async) {
|
||||||
Object.assign(this, { $state, $window, ModalService, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async });
|
Object.assign(this, { $state, $window, ModalService, EdgeStackService, EdgeGroupService, EdgeTemplateService, Notifications, FormHelper, $async });
|
||||||
|
@ -15,8 +13,9 @@ export class CreateEdgeStackViewController {
|
||||||
RepositoryUsername: '',
|
RepositoryUsername: '',
|
||||||
RepositoryPassword: '',
|
RepositoryPassword: '',
|
||||||
Env: [],
|
Env: [],
|
||||||
ComposeFilePathInRepository: 'docker-compose.yml',
|
ComposeFilePathInRepository: '',
|
||||||
Groups: [],
|
Groups: [],
|
||||||
|
DeploymentType: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
|
@ -25,22 +24,21 @@ export class CreateEdgeStackViewController {
|
||||||
actionInProgress: false,
|
actionInProgress: false,
|
||||||
StackType: null,
|
StackType: null,
|
||||||
isEditorDirty: false,
|
isEditorDirty: false,
|
||||||
|
hasKubeEndpoint: false,
|
||||||
|
endpointTypes: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
this.edgeGroups = null;
|
this.edgeGroups = null;
|
||||||
|
|
||||||
this.createStack = this.createStack.bind(this);
|
this.createStack = this.createStack.bind(this);
|
||||||
this.createStackAsync = this.createStackAsync.bind(this);
|
|
||||||
this.validateForm = this.validateForm.bind(this);
|
this.validateForm = this.validateForm.bind(this);
|
||||||
this.createStackByMethod = this.createStackByMethod.bind(this);
|
this.createStackByMethod = this.createStackByMethod.bind(this);
|
||||||
this.createStackFromFileContent = this.createStackFromFileContent.bind(this);
|
this.createStackFromFileContent = this.createStackFromFileContent.bind(this);
|
||||||
this.createStackFromFileUpload = this.createStackFromFileUpload.bind(this);
|
this.createStackFromFileUpload = this.createStackFromFileUpload.bind(this);
|
||||||
this.createStackFromGitRepository = this.createStackFromGitRepository.bind(this);
|
this.createStackFromGitRepository = this.createStackFromGitRepository.bind(this);
|
||||||
this.editorUpdate = this.editorUpdate.bind(this);
|
this.onChangeGroups = this.onChangeGroups.bind(this);
|
||||||
this.onChangeTemplate = this.onChangeTemplate.bind(this);
|
this.hasDockerEndpoint = this.hasDockerEndpoint.bind(this);
|
||||||
this.onChangeTemplateAsync = this.onChangeTemplateAsync.bind(this);
|
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
||||||
this.onChangeMethod = this.onChangeMethod.bind(this);
|
|
||||||
this.onChangeFormValues = this.onChangeFormValues.bind(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
buildAnalyticsProperties() {
|
buildAnalyticsProperties() {
|
||||||
|
@ -67,7 +65,7 @@ export class CreateEdgeStackViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async uiCanExit() {
|
uiCanExit() {
|
||||||
if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) {
|
if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) {
|
||||||
return this.ModalService.confirmWebEditorDiscard();
|
return this.ModalService.confirmWebEditorDiscard();
|
||||||
}
|
}
|
||||||
|
@ -81,13 +79,6 @@ export class CreateEdgeStackViewController {
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
|
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
const templates = await this.EdgeTemplateService.edgeTemplates();
|
|
||||||
this.templates = _.map(templates, (template) => ({ ...template, label: `${template.title} - ${template.description}` }));
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve Templates');
|
|
||||||
}
|
|
||||||
|
|
||||||
this.$window.onbeforeunload = () => {
|
this.$window.onbeforeunload = () => {
|
||||||
if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) {
|
if (this.state.Method === 'editor' && this.formValues.StackFileContent && this.state.isEditorDirty) {
|
||||||
return '';
|
return '';
|
||||||
|
@ -100,52 +91,54 @@ export class CreateEdgeStackViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
createStack() {
|
createStack() {
|
||||||
return this.$async(this.createStackAsync);
|
return this.$async(async () => {
|
||||||
|
const name = this.formValues.Name;
|
||||||
|
let method = this.state.Method;
|
||||||
|
|
||||||
|
if (method === 'template') {
|
||||||
|
method = 'editor';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.validateForm(method)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.actionInProgress = true;
|
||||||
|
try {
|
||||||
|
await this.createStackByMethod(name, method);
|
||||||
|
|
||||||
|
this.Notifications.success('Stack successfully deployed');
|
||||||
|
this.state.isEditorDirty = false;
|
||||||
|
this.$state.go('edge.stacks');
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Deployment error', err, 'Unable to deploy stack');
|
||||||
|
} finally {
|
||||||
|
this.state.actionInProgress = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeMethod() {
|
onChangeGroups(groups) {
|
||||||
this.formValues.StackFileContent = '';
|
this.formValues.Groups = groups;
|
||||||
this.selectedTemplate = null;
|
|
||||||
|
this.checkIfEndpointTypes(groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeTemplate(template) {
|
checkIfEndpointTypes(groups) {
|
||||||
return this.$async(this.onChangeTemplateAsync, template);
|
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
||||||
}
|
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
||||||
|
|
||||||
async onChangeTemplateAsync(template) {
|
if (this.hasDockerEndpoint() && this.formValues.DeploymentType == 1) {
|
||||||
this.formValues.StackFileContent = '';
|
this.onChangeDeploymentType(0);
|
||||||
try {
|
|
||||||
const fileContent = await this.EdgeTemplateService.edgeTemplate(template);
|
|
||||||
this.formValues.StackFileContent = fileContent;
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve Template');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async createStackAsync() {
|
hasKubeEndpoint() {
|
||||||
const name = this.formValues.Name;
|
return this.state.endpointTypes.includes(7);
|
||||||
let method = this.state.Method;
|
}
|
||||||
|
|
||||||
if (method === 'template') {
|
hasDockerEndpoint() {
|
||||||
method = 'editor';
|
return this.state.endpointTypes.includes(4);
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.validateForm(method)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.actionInProgress = true;
|
|
||||||
try {
|
|
||||||
await this.createStackByMethod(name, method);
|
|
||||||
|
|
||||||
this.Notifications.success('Stack successfully deployed');
|
|
||||||
this.state.isEditorDirty = false;
|
|
||||||
this.$state.go('edge.stacks');
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Deployment error', err, 'Unable to deploy stack');
|
|
||||||
} finally {
|
|
||||||
this.state.actionInProgress = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
validateForm(method) {
|
validateForm(method) {
|
||||||
|
@ -171,31 +164,55 @@ export class CreateEdgeStackViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
createStackFromFileContent(name) {
|
createStackFromFileContent(name) {
|
||||||
return this.EdgeStackService.createStackFromFileContent(name, this.formValues.StackFileContent, this.formValues.Groups);
|
const { StackFileContent, Groups, DeploymentType } = this.formValues;
|
||||||
|
|
||||||
|
return this.EdgeStackService.createStackFromFileContent({
|
||||||
|
name,
|
||||||
|
StackFileContent,
|
||||||
|
EdgeGroups: Groups,
|
||||||
|
DeploymentType,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
createStackFromFileUpload(name) {
|
createStackFromFileUpload(name) {
|
||||||
return this.EdgeStackService.createStackFromFileUpload(name, this.formValues.StackFile, this.formValues.Groups);
|
const { StackFile, Groups, DeploymentType } = this.formValues;
|
||||||
|
return this.EdgeStackService.createStackFromFileUpload(
|
||||||
|
{
|
||||||
|
Name: name,
|
||||||
|
EdgeGroups: Groups,
|
||||||
|
DeploymentType,
|
||||||
|
},
|
||||||
|
StackFile
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
createStackFromGitRepository(name) {
|
createStackFromGitRepository(name) {
|
||||||
|
const { Groups, DeploymentType } = this.formValues;
|
||||||
const repositoryOptions = {
|
const repositoryOptions = {
|
||||||
RepositoryURL: this.formValues.RepositoryURL,
|
RepositoryURL: this.formValues.RepositoryURL,
|
||||||
RepositoryReferenceName: this.formValues.RepositoryReferenceName,
|
RepositoryReferenceName: this.formValues.RepositoryReferenceName,
|
||||||
ComposeFilePathInRepository: this.formValues.ComposeFilePathInRepository,
|
FilePathInRepository: this.formValues.ComposeFilePathInRepository,
|
||||||
RepositoryAuthentication: this.formValues.RepositoryAuthentication,
|
RepositoryAuthentication: this.formValues.RepositoryAuthentication,
|
||||||
RepositoryUsername: this.formValues.RepositoryUsername,
|
RepositoryUsername: this.formValues.RepositoryUsername,
|
||||||
RepositoryPassword: this.formValues.RepositoryPassword,
|
RepositoryPassword: this.formValues.RepositoryPassword,
|
||||||
};
|
};
|
||||||
return this.EdgeStackService.createStackFromGitRepository(name, repositoryOptions, this.formValues.Groups);
|
return this.EdgeStackService.createStackFromGitRepository(
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
EdgeGroups: Groups,
|
||||||
|
DeploymentType,
|
||||||
|
},
|
||||||
|
repositoryOptions
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onChangeFormValues(values) {
|
onChangeDeploymentType(deploymentType) {
|
||||||
this.formValues = values;
|
this.formValues.DeploymentType = deploymentType;
|
||||||
|
this.state.Method = 'editor';
|
||||||
|
this.formValues.StackFileContent = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
editorUpdate(cm) {
|
formIsInvalid() {
|
||||||
this.formValues.StackFileContent = cm.getValue();
|
return this.form.$invalid || !this.formValues.Groups.length || (['template', 'editor'].includes(this.state.Method) && !this.formValues.StackFileContent);
|
||||||
this.state.isEditorDirty = true;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,94 @@
|
||||||
|
<rd-header>
|
||||||
|
<rd-header-title title-text="Create Edge stack"></rd-header-title>
|
||||||
|
<rd-header-content> <a ui-sref="edge.stacks">Edge Stacks</a> > Create Edge stack </rd-header-content>
|
||||||
|
</rd-header>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<rd-widget>
|
||||||
|
<rd-widget-body>
|
||||||
|
<form class="form-horizontal" name="$ctrl.form">
|
||||||
|
<!-- name-input -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="stack_name" class="col-sm-1 control-label text-left">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-11">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
ng-model="$ctrl.formValues.Name"
|
||||||
|
id="stack_name"
|
||||||
|
name="nameField"
|
||||||
|
placeholder="e.g. mystack"
|
||||||
|
auto-focus
|
||||||
|
required
|
||||||
|
data-cy="edgeStackCreate-nameInput"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !name-input -->
|
||||||
|
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Edge Groups
|
||||||
|
</div>
|
||||||
|
<div class="form-group" ng-if="$ctrl.edgeGroups">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<edge-groups-selector ng-if="!$ctrl.noGroups" model="$ctrl.formValues.Groups" on-change="($ctrl.onChangeGroups)" items="$ctrl.edgeGroups"></edge-groups-selector>
|
||||||
|
</div>
|
||||||
|
<div ng-if="$ctrl.noGroups" class="col-sm-12 small text-muted">
|
||||||
|
No Edge groups are available. Head over to the <a ui-sref="edge.groups">Edge groups view</a> to create one.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<edge-stack-deployment-type-selector
|
||||||
|
value="$ctrl.formValues.DeploymentType"
|
||||||
|
has-docker-endpoint="$ctrl.hasDockerEndpoint"
|
||||||
|
on-change="($ctrl.onChangeDeploymentType)"
|
||||||
|
></edge-stack-deployment-type-selector>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<div class="small text-muted space-right" ng-if="$ctrl.formValues.DeploymentType === 0 && $ctrl.hasKubeEndpoint()">
|
||||||
|
<i class="fa fa-exclamation-triangle orange-icon" aria-hidden="true"></i>
|
||||||
|
Portainer uses <a href="https://kompose.io/" target="_blank">Kompose</a> to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that not all
|
||||||
|
the Compose format options are supported by Kompose at the moment.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<edge-stacks-docker-compose-form ng-if="$ctrl.formValues.DeploymentType == 0" form-values="$ctrl.formValues" state="$ctrl.state"></edge-stacks-docker-compose-form>
|
||||||
|
|
||||||
|
<edge-stacks-kube-manifest-form ng-if="$ctrl.formValues.DeploymentType == 1" form-values="$ctrl.formValues" state="$ctrl.state"></edge-stacks-kube-manifest-form>
|
||||||
|
|
||||||
|
<!-- actions -->
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Actions
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm"
|
||||||
|
ng-disabled="$ctrl.state.actionInProgress || $ctrl.formIsInvalid()"
|
||||||
|
ng-click="$ctrl.createStack()"
|
||||||
|
button-spinner="$ctrl.state.actionInProgress"
|
||||||
|
analytics-on
|
||||||
|
analytics-event="edge-stack-creation"
|
||||||
|
analytics-category="edge"
|
||||||
|
analytics-properties="$ctrl.buildAnalyticsProperties()"
|
||||||
|
>
|
||||||
|
<span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span>
|
||||||
|
<span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span>
|
||||||
|
</button>
|
||||||
|
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
|
||||||
|
{{ $ctrl.state.formValidationError }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !actions -->
|
||||||
|
</form>
|
||||||
|
</rd-widget-body>
|
||||||
|
</rd-widget>
|
||||||
|
</div>
|
||||||
|
</div>
|
|
@ -0,0 +1,6 @@
|
||||||
|
import controller from './create-edge-stack-view.controller';
|
||||||
|
|
||||||
|
export const createEdgeStackView = {
|
||||||
|
templateUrl: './create-edge-stack-view.html',
|
||||||
|
controller,
|
||||||
|
};
|
|
@ -1,220 +0,0 @@
|
||||||
<rd-header>
|
|
||||||
<rd-header-title title-text="Create Edge stack"></rd-header-title>
|
|
||||||
<rd-header-content> <a ui-sref="edge.stacks">Edge Stacks</a> > Create Edge stack </rd-header-content>
|
|
||||||
</rd-header>
|
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<rd-widget>
|
|
||||||
<rd-widget-body>
|
|
||||||
<form class="form-horizontal">
|
|
||||||
<!-- name-input -->
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="stack_name" class="col-sm-1 control-label text-left">
|
|
||||||
Name
|
|
||||||
</label>
|
|
||||||
<div class="col-sm-11">
|
|
||||||
<input type="text" class="form-control" ng-model="$ctrl.formValues.Name" id="stack_name" placeholder="e.g. mystack" auto-focus data-cy="edgeStackCreate-nameInput" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !name-input -->
|
|
||||||
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Edge Groups
|
|
||||||
</div>
|
|
||||||
<div class="form-group" ng-if="$ctrl.edgeGroups">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<edge-groups-selector ng-if="!$ctrl.noGroups" model="$ctrl.formValues.Groups" on-change="(onChangeGroups)" items="$ctrl.edgeGroups"></edge-groups-selector>
|
|
||||||
</div>
|
|
||||||
<div ng-if="$ctrl.noGroups" class="col-sm-12 small text-muted">
|
|
||||||
No Edge groups are available. Head over to the <a ui-sref="edge.groups">Edge groups view</a> to create one.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- build-method -->
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Build method
|
|
||||||
</div>
|
|
||||||
<div class="form-group"></div>
|
|
||||||
<div class="form-group" style="margin-bottom: 0;">
|
|
||||||
<div class="boxselector_wrapper">
|
|
||||||
<div>
|
|
||||||
<input type="radio" id="method_editor" ng-model="$ctrl.state.Method" value="editor" ng-change="$ctrl.onChangeMethod()" />
|
|
||||||
<label for="method_editor" data-cy="edgeStackCreate-webEditorButton">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i class="fa fa-edit" aria-hidden="true" style="margin-right: 2px;"></i>
|
|
||||||
Web editor
|
|
||||||
</div>
|
|
||||||
<p>Use our Web editor</p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input type="radio" id="method_upload" ng-model="$ctrl.state.Method" value="upload" ng-change="$ctrl.onChangeMethod()" />
|
|
||||||
<label for="method_upload" data-cy="edgeStackCreate-uploadButton">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i class="fa fa-upload" aria-hidden="true" style="margin-right: 2px;"></i>
|
|
||||||
Upload
|
|
||||||
</div>
|
|
||||||
<p>Upload from your computer</p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input type="radio" id="method_repository" ng-model="$ctrl.state.Method" value="repository" ng-change="$ctrl.onChangeMethod()" />
|
|
||||||
<label for="method_repository" data-cy="edgeStackCreate-repoButton">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i class="fab fa-git" aria-hidden="true" style="margin-right: 2px;"></i>
|
|
||||||
Repository
|
|
||||||
</div>
|
|
||||||
<p>Use a git repository</p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input type="radio" id="method_template" ng-model="$ctrl.state.Method" value="template" ng-change="$ctrl.onChangeMethod()" />
|
|
||||||
<label for="method_template" data-cy="edgeStackCreate-templateButton">
|
|
||||||
<div class="boxselector_header">
|
|
||||||
<i class="fas fa-rocket" aria-hidden="true" style="margin-right: 2px;"></i>
|
|
||||||
Template
|
|
||||||
</div>
|
|
||||||
<p>Use an Edge stack template</p>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !build-method -->
|
|
||||||
<!-- web-editor -->
|
|
||||||
<div ng-show="$ctrl.state.Method === 'editor'">
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Web editor
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<span class="col-sm-12 text-muted small">
|
|
||||||
You can get more information about Compose file format in the
|
|
||||||
<a href="https://docs.docker.com/compose/compose-file/" target="_blank">
|
|
||||||
official documentation
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<code-editor
|
|
||||||
identifier="stack-creation-editor"
|
|
||||||
placeholder="# Define or paste the content of your docker-compose file here"
|
|
||||||
yml="true"
|
|
||||||
value="$ctrl.formValues.StackFileContent"
|
|
||||||
on-change="($ctrl.editorUpdate)"
|
|
||||||
></code-editor>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !web-editor -->
|
|
||||||
<!-- upload -->
|
|
||||||
<div ng-show="$ctrl.state.Method === 'upload'">
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Upload
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<span class="col-sm-12 text-muted small">
|
|
||||||
You can upload a Compose file from your computer.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button type="button" class="btn btn-sm btn-primary" ngf-select ng-model="$ctrl.formValues.StackFile">
|
|
||||||
Select file
|
|
||||||
</button>
|
|
||||||
<span style="margin-left: 5px;">
|
|
||||||
{{ $ctrl.formValues.StackFile.name }}
|
|
||||||
<i class="fa fa-times red-icon" ng-if="!$ctrl.formValues.StackFile" aria-hidden="true"></i>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !upload -->
|
|
||||||
<!-- repository -->
|
|
||||||
<git-form ng-show="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
|
|
||||||
<!-- !repository -->
|
|
||||||
<!-- template -->
|
|
||||||
<div ng-show="$ctrl.state.Method === 'template'">
|
|
||||||
<div class="form-group">
|
|
||||||
<label for="stack_template" class="col-sm-1 control-label text-left">
|
|
||||||
Template
|
|
||||||
</label>
|
|
||||||
<div class="col-sm-11">
|
|
||||||
<select
|
|
||||||
class="form-control"
|
|
||||||
ng-model="$ctrl.selectedTemplate"
|
|
||||||
ng-options="template as template.label for template in $ctrl.templates"
|
|
||||||
ng-change="$ctrl.onChangeTemplate($ctrl.selectedTemplate)"
|
|
||||||
>
|
|
||||||
<option value="" label="Select an Edge stack template" disabled selected="selected"> </option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- description -->
|
|
||||||
<div ng-if="$ctrl.selectedTemplate.note">
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Information
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<div class="template-note" ng-bind-html="$ctrl.selectedTemplate.note"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !description -->
|
|
||||||
<!-- editor -->
|
|
||||||
<div ng-if="$ctrl.selectedTemplate && $ctrl.formValues.StackFileContent">
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Web editor
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<code-editor
|
|
||||||
identifier="template-content-editor"
|
|
||||||
placeholder="# Define or paste the content of your docker-compose file here"
|
|
||||||
yml="true"
|
|
||||||
value="$ctrl.formValues.StackFileContent"
|
|
||||||
on-change="($ctrl.editorUpdate)"
|
|
||||||
></code-editor>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !editor -->
|
|
||||||
<!-- !template -->
|
|
||||||
<!-- actions -->
|
|
||||||
<div class="col-sm-12 form-section-title">
|
|
||||||
Actions
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-primary btn-sm"
|
|
||||||
ng-disabled="$ctrl.state.actionInProgress || !$ctrl.formValues.Groups.length
|
|
||||||
|| ($ctrl.state.Method === 'editor' && !$ctrl.formValues.StackFileContent)
|
|
||||||
|| ($ctrl.state.Method === 'upload' && !$ctrl.formValues.StackFile)
|
|
||||||
|| ($ctrl.state.Method === 'repository' && ((!$ctrl.formValues.RepositoryURL || !$ctrl.formValues.ComposeFilePathInRepository) || ($ctrl.formValues.RepositoryAuthentication && (!$ctrl.formValues.RepositoryUsername || !$ctrl.formValues.RepositoryPassword))))
|
|
||||||
|| !$ctrl.formValues.Name"
|
|
||||||
ng-click="$ctrl.createStack()"
|
|
||||||
button-spinner="$ctrl.state.actionInProgress"
|
|
||||||
data-cy="edgeStackCreate-createStackButton"
|
|
||||||
analytics-on
|
|
||||||
analytics-event="edge-stack-creation"
|
|
||||||
analytics-category="edge"
|
|
||||||
analytics-properties="$ctrl.buildAnalyticsProperties()"
|
|
||||||
>
|
|
||||||
<span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span>
|
|
||||||
<span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span>
|
|
||||||
</button>
|
|
||||||
<span class="text-danger" ng-if="$ctrl.state.formValidationError" style="margin-left: 5px;">
|
|
||||||
{{ $ctrl.state.formValidationError }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !actions -->
|
|
||||||
</form>
|
|
||||||
</rd-widget-body>
|
|
||||||
</rd-widget>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
class DockerComposeFormController {
|
||||||
|
/* @ngInject */
|
||||||
|
constructor($async, EdgeTemplateService, Notifications) {
|
||||||
|
Object.assign(this, { $async, EdgeTemplateService, Notifications });
|
||||||
|
|
||||||
|
this.methodOptions = [
|
||||||
|
{ id: 'method_editor', icon: 'fa fa-edit', label: 'Web editor', description: 'Use our Web editor', value: 'editor' },
|
||||||
|
{ id: 'method_upload', icon: 'fa fa-upload', label: 'Upload', description: 'Upload from your computer', value: 'upload' },
|
||||||
|
{ id: 'method_repository', icon: 'fab fa-github', label: 'Repository', description: 'Use a git repository', value: 'repository' },
|
||||||
|
{ id: 'method_template', icon: 'fa fa-rocket', label: 'Template', description: 'Use an Edge stack template', value: 'template' },
|
||||||
|
];
|
||||||
|
|
||||||
|
this.selectedTemplate = null;
|
||||||
|
|
||||||
|
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||||
|
this.onChangeFile = this.onChangeFile.bind(this);
|
||||||
|
this.onChangeTemplate = this.onChangeTemplate.bind(this);
|
||||||
|
this.onChangeMethod = this.onChangeMethod.bind(this);
|
||||||
|
this.onChangeFormValues = this.onChangeFormValues.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeFormValues(values) {
|
||||||
|
this.formValues = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeMethod() {
|
||||||
|
this.formValues.StackFileContent = '';
|
||||||
|
this.selectedTemplate = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeTemplate(template) {
|
||||||
|
return this.$async(async () => {
|
||||||
|
this.formValues.StackFileContent = '';
|
||||||
|
try {
|
||||||
|
const fileContent = await this.EdgeTemplateService.edgeTemplate(template);
|
||||||
|
this.formValues.StackFileContent = fileContent;
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to retrieve Template');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeFileContent(value) {
|
||||||
|
this.formValues.StackFileContent = value;
|
||||||
|
this.state.isEditorDirty = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeFile(value) {
|
||||||
|
this.formValues.StackFile = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async $onInit() {
|
||||||
|
return this.$async(async () => {
|
||||||
|
try {
|
||||||
|
const templates = await this.EdgeTemplateService.edgeTemplates();
|
||||||
|
this.templates = templates.map((template) => ({ ...template, label: `${template.title} - ${template.description}` }));
|
||||||
|
} catch (err) {
|
||||||
|
this.Notifications.error('Failure', err, 'Unable to retrieve Templates');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DockerComposeFormController;
|
|
@ -0,0 +1,74 @@
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Build method
|
||||||
|
</div>
|
||||||
|
<box-selector radio-name="method" ng-model="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
||||||
|
|
||||||
|
<web-editor-form
|
||||||
|
ng-if="$ctrl.state.Method === 'editor'"
|
||||||
|
identifier="stack-creation-editor"
|
||||||
|
value="$ctrl.formValues.StackFileContent"
|
||||||
|
on-change="($ctrl.onChangeFileContent)"
|
||||||
|
ng-required="true"
|
||||||
|
yml="true"
|
||||||
|
placeholder="# Define or paste the content of your docker-compose file here"
|
||||||
|
>
|
||||||
|
<editor-description>
|
||||||
|
You can get more information about Compose file format in the
|
||||||
|
<a href="https://docs.docker.com/compose/compose-file/" target="_blank">
|
||||||
|
official documentation
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</editor-description>
|
||||||
|
</web-editor-form>
|
||||||
|
|
||||||
|
<file-upload-form ng-if="$ctrl.state.Method === 'upload'" file="$ctrl.formValues.StackFile" on-change="($ctrl.onChangeFile)" ng-required="true">
|
||||||
|
<file-upload-description>
|
||||||
|
You can upload a Compose file from your computer.
|
||||||
|
</file-upload-description>
|
||||||
|
</file-upload-form>
|
||||||
|
|
||||||
|
<git-form ng-if="$ctrl.state.Method === 'repository'" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
|
||||||
|
|
||||||
|
<!-- template -->
|
||||||
|
<div ng-if="$ctrl.state.Method === 'template'">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="stack_template" class="col-sm-1 control-label text-left">
|
||||||
|
Template
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-11">
|
||||||
|
<select
|
||||||
|
class="form-control"
|
||||||
|
ng-model="$ctrl.selectedTemplate"
|
||||||
|
ng-options="template as template.label for template in $ctrl.templates"
|
||||||
|
ng-change="$ctrl.onChangeTemplate($ctrl.selectedTemplate)"
|
||||||
|
>
|
||||||
|
<option value="" label="Select an Edge stack template" disabled selected="selected"> </option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- description -->
|
||||||
|
<div ng-if="$ctrl.selectedTemplate.note">
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Information
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<div class="col-sm-12">
|
||||||
|
<div class="template-note" ng-bind-html="$ctrl.selectedTemplate.note"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- !description -->
|
||||||
|
|
||||||
|
<web-editor-form
|
||||||
|
ng-if="$ctrl.selectedTemplate && $ctrl.formValues.StackFileContent"
|
||||||
|
identifier="template-content-editor"
|
||||||
|
value="$ctrl.formValues.StackFileContent"
|
||||||
|
on-change="($ctrl.onChangeFileContent)"
|
||||||
|
yml="true"
|
||||||
|
placeholder="# Define or paste the content of your docker-compose file here"
|
||||||
|
ng-required="true"
|
||||||
|
>
|
||||||
|
</web-editor-form>
|
||||||
|
|
||||||
|
<!-- !template -->
|
||||||
|
</div>
|
|
@ -0,0 +1,11 @@
|
||||||
|
import controller from './docker-compose-form.controller.js';
|
||||||
|
|
||||||
|
export const edgeStacksDockerComposeForm = {
|
||||||
|
templateUrl: './docker-compose-form.html',
|
||||||
|
controller,
|
||||||
|
|
||||||
|
bindings: {
|
||||||
|
formValues: '=',
|
||||||
|
state: '=',
|
||||||
|
},
|
||||||
|
};
|
|
@ -1,8 +1,13 @@
|
||||||
import angular from 'angular';
|
import angular from 'angular';
|
||||||
|
|
||||||
import { CreateEdgeStackViewController } from './createEdgeStackViewController';
|
import { createEdgeStackView } from './create-edge-stack-view';
|
||||||
|
import { edgeStacksDockerComposeForm } from './docker-compose-form';
|
||||||
|
import { kubeManifestForm } from './kube-manifest-form';
|
||||||
|
import { kubeDeployDescription } from './kube-deploy-description';
|
||||||
|
|
||||||
angular.module('portainer.edge').component('createEdgeStackView', {
|
export default angular
|
||||||
templateUrl: './createEdgeStackView.html',
|
.module('portainer.edge.stacks.create', [])
|
||||||
controller: CreateEdgeStackViewController,
|
.component('createEdgeStackView', createEdgeStackView)
|
||||||
});
|
.component('edgeStacksDockerComposeForm', edgeStacksDockerComposeForm)
|
||||||
|
.component('edgeStacksKubeManifestForm', kubeManifestForm)
|
||||||
|
.component('kubeDeployDescription', kubeDeployDescription).name;
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const kubeDeployDescription = {
|
||||||
|
templateUrl: './kube-deploy-description.html',
|
||||||
|
};
|
|
@ -0,0 +1,8 @@
|
||||||
|
<p>
|
||||||
|
<i class="fa fa-info-circle blue-icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
|
This feature allows you to deploy any kind of Kubernetes resource in this environment (Deployment, Secret, ConfigMap...).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You can get more information about Kubernetes file format in the
|
||||||
|
<a href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/" target="_blank">official documentation</a>.
|
||||||
|
</p>
|
|
@ -0,0 +1,11 @@
|
||||||
|
import controller from './kube-manifest-form.controller.js';
|
||||||
|
|
||||||
|
export const kubeManifestForm = {
|
||||||
|
templateUrl: './kube-manifest-form.html',
|
||||||
|
controller,
|
||||||
|
|
||||||
|
bindings: {
|
||||||
|
formValues: '=',
|
||||||
|
state: '=',
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,29 @@
|
||||||
|
class KubeManifestFormController {
|
||||||
|
/* @ngInject */
|
||||||
|
constructor() {
|
||||||
|
this.methodOptions = [
|
||||||
|
{ id: 'method_editor', icon: 'fa fa-edit', label: 'Web editor', description: 'Use our Web editor', value: 'editor' },
|
||||||
|
{ id: 'method_upload', icon: 'fa fa-upload', label: 'Upload', description: 'Upload from your computer', value: 'upload' },
|
||||||
|
{ id: 'method_repository', icon: 'fab fa-github', label: 'Repository', description: 'Use a git repository', value: 'repository' },
|
||||||
|
];
|
||||||
|
|
||||||
|
this.onChangeFileContent = this.onChangeFileContent.bind(this);
|
||||||
|
this.onChangeFormValues = this.onChangeFormValues.bind(this);
|
||||||
|
this.onChangeFile = this.onChangeFile.bind(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeFormValues(values) {
|
||||||
|
this.formValues = values;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeFileContent(value) {
|
||||||
|
this.state.isEditorDirty = true;
|
||||||
|
this.formValues.StackFileContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
onChangeFile(value) {
|
||||||
|
this.formValues.StackFile = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default KubeManifestFormController;
|
|
@ -0,0 +1,26 @@
|
||||||
|
<div class="col-sm-12 form-section-title">
|
||||||
|
Build method
|
||||||
|
</div>
|
||||||
|
<box-selector radio-name="method" ng-model="$ctrl.state.Method" options="$ctrl.methodOptions" on-change="($ctrl.onChangeMethod)"></box-selector>
|
||||||
|
|
||||||
|
<web-editor-form
|
||||||
|
ng-if="$ctrl.state.Method === 'editor'"
|
||||||
|
identifier="stack-creation-editor"
|
||||||
|
value="$ctrl.formValues.StackFileContent"
|
||||||
|
on-change="($ctrl.onChangeFileContent)"
|
||||||
|
yml="true"
|
||||||
|
placeholder="# Define or paste the content of your manifest here"
|
||||||
|
ng-required="true"
|
||||||
|
>
|
||||||
|
<editor-description>
|
||||||
|
<kube-deploy-description></kube-deploy-description>
|
||||||
|
</editor-description>
|
||||||
|
</web-editor-form>
|
||||||
|
|
||||||
|
<file-upload-form ng-if="$ctrl.state.Method === 'upload'" file="$ctrl.formValues.StackFile" on-change="($ctrl.onChangeFile)" ng-required="true">
|
||||||
|
<file-upload-description>
|
||||||
|
<kube-deploy-description></kube-deploy-description>
|
||||||
|
</file-upload-description>
|
||||||
|
</file-upload-form>
|
||||||
|
|
||||||
|
<git-form ng-if="$ctrl.state.Method === 'repository'" deploy-method="kubernetes" model="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
|
|
@ -39,7 +39,7 @@ export class EditEdgeStackViewController {
|
||||||
this.formValues = {
|
this.formValues = {
|
||||||
StackFileContent: file,
|
StackFileContent: file,
|
||||||
EdgeGroups: this.stack.EdgeGroups,
|
EdgeGroups: this.stack.EdgeGroups,
|
||||||
Prune: this.stack.Prune,
|
DeploymentType: this.stack.DeploymentType,
|
||||||
};
|
};
|
||||||
this.oldFileContent = this.formValues.StackFileContent;
|
this.oldFileContent = this.formValues.StackFileContent;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -58,7 +58,7 @@ export class EditEdgeStackViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
async uiCanExit() {
|
async uiCanExit() {
|
||||||
if (this.formValues.StackFileContent !== this.oldFileContent && this.state.isEditorDirty) {
|
if (this.formValues.StackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== this.oldFileContent.replace(/(\r\n|\n|\r)/gm, '') && this.state.isEditorDirty) {
|
||||||
return this.ModalService.confirmWebEditorDiscard();
|
return this.ModalService.confirmWebEditorDiscard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -99,7 +99,7 @@ export class EditEdgeStackViewController {
|
||||||
|
|
||||||
async getPaginatedEndpointsAsync(lastId, limit, search) {
|
async getPaginatedEndpointsAsync(lastId, limit, search) {
|
||||||
try {
|
try {
|
||||||
const query = { search, types: [4], endpointIds: this.stackEndpointIds };
|
const query = { search, types: [4, 7], endpointIds: this.stackEndpointIds };
|
||||||
const { value, totalCount } = await this.EndpointService.endpoints(lastId, limit, query);
|
const { value, totalCount } = await this.EndpointService.endpoints(lastId, limit, query);
|
||||||
const endpoints = _.map(value, (endpoint) => {
|
const endpoints = _.map(value, (endpoint) => {
|
||||||
const status = this.stack.Status[endpoint.Id];
|
const status = this.stack.Status[endpoint.Id];
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
import angular from 'angular';
|
||||||
|
|
||||||
|
import createModule from './createEdgeStackView';
|
||||||
|
|
||||||
|
export default angular.module('portainer.edge.stacks', [createModule]).name;
|
|
@ -56,7 +56,7 @@ class AssoicatedEndpointsSelectorController {
|
||||||
|
|
||||||
async getEndpointsAsync() {
|
async getEndpointsAsync() {
|
||||||
const { start, search, limit } = this.getPaginationData('available');
|
const { start, search, limit } = this.getPaginationData('available');
|
||||||
const query = { search, types: [4] };
|
const query = { search, types: [4, 7] };
|
||||||
|
|
||||||
const response = await this.EndpointService.endpoints(start, limit, query);
|
const response = await this.EndpointService.endpoints(start, limit, query);
|
||||||
|
|
||||||
|
@ -73,7 +73,7 @@ class AssoicatedEndpointsSelectorController {
|
||||||
let response = { value: [], totalCount: 0 };
|
let response = { value: [], totalCount: 0 };
|
||||||
if (this.endpointIds.length > 0) {
|
if (this.endpointIds.length > 0) {
|
||||||
const { start, search, limit } = this.getPaginationData('associated');
|
const { start, search, limit } = this.getPaginationData('associated');
|
||||||
const query = { search, types: [4], endpointIds: this.endpointIds };
|
const query = { search, types: [4, 7], endpointIds: this.endpointIds };
|
||||||
|
|
||||||
response = await this.EndpointService.endpoints(start, limit, query);
|
response = await this.EndpointService.endpoints(start, limit, query);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,20 @@
|
||||||
<div class="box-selector-item">
|
<div
|
||||||
<input type="radio" name="{{ $ctrl.radioName }}" id="{{ $ctrl.option.id }}" ng-checked="$ctrl.isChecked($ctrl.option.value)" ng-value="$ctrl.option.value" />
|
class="box-selector-item"
|
||||||
<label for="{{ $ctrl.option.id }}" ng-click="$ctrl.onChange($ctrl.option.value)">
|
tooltip-append-to-body="true"
|
||||||
|
tooltip-placement="bottom"
|
||||||
|
tooltip-class="portainer-tooltip"
|
||||||
|
tooltip-enable="$ctrl.tooltip"
|
||||||
|
uib-tooltip="{{ $ctrl.tooltip }}"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="{{ $ctrl.radioName }}"
|
||||||
|
id="{{ $ctrl.option.id }}"
|
||||||
|
ng-checked="$ctrl.isChecked($ctrl.option.value)"
|
||||||
|
ng-value="$ctrl.option.value"
|
||||||
|
ng-disabled="$ctrl.disabled"
|
||||||
|
/>
|
||||||
|
<label for="{{ $ctrl.option.id }}" ng-click="$ctrl.onChange($ctrl.option.value)" t>
|
||||||
<div class="boxselector_header">
|
<div class="boxselector_header">
|
||||||
<i ng-class="$ctrl.option.icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
<i ng-class="$ctrl.option.icon" aria-hidden="true" style="margin-right: 2px;"></i>
|
||||||
{{ $ctrl.option.label }}
|
{{ $ctrl.option.label }}
|
||||||
|
|
|
@ -7,5 +7,7 @@ angular.module('portainer.app').component('boxSelectorItem', {
|
||||||
isChecked: '<',
|
isChecked: '<',
|
||||||
option: '<',
|
option: '<',
|
||||||
onChange: '<',
|
onChange: '<',
|
||||||
|
disabled: '<',
|
||||||
|
tooltip: '<',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
@ -14,6 +14,7 @@
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 5px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boxselector_header .fa,
|
.boxselector_header .fa,
|
||||||
|
@ -46,11 +47,14 @@
|
||||||
box-shadow: var(--shadow-boxselector-color);
|
box-shadow: var(--shadow-boxselector-color);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.box-selector-item input:disabled + label,
|
||||||
.boxselector_wrapper label.boxselector_disabled {
|
.boxselector_wrapper label.boxselector_disabled {
|
||||||
background: var(--bg-boxselector-disabled-color) !important;
|
background: var(--bg-boxselector-disabled-color) !important;
|
||||||
border-color: #787878;
|
border-color: #787878;
|
||||||
color: #787878;
|
color: #787878;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boxselector_wrapper input[type='radio']:checked + label {
|
.boxselector_wrapper input[type='radio']:checked + label {
|
||||||
|
|
|
@ -8,6 +8,8 @@
|
||||||
option="option"
|
option="option"
|
||||||
on-change="($ctrl.change)"
|
on-change="($ctrl.change)"
|
||||||
is-checked="$ctrl.isChecked"
|
is-checked="$ctrl.isChecked"
|
||||||
|
disabled="option.disabled()"
|
||||||
|
tooltip="option.tooltip()"
|
||||||
></box-selector-item>
|
></box-selector-item>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,12 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="col-sm-12">
|
<div class="col-sm-12">
|
||||||
<por-switch-field ng-model="$ctrl.model.RepositoryAuthentication" label="Authentication" on-change="($ctrl.onChangeAuth)" data-cy="component-gitAuthToggle"></por-switch-field>
|
<por-switch-field
|
||||||
|
ng-model="$ctrl.model.RepositoryAuthentication"
|
||||||
|
label="Authentication"
|
||||||
|
name="authSwitch"
|
||||||
|
on-change="($ctrl.onChangeAuth)"
|
||||||
|
data-cy="component-gitAuthToggle"
|
||||||
|
></por-switch-field>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="small text-warning" style="margin: 5px 0 15px 0;" ng-if="$ctrl.model.RepositoryAuthentication && $ctrl.showAuthExplanation">
|
<div class="small text-warning" style="margin: 5px 0 15px 0;" ng-if="$ctrl.model.RepositoryAuthentication && $ctrl.showAuthExplanation">
|
||||||
|
@ -19,6 +25,7 @@
|
||||||
placeholder="git username"
|
placeholder="git username"
|
||||||
ng-change="$ctrl.onChangeUsername($ctrl.model.RepositoryUsername)"
|
ng-change="$ctrl.onChangeUsername($ctrl.model.RepositoryUsername)"
|
||||||
data-cy="component-gitUsernameInput"
|
data-cy="component-gitUsernameInput"
|
||||||
|
ng-required="$ctrl.model.RepositoryAuthentication"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<span class="col-sm-12 text-muted small">
|
<span class="col-sm-12 text-muted small"> Indicate the path to the {{ $ctrl.deployMethod == 'compose' ? 'Compose' : 'Manifest' }} file from the root of your repository. </span>
|
||||||
Indicate the path to the Compose file from the root of your repository.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stack_repository_path" class="col-sm-2 control-label text-left">Compose path</label>
|
<label for="stack_repository_path" class="col-sm-2 control-label text-left">{{ $ctrl.deployMethod == 'compose' ? 'Compose' : 'Manifest' }} path</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<input type="text" class="form-control" ng-model="$ctrl.value" ng-change="$ctrl.onChange($ctrl.value)" id="stack_repository_path" placeholder="docker-compose.yml" />
|
<input
|
||||||
|
type="text"
|
||||||
|
class="form-control"
|
||||||
|
name="repoPathField"
|
||||||
|
ng-model="$ctrl.value"
|
||||||
|
ng-change="$ctrl.onChange($ctrl.value)"
|
||||||
|
id="stack_repository_path"
|
||||||
|
placeholder="{{ $ctrl.deployMethod == 'compose' ? 'docker-compose.yml' : 'manifest.yml' }}"
|
||||||
|
required
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
export const gitFormComposePathField = {
|
export const gitFormComposePathField = {
|
||||||
templateUrl: './git-form-compose-path-field.html',
|
templateUrl: './git-form-compose-path-field.html',
|
||||||
bindings: {
|
bindings: {
|
||||||
|
deployMethod: '@',
|
||||||
|
|
||||||
value: '<',
|
value: '<',
|
||||||
onChange: '<',
|
onChange: '<',
|
||||||
},
|
},
|
||||||
|
|
|
@ -5,8 +5,8 @@
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="stack_repository_reference_name" class="col-sm-1 control-label text-left">Repository reference</label>
|
<label for="stack_repository_reference_name" class="col-sm-2 control-label text-left">Repository reference</label>
|
||||||
<div class="col-sm-11">
|
<div class="col-sm-10">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
|
|
|
@ -8,12 +8,14 @@
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
name="repoUrlField"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
ng-model="$ctrl.value"
|
ng-model="$ctrl.value"
|
||||||
ng-change="$ctrl.onChange($ctrl.value)"
|
ng-change="$ctrl.onChange($ctrl.value)"
|
||||||
id="stack_repository_url"
|
id="stack_repository_url"
|
||||||
placeholder="https://github.com/portainer/portainer-compose"
|
placeholder="https://github.com/portainer/portainer-compose"
|
||||||
data-cy="component-gitUrlInput"
|
data-cy="component-gitUrlInput"
|
||||||
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -15,4 +15,8 @@ export default class GitFormController {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$onInit() {
|
||||||
|
this.deployMethod = this.deployMethod || 'compose';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,18 @@
|
||||||
<div>
|
<ng-form name="$ctrl.gitForm">
|
||||||
<div class="col-sm-12 form-section-title">
|
<div class="col-sm-12 form-section-title">
|
||||||
Git repository
|
Git repository
|
||||||
</div>
|
</div>
|
||||||
<git-form-url-field value="$ctrl.model.RepositoryURL" on-change="($ctrl.onChangeURL)"></git-form-url-field>
|
<git-form-url-field value="$ctrl.model.RepositoryURL" on-change="($ctrl.onChangeURL)"></git-form-url-field>
|
||||||
<git-form-ref-field value="$ctrl.model.RepositoryReferenceName" on-change="($ctrl.onChangeRefName)"></git-form-ref-field>
|
<git-form-ref-field value="$ctrl.model.RepositoryReferenceName" on-change="($ctrl.onChangeRefName)"></git-form-ref-field>
|
||||||
<git-form-compose-path-field value="$ctrl.model.ComposeFilePathInRepository" on-change="($ctrl.onChangeComposePath)"></git-form-compose-path-field>
|
<git-form-compose-path-field
|
||||||
|
value="$ctrl.model.ComposeFilePathInRepository"
|
||||||
|
on-change="($ctrl.onChangeComposePath)"
|
||||||
|
deploy-method="{{ $ctrl.deployMethod }}"
|
||||||
|
></git-form-compose-path-field>
|
||||||
|
|
||||||
<git-form-additional-files-panel ng-if="$ctrl.additionalFile" model="$ctrl.model" on-change="($ctrl.onChange)"></git-form-additional-files-panel>
|
<git-form-additional-files-panel ng-if="$ctrl.additionalFile" model="$ctrl.model" on-change="($ctrl.onChange)"></git-form-additional-files-panel>
|
||||||
<git-form-auth-fieldset model="$ctrl.model" on-change="($ctrl.onChange)" show-auth-explanation="$ctrl.showAuthExplanation"></git-form-auth-fieldset>
|
|
||||||
|
<git-form-auth-fieldset model="$ctrl.model" on-change="($ctrl.onChange)"></git-form-auth-fieldset>
|
||||||
|
|
||||||
<git-form-auto-update-fieldset ng-if="$ctrl.autoUpdate" model="$ctrl.model" on-change="($ctrl.onChange)"></git-form-auto-update-fieldset>
|
<git-form-auto-update-fieldset ng-if="$ctrl.autoUpdate" model="$ctrl.model" on-change="($ctrl.onChange)"></git-form-auto-update-fieldset>
|
||||||
</div>
|
</ng-form>
|
||||||
|
|
|
@ -4,6 +4,7 @@ export const gitForm = {
|
||||||
templateUrl: './git-form.html',
|
templateUrl: './git-form.html',
|
||||||
controller,
|
controller,
|
||||||
bindings: {
|
bindings: {
|
||||||
|
deployMethod: '@',
|
||||||
model: '<',
|
model: '<',
|
||||||
onChange: '<',
|
onChange: '<',
|
||||||
additionalFile: '<',
|
additionalFile: '<',
|
||||||
|
|
|
@ -96,13 +96,13 @@ angular.module('portainer.app').factory('FileUploadService', [
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
service.createEdgeStack = function createEdgeStack(stackName, file, edgeGroups) {
|
service.createEdgeStack = function createEdgeStack({ EdgeGroups, ...payload }, file) {
|
||||||
return Upload.upload({
|
return Upload.upload({
|
||||||
url: 'api/edge_stacks?method=file',
|
url: 'api/edge_stacks?method=file',
|
||||||
data: {
|
data: {
|
||||||
file: file,
|
file,
|
||||||
Name: stackName,
|
EdgeGroups: Upload.json(EdgeGroups),
|
||||||
EdgeGroups: Upload.json(edgeGroups),
|
...payload,
|
||||||
},
|
},
|
||||||
ignoreLoadingBar: true,
|
ignoreLoadingBar: true,
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,10 +16,10 @@ angular.module('portainer.app').factory('Notifications', [
|
||||||
|
|
||||||
service.error = function (title, e, fallbackText) {
|
service.error = function (title, e, fallbackText) {
|
||||||
var msg = fallbackText;
|
var msg = fallbackText;
|
||||||
if (e.err && e.err.data && e.err.data.message) {
|
if (e.err && e.err.data && e.err.data.details) {
|
||||||
msg = e.err.data.message;
|
|
||||||
} else if (e.err && e.err.data && e.err.data.details) {
|
|
||||||
msg = e.err.data.details;
|
msg = e.err.data.details;
|
||||||
|
} else if (e.err && e.err.data && e.err.data.message) {
|
||||||
|
msg = e.err.data.message;
|
||||||
} else if (e.data && e.data.details) {
|
} else if (e.data && e.data.details) {
|
||||||
msg = e.data.details;
|
msg = e.data.details;
|
||||||
} else if (e.data && e.data.message) {
|
} else if (e.data && e.data.message) {
|
||||||
|
@ -40,6 +40,9 @@ angular.module('portainer.app').factory('Notifications', [
|
||||||
msg = e.msg;
|
msg = e.msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error(e);
|
||||||
|
|
||||||
if (msg !== 'Invalid JWT token') {
|
if (msg !== 'Invalid JWT token') {
|
||||||
toastr.error($sanitize(msg), $sanitize(title), { timeOut: 6000 });
|
toastr.error($sanitize(msg), $sanitize(title), { timeOut: 6000 });
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue