diff --git a/api/http/handler/edgegroups/edgegroup_list.go b/api/http/handler/edgegroups/edgegroup_list.go index efaf7dd85..0363ce87e 100644 --- a/api/http/handler/edgegroups/edgegroup_list.go +++ b/api/http/handler/edgegroups/edgegroup_list.go @@ -1,6 +1,7 @@ package edgegroups import ( + "fmt" "net/http" httperror "github.com/portainer/libhttp/error" @@ -10,7 +11,8 @@ import ( type decoratedEdgeGroup struct { portainer.EdgeGroup - HasEdgeStack bool `json:"HasEdgeStack"` + HasEdgeStack bool `json:"HasEdgeStack"` + EndpointTypes []portainer.EndpointType } // @id EdgeGroupList @@ -46,17 +48,25 @@ func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *h decoratedEdgeGroups := []decoratedEdgeGroup{} for _, orgEdgeGroup := range edgeGroups { edgeGroup := decoratedEdgeGroup{ - EdgeGroup: orgEdgeGroup, + EdgeGroup: orgEdgeGroup, + EndpointTypes: []portainer.EndpointType{}, } if edgeGroup.Dynamic { - endpoints, err := handler.getEndpointsByTags(edgeGroup.TagIDs, edgeGroup.PartialMatch) + endpointIDs, err := handler.getEndpointsByTags(edgeGroup.TagIDs, edgeGroup.PartialMatch) if err != nil { 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] decoratedEdgeGroups = append(decoratedEdgeGroups, edgeGroup) @@ -64,3 +74,22 @@ func (handler *Handler) edgeGroupList(w http.ResponseWriter, r *http.Request) *h 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 +} diff --git a/api/http/handler/edgegroups/edgegroup_list_test.go b/api/http/handler/edgegroups/edgegroup_list_test.go new file mode 100644 index 000000000..36816fea9 --- /dev/null +++ b/api/http/handler/edgegroups/edgegroup_list_test.go @@ -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") +} diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go index 80e838827..4a3f5ce30 100644 --- a/api/http/handler/edgestacks/edgestack_create.go +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -2,6 +2,7 @@ package edgestacks import ( "errors" + "fmt" "net/http" "strconv" "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} } - 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) } @@ -95,6 +65,11 @@ type swarmStackFromFileContentPayload struct { StackFileContent string `example:"version: 3\n services:\n web:\n image:nginx" validate:"required"` // List of identifiers of EdgeGroups EdgeGroups []portainer.EdgeGroupID `example:"1"` + // Deployment type to deploy this stack + // Valid values are: 0 - 'compose', 1 - 'kubernetes' + // 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 { @@ -124,21 +99,64 @@ func (handler *Handler) createSwarmStackFromFileContent(r *http.Request) (*porta stackID := handler.DataStore.EdgeStack().GetNextIdentifier() stack := &portainer.EdgeStack{ - ID: portainer.EdgeStackID(stackID), - Name: payload.Name, - EntryPoint: filesystem.ComposeFileDefaultName, - CreationDate: time.Now().Unix(), - EdgeGroups: payload.EdgeGroups, - Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), - Version: 1, + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + DeploymentType: payload.DeploymentType, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + 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)) - projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) - if err != nil { - return nil, err + if stack.DeploymentType == portainer.EdgeStackDeploymentCompose { + stack.EntryPoint = filesystem.ComposeFileDefaultName + + 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) if err != nil { @@ -162,9 +180,14 @@ type swarmStackFromGitRepositoryPayload struct { // Password used in basic authentication. Required when RepositoryAuthentication is true. RepositoryPassword string `example:"myGitPassword"` // 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 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 { @@ -177,8 +200,8 @@ func (payload *swarmStackFromGitRepositoryPayload) Validate(r *http.Request) err if payload.RepositoryAuthentication && (govalidator.IsNull(payload.RepositoryUsername) || govalidator.IsNull(payload.RepositoryPassword)) { return errors.New("Invalid repository credentials. Username and password must be specified when authentication is enabled") } - if govalidator.IsNull(payload.ComposeFilePathInRepository) { - payload.ComposeFilePathInRepository = filesystem.ComposeFileDefaultName + if govalidator.IsNull(payload.FilePathInRepository) { + payload.FilePathInRepository = filesystem.ComposeFileDefaultName } if payload.EdgeGroups == nil || len(payload.EdgeGroups) == 0 { 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() stack := &portainer.EdgeStack{ - ID: portainer.EdgeStackID(stackID), - Name: payload.Name, - EntryPoint: payload.ComposeFilePathInRepository, - CreationDate: time.Now().Unix(), - EdgeGroups: payload.EdgeGroups, - Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), - Version: 1, + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + DeploymentType: payload.DeploymentType, + Version: 1, } projectPath := handler.FileService.GetEdgeStackProjectPath(strconv.Itoa(int(stack.ID))) @@ -219,11 +242,37 @@ func (handler *Handler) createSwarmStackFromGitRepository(r *http.Request) (*por 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) if err != nil { 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) if err != nil { return nil, err @@ -236,6 +285,7 @@ type swarmStackFromFileUploadPayload struct { Name string StackFileContent []byte EdgeGroups []portainer.EdgeGroupID + DeploymentType portainer.EdgeStackDeploymentType } 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") } 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 } @@ -274,21 +331,54 @@ func (handler *Handler) createSwarmStackFromFileUpload(r *http.Request) (*portai stackID := handler.DataStore.EdgeStack().GetNextIdentifier() stack := &portainer.EdgeStack{ - ID: portainer.EdgeStackID(stackID), - Name: payload.Name, - EntryPoint: filesystem.ComposeFileDefaultName, - CreationDate: time.Now().Unix(), - EdgeGroups: payload.EdgeGroups, - Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), - Version: 1, + ID: portainer.EdgeStackID(stackID), + Name: payload.Name, + DeploymentType: payload.DeploymentType, + CreationDate: time.Now().Unix(), + EdgeGroups: payload.EdgeGroups, + Status: make(map[portainer.EndpointID]portainer.EdgeStackStatus), + 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)) - projectPath, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent)) - if err != nil { - return nil, err + if stack.DeploymentType == portainer.EdgeStackDeploymentCompose { + stack.EntryPoint = filesystem.ComposeFileDefaultName + + 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) if err != nil { @@ -311,3 +401,22 @@ func (handler *Handler) validateUniqueName(name string) error { } 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 +} diff --git a/api/http/handler/edgestacks/edgestack_create_test.go b/api/http/handler/edgestacks/edgestack_create_test.go new file mode 100644 index 000000000..8e1144e4a --- /dev/null +++ b/api/http/handler/edgestacks/edgestack_create_test.go @@ -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]) + } +} diff --git a/api/http/handler/edgestacks/edgestack_delete.go b/api/http/handler/edgestacks/edgestack_delete.go index 7ff0ee1cd..f9269705b 100644 --- a/api/http/handler/edgestacks/edgestack_delete.go +++ b/api/http/handler/edgestacks/edgestack_delete.go @@ -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} } - endpoints, err := handler.DataStore.Endpoint().Endpoints() + relationConfig, err := fetchEndpointRelationsConfig(handler.DataStore) 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 { - 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() - 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 { + for _, endpointID := range relatedEndpointIds { relation, err := handler.DataStore.EndpointRelation().EndpointRelation(endpointID) if err != nil { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to find environment relation in database", err} diff --git a/api/http/handler/edgestacks/edgestack_file.go b/api/http/handler/edgestacks/edgestack_file.go index 02bfbbe84..e4adee8d8 100644 --- a/api/http/handler/edgestacks/edgestack_file.go +++ b/api/http/handler/edgestacks/edgestack_file.go @@ -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} } - 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 { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err} } diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go index e81fe5579..f2d2bd329 100644 --- a/api/http/handler/edgestacks/edgestack_update.go +++ b/api/http/handler/edgestacks/edgestack_update.go @@ -5,24 +5,24 @@ import ( "net/http" "strconv" - "github.com/asaskevich/govalidator" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" portainer "github.com/portainer/portainer/api" bolterrors "github.com/portainer/portainer/api/bolt/errors" + "github.com/portainer/portainer/api/filesystem" "github.com/portainer/portainer/api/internal/edge" ) type updateEdgeStackPayload struct { StackFileContent string Version *int - Prune *bool EdgeGroups []portainer.EdgeGroupID + DeploymentType portainer.EdgeStackDeploymentType } func (payload *updateEdgeStackPayload) Validate(r *http.Request) error { - if govalidator.IsNull(payload.StackFileContent) { + if payload.StackFileContent == "" { return errors.New("Invalid stack file content") } 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} } + 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 { - 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} - } - - oldRelated, err := edge.EdgeStackRelatedEndpoints(stack.EdgeGroups, endpoints, endpointGroups, edgeGroups) + newRelated, err := edge.EdgeStackRelatedEndpoints(payload.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} } - newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, endpoints, endpointGroups, edgeGroups) - if err != nil { - return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve edge stack related environments from database", err} - } - - oldRelatedSet := EndpointSet(oldRelated) + oldRelatedSet := EndpointSet(relatedEndpointIds) newRelatedSet := EndpointSet(newRelated) endpointsToRemove := map[portainer.EndpointID]bool{} @@ -136,17 +126,55 @@ func (handler *Handler) edgeStackUpdate(w http.ResponseWriter, r *http.Request) } stack.EdgeGroups = payload.EdgeGroups - + relatedEndpointIds = newRelated } - if payload.Prune != nil { - stack.Prune = *payload.Prune + if stack.DeploymentType != payload.DeploymentType { + // 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)) - _, 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} + if payload.DeploymentType == portainer.EdgeStackDeploymentCompose { + if stack.EntryPoint == "" { + 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 { diff --git a/api/http/handler/edgestacks/endpoints.go b/api/http/handler/edgestacks/endpoints.go new file mode 100644 index 000000000..de494f9f0 --- /dev/null +++ b/api/http/handler/edgestacks/endpoints.go @@ -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 +} diff --git a/api/http/handler/edgestacks/endpoints_test.go b/api/http/handler/edgestacks/endpoints_test.go new file mode 100644 index 000000000..6cc94b17c --- /dev/null +++ b/api/http/handler/edgestacks/endpoints_test.go @@ -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") +} diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go index 2e0580d6d..5e14719db 100644 --- a/api/http/handler/edgestacks/handler.go +++ b/api/http/handler/edgestacks/handler.go @@ -1,21 +1,26 @@ package edgestacks import ( + "fmt" "net/http" + "path" + "strconv" "github.com/gorilla/mux" 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" ) // Handler is the HTTP handler used to handle endpoint group operations. type Handler struct { *mux.Router - requestBouncer *security.RequestBouncer - DataStore portainer.DataStore - FileService portainer.FileService - GitService portainer.GitService + requestBouncer *security.RequestBouncer + DataStore portainer.DataStore + FileService portainer.FileService + GitService portainer.GitService + KubernetesDeployer portainer.KubernetesDeployer } // 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) 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 +} diff --git a/api/http/handler/endpointedge/endpoint_edgestack_inspect.go b/api/http/handler/endpointedge/endpoint_edgestack_inspect.go index 823f21c98..8f749fb1b 100644 --- a/api/http/handler/endpointedge/endpoint_edgestack_inspect.go +++ b/api/http/handler/endpointedge/endpoint_edgestack_inspect.go @@ -1,6 +1,7 @@ package endpointedge import ( + "errors" "net/http" "path" @@ -8,11 +9,11 @@ import ( "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" 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 { - Prune bool StackFileContent 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)) - 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} } else if err != nil { 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)) - 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} } else if err != nil { 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 { return &httperror.HandlerError{http.StatusInternalServerError, "Unable to retrieve Compose file from disk", err} } return response.JSON(w, configResponse{ - Prune: edgeStack.Prune, StackFileContent: string(stackFileContent), Name: edgeStack.Name, }) diff --git a/api/http/server.go b/api/http/server.go index 88ca1284f..d826760f0 100644 --- a/api/http/server.go +++ b/api/http/server.go @@ -130,6 +130,7 @@ func (server *Server) Start() error { edgeStacksHandler.DataStore = server.DataStore edgeStacksHandler.FileService = server.FileService edgeStacksHandler.GitService = server.GitService + edgeStacksHandler.KubernetesDeployer = server.KubernetesDeployer var edgeTemplatesHandler = edgetemplates.NewHandler(requestBouncer) edgeTemplatesHandler.DataStore = server.DataStore diff --git a/api/internal/endpointutils/endpoint_test.go b/api/internal/endpointutils/endpoint_test.go new file mode 100644 index 000000000..35793b6e5 --- /dev/null +++ b/api/internal/endpointutils/endpoint_test.go @@ -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) + } +} diff --git a/api/internal/testhelpers/datastore.go b/api/internal/testhelpers/datastore.go index 08e865c77..89e3b0329 100644 --- a/api/internal/testhelpers/datastore.go +++ b/api/internal/testhelpers/datastore.go @@ -4,6 +4,7 @@ import ( "io" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/bolt/errors" ) type datastore struct { @@ -127,3 +128,106 @@ func WithEdgeJobs(js []portainer.EdgeJob) datastoreOption { 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} + } +} diff --git a/api/portainer.go b/api/portainer.go index 96af4f17e..703b3fd36 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -194,17 +194,23 @@ type ( //EdgeStack represents an edge stack EdgeStack struct { // EdgeStack Identifier - ID EdgeStackID `json:"Id" example:"1"` - Name string `json:"Name"` - Status map[EndpointID]EdgeStackStatus `json:"Status"` - CreationDate int64 `json:"CreationDate"` - EdgeGroups []EdgeGroupID `json:"EdgeGroups"` - ProjectPath string `json:"ProjectPath"` - EntryPoint string `json:"EntryPoint"` - Version int `json:"Version"` - Prune bool `json:"Prune"` + ID EdgeStackID `json:"Id" example:"1"` + Name string `json:"Name"` + Status map[EndpointID]EdgeStackStatus `json:"Status"` + CreationDate int64 `json:"CreationDate"` + EdgeGroups []EdgeGroupID `json:"EdgeGroups"` + ProjectPath string `json:"ProjectPath"` + EntryPoint string `json:"EntryPoint"` + Version int `json:"Version"` + ManifestPath string + DeploymentType EdgeStackDeploymentType + + // Deprecated + Prune bool `json:"Prune"` } + EdgeStackDeploymentType int + //EdgeStackID represents an edge stack id EdgeStackID int @@ -1502,6 +1508,13 @@ const ( 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 ( _ EdgeStackStatusType = iota //StatusOk represents a successfully deployed edge stack diff --git a/app/edge/__module.js b/app/edge/__module.js index a1507f356..72af140a5 100644 --- a/app/edge/__module.js +++ b/app/edge/__module.js @@ -1,6 +1,8 @@ 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 = { name: 'edge', url: '/edge', diff --git a/app/edge/components/edge-groups-selector/edgeGroupsSelector.html b/app/edge/components/edge-groups-selector/edgeGroupsSelector.html index 3ecb421a0..1210f3cb0 100644 --- a/app/edge/components/edge-groups-selector/edgeGroupsSelector.html +++ b/app/edge/components/edge-groups-selector/edgeGroupsSelector.html @@ -1,4 +1,12 @@ - + + {{ $item.Name }} diff --git a/app/edge/components/edge-groups-selector/index.js b/app/edge/components/edge-groups-selector/index.js index 0a7f7cbb3..902139191 100644 --- a/app/edge/components/edge-groups-selector/index.js +++ b/app/edge/components/edge-groups-selector/index.js @@ -3,7 +3,8 @@ import angular from 'angular'; angular.module('portainer.edge').component('edgeGroupsSelector', { templateUrl: './edgeGroupsSelector.html', bindings: { - model: '=', + model: '<', items: '<', + onChange: '<', }, }); diff --git a/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.controller.js b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.controller.js new file mode 100644 index 000000000..3cedd0464 --- /dev/null +++ b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.controller.js @@ -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' : ''; + }, + }, + ]; + } +} diff --git a/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html new file mode 100644 index 000000000..4eae73cc5 --- /dev/null +++ b/app/edge/components/edge-stack-deployment-type-selector/edge-stack-deployment-type-selector.html @@ -0,0 +1,4 @@ +
+ Deployment type +
+ diff --git a/app/edge/components/edge-stack-deployment-type-selector/index.js b/app/edge/components/edge-stack-deployment-type-selector/index.js new file mode 100644 index 000000000..c175249fd --- /dev/null +++ b/app/edge/components/edge-stack-deployment-type-selector/index.js @@ -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); diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html index e21b87c0e..95eb4f316 100644 --- a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html +++ b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html @@ -4,52 +4,70 @@
- +
- -
- Web editor -
-
- - You can get more information about Compose file format in the - - official documentation - - . - -
-
+
- +
+ + One or more of the selected Edge group contains Edge Docker endpoints that cannot be used with a Kubernetes Edge stack. +
- -
- Options -
-
+ + +
- - +
+ + Portainer uses Kompose to convert your Compose manifest to a Kubernetes compliant manifest. Be wary that not all the + Compose format options are supported by Kompose at the moment. +
+ + +
+ You can get more information about Compose file format in the + + official documentation + + . +
+
+
+ + + +

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

+
+
+
Actions @@ -59,9 +77,7 @@ + + {{ $ctrl.state.formValidationError }} + +
+
+ + + + +
+ diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.js b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.js new file mode 100644 index 000000000..7da77fb05 --- /dev/null +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.js @@ -0,0 +1,6 @@ +import controller from './create-edge-stack-view.controller'; + +export const createEdgeStackView = { + templateUrl: './create-edge-stack-view.html', + controller, +}; diff --git a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html b/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html deleted file mode 100644 index 916bcc1a7..000000000 --- a/app/edge/views/edge-stacks/createEdgeStackView/createEdgeStackView.html +++ /dev/null @@ -1,220 +0,0 @@ - - - Edge Stacks > Create Edge stack - - -
-
- - -
- -
- -
- -
-
- - -
- Edge Groups -
-
-
- -
-
- No Edge groups are available. Head over to the Edge groups view to create one. -
-
- -
- Build method -
-
-
-
-
- - -
-
- - -
-
- - -
-
- - -
-
-
- - -
-
- Web editor -
-
- - You can get more information about Compose file format in the - - official documentation - - . - -
-
-
- -
-
-
- - -
-
- Upload -
-
- - You can upload a Compose file from your computer. - -
-
-
- - - {{ $ctrl.formValues.StackFile.name }} - - -
-
-
- - - - - -
-
- -
- -
-
- -
-
- Information -
-
-
-
-
-
-
- - -
-
- Web editor -
-
-
- -
-
-
-
- - - -
- Actions -
-
-
- - - {{ $ctrl.state.formValidationError }} - -
-
- -
-
-
-
-
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js new file mode 100644 index 000000000..c78caa495 --- /dev/null +++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.controller.js @@ -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; diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html new file mode 100644 index 000000000..48388c9dc --- /dev/null +++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html @@ -0,0 +1,74 @@ +
+ Build method +
+ + + + + You can get more information about Compose file format in the + + official documentation + + . + + + + + + You can upload a Compose file from your computer. + + + + + + +
+
+ +
+ +
+
+ +
+
+ Information +
+
+
+
+
+
+
+ + + + + + +
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/index.js b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/index.js new file mode 100644 index 000000000..59fd0aecc --- /dev/null +++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/index.js @@ -0,0 +1,11 @@ +import controller from './docker-compose-form.controller.js'; + +export const edgeStacksDockerComposeForm = { + templateUrl: './docker-compose-form.html', + controller, + + bindings: { + formValues: '=', + state: '=', + }, +}; diff --git a/app/edge/views/edge-stacks/createEdgeStackView/index.js b/app/edge/views/edge-stacks/createEdgeStackView/index.js index 29206fe54..af8eee573 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/index.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/index.js @@ -1,8 +1,13 @@ 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', { - templateUrl: './createEdgeStackView.html', - controller: CreateEdgeStackViewController, -}); +export default angular + .module('portainer.edge.stacks.create', []) + .component('createEdgeStackView', createEdgeStackView) + .component('edgeStacksDockerComposeForm', edgeStacksDockerComposeForm) + .component('edgeStacksKubeManifestForm', kubeManifestForm) + .component('kubeDeployDescription', kubeDeployDescription).name; diff --git a/app/edge/views/edge-stacks/createEdgeStackView/kube-deploy-description/index.js b/app/edge/views/edge-stacks/createEdgeStackView/kube-deploy-description/index.js new file mode 100644 index 000000000..53d8f3dd5 --- /dev/null +++ b/app/edge/views/edge-stacks/createEdgeStackView/kube-deploy-description/index.js @@ -0,0 +1,3 @@ +export const kubeDeployDescription = { + templateUrl: './kube-deploy-description.html', +}; diff --git a/app/edge/views/edge-stacks/createEdgeStackView/kube-deploy-description/kube-deploy-description.html b/app/edge/views/edge-stacks/createEdgeStackView/kube-deploy-description/kube-deploy-description.html new file mode 100644 index 000000000..c12d7ab22 --- /dev/null +++ b/app/edge/views/edge-stacks/createEdgeStackView/kube-deploy-description/kube-deploy-description.html @@ -0,0 +1,8 @@ +

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

+

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

diff --git a/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/index.js b/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/index.js new file mode 100644 index 000000000..41949f7e3 --- /dev/null +++ b/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/index.js @@ -0,0 +1,11 @@ +import controller from './kube-manifest-form.controller.js'; + +export const kubeManifestForm = { + templateUrl: './kube-manifest-form.html', + controller, + + bindings: { + formValues: '=', + state: '=', + }, +}; diff --git a/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.controller.js new file mode 100644 index 000000000..5c3719cac --- /dev/null +++ b/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.controller.js @@ -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; diff --git a/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.html b/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.html new file mode 100644 index 000000000..1b09a0712 --- /dev/null +++ b/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.html @@ -0,0 +1,26 @@ +
+ Build method +
+ + + + + + + + + + + + + + + diff --git a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js index 6fe162470..742b31728 100644 --- a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js +++ b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js @@ -39,7 +39,7 @@ export class EditEdgeStackViewController { this.formValues = { StackFileContent: file, EdgeGroups: this.stack.EdgeGroups, - Prune: this.stack.Prune, + DeploymentType: this.stack.DeploymentType, }; this.oldFileContent = this.formValues.StackFileContent; } catch (err) { @@ -58,7 +58,7 @@ export class EditEdgeStackViewController { } 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(); } } @@ -99,7 +99,7 @@ export class EditEdgeStackViewController { async getPaginatedEndpointsAsync(lastId, limit, search) { 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 endpoints = _.map(value, (endpoint) => { const status = this.stack.Status[endpoint.Id]; diff --git a/app/edge/views/edge-stacks/index.js b/app/edge/views/edge-stacks/index.js new file mode 100644 index 000000000..ed21f54f6 --- /dev/null +++ b/app/edge/views/edge-stacks/index.js @@ -0,0 +1,5 @@ +import angular from 'angular'; + +import createModule from './createEdgeStackView'; + +export default angular.module('portainer.edge.stacks', [createModule]).name; diff --git a/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelectorController.js b/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelectorController.js index 7d23a6d5e..c051d46c0 100644 --- a/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelectorController.js +++ b/app/portainer/components/associated-endpoints-selector/associatedEndpointsSelectorController.js @@ -56,7 +56,7 @@ class AssoicatedEndpointsSelectorController { async getEndpointsAsync() { 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); @@ -73,7 +73,7 @@ class AssoicatedEndpointsSelectorController { let response = { value: [], totalCount: 0 }; if (this.endpointIds.length > 0) { 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); } diff --git a/app/portainer/components/box-selector/box-selector-item/box-selector-item.html b/app/portainer/components/box-selector/box-selector-item/box-selector-item.html index 398f65563..b6ff6c670 100644 --- a/app/portainer/components/box-selector/box-selector-item/box-selector-item.html +++ b/app/portainer/components/box-selector/box-selector-item/box-selector-item.html @@ -1,6 +1,20 @@ -
- -
diff --git a/app/portainer/components/forms/git-form/git-form-compose-path-field/git-form-compose-path-field.html b/app/portainer/components/forms/git-form/git-form-compose-path-field/git-form-compose-path-field.html index 541023c49..bce200564 100644 --- a/app/portainer/components/forms/git-form/git-form-compose-path-field/git-form-compose-path-field.html +++ b/app/portainer/components/forms/git-form/git-form-compose-path-field/git-form-compose-path-field.html @@ -1,11 +1,18 @@
- - Indicate the path to the Compose file from the root of your repository. - + Indicate the path to the {{ $ctrl.deployMethod == 'compose' ? 'Compose' : 'Manifest' }} file from the root of your repository.
- +
- +
diff --git a/app/portainer/components/forms/git-form/git-form-compose-path-field/index.js b/app/portainer/components/forms/git-form/git-form-compose-path-field/index.js index 7906d0e85..03c7b27e1 100644 --- a/app/portainer/components/forms/git-form/git-form-compose-path-field/index.js +++ b/app/portainer/components/forms/git-form/git-form-compose-path-field/index.js @@ -1,6 +1,8 @@ export const gitFormComposePathField = { templateUrl: './git-form-compose-path-field.html', bindings: { + deployMethod: '@', + value: '<', onChange: '<', }, diff --git a/app/portainer/components/forms/git-form/git-form-ref-field/git-form-ref-field.html b/app/portainer/components/forms/git-form/git-form-ref-field/git-form-ref-field.html index d8b5908f5..ff5a84f8d 100644 --- a/app/portainer/components/forms/git-form/git-form-ref-field/git-form-ref-field.html +++ b/app/portainer/components/forms/git-form/git-form-ref-field/git-form-ref-field.html @@ -5,8 +5,8 @@
- -
+ +
diff --git a/app/portainer/components/forms/git-form/git-form.controller.js b/app/portainer/components/forms/git-form/git-form.controller.js index 795e9a7ba..1a0476224 100644 --- a/app/portainer/components/forms/git-form/git-form.controller.js +++ b/app/portainer/components/forms/git-form/git-form.controller.js @@ -15,4 +15,8 @@ export default class GitFormController { }); }; } + + $onInit() { + this.deployMethod = this.deployMethod || 'compose'; + } } diff --git a/app/portainer/components/forms/git-form/git-form.html b/app/portainer/components/forms/git-form/git-form.html index ba35fcf67..7762a0eca 100644 --- a/app/portainer/components/forms/git-form/git-form.html +++ b/app/portainer/components/forms/git-form/git-form.html @@ -1,11 +1,18 @@ -
+
Git repository
- + + - + + + -
+ diff --git a/app/portainer/components/forms/git-form/git-form.js b/app/portainer/components/forms/git-form/git-form.js index affa081dc..8e6bbf70f 100644 --- a/app/portainer/components/forms/git-form/git-form.js +++ b/app/portainer/components/forms/git-form/git-form.js @@ -4,6 +4,7 @@ export const gitForm = { templateUrl: './git-form.html', controller, bindings: { + deployMethod: '@', model: '<', onChange: '<', additionalFile: '<', diff --git a/app/portainer/services/fileUpload.js b/app/portainer/services/fileUpload.js index 81340d623..9ab3f4411 100644 --- a/app/portainer/services/fileUpload.js +++ b/app/portainer/services/fileUpload.js @@ -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({ url: 'api/edge_stacks?method=file', data: { - file: file, - Name: stackName, - EdgeGroups: Upload.json(edgeGroups), + file, + EdgeGroups: Upload.json(EdgeGroups), + ...payload, }, ignoreLoadingBar: true, }); diff --git a/app/portainer/services/notifications.js b/app/portainer/services/notifications.js index 2642dcfbf..863f883d7 100644 --- a/app/portainer/services/notifications.js +++ b/app/portainer/services/notifications.js @@ -16,10 +16,10 @@ angular.module('portainer.app').factory('Notifications', [ service.error = function (title, e, fallbackText) { var msg = fallbackText; - if (e.err && e.err.data && e.err.data.message) { - msg = e.err.data.message; - } else if (e.err && e.err.data && e.err.data.details) { + if (e.err && e.err.data && 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) { msg = e.data.details; } else if (e.data && e.data.message) { @@ -40,6 +40,9 @@ angular.module('portainer.app').factory('Notifications', [ msg = e.msg; } + // eslint-disable-next-line no-console + console.error(e); + if (msg !== 'Invalid JWT token') { toastr.error($sanitize(msg), $sanitize(title), { timeOut: 6000 }); }