mirror of https://github.com/portainer/portainer
feat(edge/stacks): sync EE codechanges [EE-498] (#8580)
parent
0ec7dfce69
commit
93bf630105
|
@ -95,6 +95,7 @@ type (
|
||||||
EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error)
|
EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error)
|
||||||
EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
|
EdgeStackVersion(ID portainer.EdgeStackID) (int, bool)
|
||||||
Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error
|
Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error
|
||||||
|
// Deprecated: Use UpdateEdgeStackFunc instead.
|
||||||
UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error
|
UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error
|
||||||
UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error
|
||||||
DeleteEdgeStack(ID portainer.EdgeStackID) error
|
DeleteEdgeStack(ID portainer.EdgeStackID) error
|
||||||
|
|
|
@ -0,0 +1,20 @@
|
||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
httperror "github.com/portainer/libhttp/error"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TxResponse(err error, validResponse func() *httperror.HandlerError) *httperror.HandlerError {
|
||||||
|
if err != nil {
|
||||||
|
var handlerError *httperror.HandlerError
|
||||||
|
if errors.As(err, &handlerError) {
|
||||||
|
return handlerError
|
||||||
|
}
|
||||||
|
|
||||||
|
return httperror.InternalServerError("Unexpected error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return validResponse()
|
||||||
|
}
|
|
@ -38,6 +38,8 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request)
|
||||||
switch {
|
switch {
|
||||||
case httperrors.IsInvalidPayloadError(err):
|
case httperrors.IsInvalidPayloadError(err):
|
||||||
return httperror.BadRequest("Invalid payload", err)
|
return httperror.BadRequest("Invalid payload", err)
|
||||||
|
case httperrors.IsConflictError(err):
|
||||||
|
return httperror.NewError(http.StatusConflict, err.Error(), err)
|
||||||
default:
|
default:
|
||||||
return httperror.InternalServerError("Unable to create Edge stack", err)
|
return httperror.InternalServerError("Unable to create Edge stack", err)
|
||||||
}
|
}
|
||||||
|
|
|
@ -105,7 +105,6 @@ func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolde
|
||||||
}
|
}
|
||||||
|
|
||||||
return composePath, "", projectPath, nil
|
return composePath, "", projectPath, nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
|
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||||
|
|
|
@ -144,7 +144,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
|
||||||
DeploymentType: edgeStack.DeploymentType,
|
DeploymentType: edgeStack.DeploymentType,
|
||||||
},
|
},
|
||||||
Method: "string",
|
Method: "string",
|
||||||
ExpectedStatusCode: 500,
|
ExpectedStatusCode: http.StatusConflict,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Empty EdgeStack Groups",
|
Name: "Empty EdgeStack Groups",
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
package edgestacks
|
package edgestacks
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/pkg/errors"
|
||||||
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"
|
||||||
|
@ -12,7 +12,7 @@ import (
|
||||||
"github.com/portainer/portainer/api/dataservices"
|
"github.com/portainer/portainer/api/dataservices"
|
||||||
"github.com/portainer/portainer/api/filesystem"
|
"github.com/portainer/portainer/api/filesystem"
|
||||||
"github.com/portainer/portainer/api/internal/edge"
|
"github.com/portainer/portainer/api/internal/edge"
|
||||||
"github.com/portainer/portainer/api/internal/endpointutils"
|
"github.com/portainer/portainer/api/internal/set"
|
||||||
"github.com/portainer/portainer/pkg/featureflags"
|
"github.com/portainer/portainer/pkg/featureflags"
|
||||||
|
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
|
@ -20,7 +20,7 @@ import (
|
||||||
|
|
||||||
type updateEdgeStackPayload struct {
|
type updateEdgeStackPayload struct {
|
||||||
StackFileContent string
|
StackFileContent string
|
||||||
Version *int
|
UpdateVersion bool
|
||||||
EdgeGroups []portainer.EdgeGroupID
|
EdgeGroups []portainer.EdgeGroupID
|
||||||
DeploymentType portainer.EdgeStackDeploymentType
|
DeploymentType portainer.EdgeStackDeploymentType
|
||||||
// Uses the manifest's namespaces instead of the default one
|
// Uses the manifest's namespaces instead of the default one
|
||||||
|
@ -104,72 +104,32 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
|
return nil, httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
endpointsToAdd := map[portainer.EndpointID]bool{}
|
groupsIds := stack.EdgeGroups
|
||||||
|
|
||||||
if payload.EdgeGroups != nil {
|
if payload.EdgeGroups != nil {
|
||||||
newRelated, err := edge.EdgeStackRelatedEndpoints(payload.EdgeGroups, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
|
newRelated, _, err := handler.handleChangeEdgeGroups(tx, stack.ID, payload.EdgeGroups, relatedEndpointIds, relationConfig)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.InternalServerError("Unable to retrieve edge stack related environments from database", err)
|
return nil, httperror.InternalServerError("Unable to handle edge groups change", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
oldRelatedSet := endpointutils.EndpointSet(relatedEndpointIds)
|
groupsIds = payload.EdgeGroups
|
||||||
newRelatedSet := endpointutils.EndpointSet(newRelated)
|
|
||||||
|
|
||||||
endpointsToRemove := map[portainer.EndpointID]bool{}
|
|
||||||
for endpointID := range oldRelatedSet {
|
|
||||||
if !newRelatedSet[endpointID] {
|
|
||||||
endpointsToRemove[endpointID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for endpointID := range endpointsToRemove {
|
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, httperror.InternalServerError("Unable to find environment relation in database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(relation.EdgeStacks, stack.ID)
|
|
||||||
|
|
||||||
err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
|
|
||||||
if err != nil {
|
|
||||||
return nil, httperror.InternalServerError("Unable to persist environment relation in database", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for endpointID := range newRelatedSet {
|
|
||||||
if !oldRelatedSet[endpointID] {
|
|
||||||
endpointsToAdd[endpointID] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for endpointID := range endpointsToAdd {
|
|
||||||
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, httperror.InternalServerError("Unable to find environment relation in database", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
relation.EdgeStacks[stack.ID] = true
|
|
||||||
|
|
||||||
err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
|
|
||||||
if err != nil {
|
|
||||||
return nil, httperror.InternalServerError("Unable to persist environment relation in database", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stack.EdgeGroups = payload.EdgeGroups
|
|
||||||
relatedEndpointIds = newRelated
|
relatedEndpointIds = newRelated
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if stack.DeploymentType != payload.DeploymentType {
|
entryPoint := stack.EntryPoint
|
||||||
|
manifestPath := stack.ManifestPath
|
||||||
|
deploymentType := stack.DeploymentType
|
||||||
|
|
||||||
|
if deploymentType != payload.DeploymentType {
|
||||||
// deployment type was changed - need to delete the old file
|
// deployment type was changed - need to delete the old file
|
||||||
err = handler.FileService.RemoveDirectory(stack.ProjectPath)
|
err = handler.FileService.RemoveDirectory(stack.ProjectPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Warn().Err(err).Msg("Unable to clear old files")
|
log.Warn().Err(err).Msg("Unable to clear old files")
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.EntryPoint = ""
|
entryPoint = ""
|
||||||
stack.ManifestPath = ""
|
manifestPath = ""
|
||||||
stack.DeploymentType = payload.DeploymentType
|
deploymentType = payload.DeploymentType
|
||||||
}
|
}
|
||||||
|
|
||||||
stackFolder := strconv.Itoa(int(stack.ID))
|
stackFolder := strconv.Itoa(int(stack.ID))
|
||||||
|
@ -183,52 +143,106 @@ func (handler *Handler) updateEdgeStack(tx dataservices.DataStoreTx, stackID por
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
if payload.DeploymentType == portainer.EdgeStackDeploymentCompose {
|
||||||
if stack.EntryPoint == "" {
|
if entryPoint == "" {
|
||||||
stack.EntryPoint = filesystem.ComposeFileDefaultName
|
entryPoint = filesystem.ComposeFileDefaultName
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.EntryPoint, []byte(payload.StackFileContent))
|
_, err := handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, entryPoint, []byte(payload.StackFileContent))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
|
return nil, httperror.InternalServerError("Unable to persist updated Compose file on disk", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
manifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, stack.ProjectPath, stack.EntryPoint, relatedEndpointIds)
|
tempManifestPath, err := handler.convertAndStoreKubeManifestIfNeeded(stackFolder, stack.ProjectPath, entryPoint, relatedEndpointIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.InternalServerError("Unable to convert and persist updated Kubernetes manifest file on disk", err)
|
return nil, httperror.InternalServerError("Unable to convert and persist updated Kubernetes manifest file on disk", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.ManifestPath = manifestPath
|
manifestPath = tempManifestPath
|
||||||
}
|
}
|
||||||
|
|
||||||
if payload.DeploymentType == portainer.EdgeStackDeploymentKubernetes {
|
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||||
if stack.ManifestPath == "" {
|
if manifestPath == "" {
|
||||||
stack.ManifestPath = filesystem.ManifestFileDefaultName
|
manifestPath = filesystem.ManifestFileDefaultName
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.UseManifestNamespaces = payload.UseManifestNamespaces
|
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, []byte(payload.StackFileContent))
|
||||||
|
|
||||||
_, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.InternalServerError("Unable to persist updated Kubernetes manifest file on disk", err)
|
return nil, httperror.InternalServerError("Unable to persist updated Kubernetes manifest file on disk", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
versionUpdated := payload.Version != nil && *payload.Version != stack.Version
|
err = tx.EdgeStack().UpdateEdgeStackFunc(stack.ID, func(edgeStack *portainer.EdgeStack) {
|
||||||
if versionUpdated {
|
edgeStack.NumDeployments = len(relatedEndpointIds)
|
||||||
stack.Version = *payload.Version
|
if payload.UpdateVersion {
|
||||||
stack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{}
|
edgeStack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus)
|
||||||
|
edgeStack.Version++
|
||||||
}
|
}
|
||||||
|
|
||||||
stack.NumDeployments = len(relatedEndpointIds)
|
edgeStack.UseManifestNamespaces = payload.UseManifestNamespaces
|
||||||
|
|
||||||
if versionUpdated {
|
edgeStack.DeploymentType = deploymentType
|
||||||
stack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus)
|
edgeStack.EntryPoint = entryPoint
|
||||||
}
|
edgeStack.ManifestPath = manifestPath
|
||||||
|
|
||||||
err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack)
|
edgeStack.EdgeGroups = groupsIds
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
|
return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return stack, nil
|
return stack, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, edgeStackID portainer.EdgeStackID, newEdgeGroupsIDs []portainer.EdgeGroupID, oldRelatedEnvironmentIDs []portainer.EndpointID, relationConfig *edge.EndpointRelationsConfig) ([]portainer.EndpointID, set.Set[portainer.EndpointID], error) {
|
||||||
|
newRelatedEnvironmentIDs, err := edge.EdgeStackRelatedEndpoints(newEdgeGroupsIDs, relationConfig.Endpoints, relationConfig.EndpointGroups, relationConfig.EdgeGroups)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.WithMessage(err, "Unable to retrieve edge stack related environments from database")
|
||||||
|
}
|
||||||
|
|
||||||
|
oldRelatedSet := set.ToSet(oldRelatedEnvironmentIDs)
|
||||||
|
newRelatedSet := set.ToSet(newRelatedEnvironmentIDs)
|
||||||
|
|
||||||
|
endpointsToRemove := set.Set[portainer.EndpointID]{}
|
||||||
|
for endpointID := range oldRelatedSet {
|
||||||
|
if !newRelatedSet[endpointID] {
|
||||||
|
endpointsToRemove[endpointID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for endpointID := range endpointsToRemove {
|
||||||
|
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(relation.EdgeStacks, edgeStackID)
|
||||||
|
|
||||||
|
err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
endpointsToAdd := set.Set[portainer.EndpointID]{}
|
||||||
|
for endpointID := range newRelatedSet {
|
||||||
|
if !oldRelatedSet[endpointID] {
|
||||||
|
endpointsToAdd[endpointID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for endpointID := range endpointsToAdd {
|
||||||
|
relation, err := tx.EndpointRelation().EndpointRelation(endpointID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.WithMessage(err, "Unable to find environment relation in database")
|
||||||
|
}
|
||||||
|
|
||||||
|
relation.EdgeStacks[edgeStackID] = true
|
||||||
|
|
||||||
|
err = tx.EndpointRelation().UpdateEndpointRelation(endpointID, relation)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errors.WithMessage(err, "Unable to persist environment relation in database")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newRelatedEnvironmentIDs, endpointsToAdd, nil
|
||||||
|
}
|
||||||
|
|
|
@ -54,10 +54,9 @@ func TestUpdateAndInspect(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
newVersion := 238
|
|
||||||
payload := updateEdgeStackPayload{
|
payload := updateEdgeStackPayload{
|
||||||
StackFileContent: "update-test",
|
StackFileContent: "update-test",
|
||||||
Version: &newVersion,
|
UpdateVersion: true,
|
||||||
EdgeGroups: append(edgeStack.EdgeGroups, newEdgeGroup.ID),
|
EdgeGroups: append(edgeStack.EdgeGroups, newEdgeGroup.ID),
|
||||||
DeploymentType: portainer.EdgeStackDeploymentCompose,
|
DeploymentType: portainer.EdgeStackDeploymentCompose,
|
||||||
}
|
}
|
||||||
|
@ -101,7 +100,7 @@ func TestUpdateAndInspect(t *testing.T) {
|
||||||
t.Fatal("error decoding response:", err)
|
t.Fatal("error decoding response:", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if data.Version != *payload.Version {
|
if payload.UpdateVersion && data.Version != edgeStack.Version+1 {
|
||||||
t.Fatalf("expected EdgeStackID %d, found %d", edgeStack.Version, data.Version)
|
t.Fatalf("expected EdgeStackID %d, found %d", edgeStack.Version, data.Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -132,7 +131,6 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
|
||||||
|
|
||||||
handler.DataStore.EdgeGroup().Create(&newEdgeGroup)
|
handler.DataStore.EdgeGroup().Create(&newEdgeGroup)
|
||||||
|
|
||||||
newVersion := 238
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
Name string
|
Name string
|
||||||
Payload updateEdgeStackPayload
|
Payload updateEdgeStackPayload
|
||||||
|
@ -142,7 +140,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
|
||||||
"Update with non-existing EdgeGroupID",
|
"Update with non-existing EdgeGroupID",
|
||||||
updateEdgeStackPayload{
|
updateEdgeStackPayload{
|
||||||
StackFileContent: "error-test",
|
StackFileContent: "error-test",
|
||||||
Version: &newVersion,
|
UpdateVersion: true,
|
||||||
EdgeGroups: []portainer.EdgeGroupID{9999},
|
EdgeGroups: []portainer.EdgeGroupID{9999},
|
||||||
DeploymentType: edgeStack.DeploymentType,
|
DeploymentType: edgeStack.DeploymentType,
|
||||||
},
|
},
|
||||||
|
@ -152,7 +150,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
|
||||||
"Update with invalid EdgeGroup (non-existing Endpoint)",
|
"Update with invalid EdgeGroup (non-existing Endpoint)",
|
||||||
updateEdgeStackPayload{
|
updateEdgeStackPayload{
|
||||||
StackFileContent: "error-test",
|
StackFileContent: "error-test",
|
||||||
Version: &newVersion,
|
UpdateVersion: true,
|
||||||
EdgeGroups: []portainer.EdgeGroupID{2},
|
EdgeGroups: []portainer.EdgeGroupID{2},
|
||||||
DeploymentType: edgeStack.DeploymentType,
|
DeploymentType: edgeStack.DeploymentType,
|
||||||
},
|
},
|
||||||
|
@ -162,7 +160,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) {
|
||||||
"Update DeploymentType from Docker to Kubernetes",
|
"Update DeploymentType from Docker to Kubernetes",
|
||||||
updateEdgeStackPayload{
|
updateEdgeStackPayload{
|
||||||
StackFileContent: "error-test",
|
StackFileContent: "error-test",
|
||||||
Version: &newVersion,
|
UpdateVersion: true,
|
||||||
EdgeGroups: []portainer.EdgeGroupID{1},
|
EdgeGroups: []portainer.EdgeGroupID{1},
|
||||||
DeploymentType: portainer.EdgeStackDeploymentKubernetes,
|
DeploymentType: portainer.EdgeStackDeploymentKubernetes,
|
||||||
},
|
},
|
||||||
|
@ -200,7 +198,6 @@ func TestUpdateWithInvalidPayload(t *testing.T) {
|
||||||
endpoint := createEndpoint(t, handler.DataStore)
|
endpoint := createEndpoint(t, handler.DataStore)
|
||||||
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
|
edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID)
|
||||||
|
|
||||||
newVersion := 238
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
Name string
|
Name string
|
||||||
Payload updateEdgeStackPayload
|
Payload updateEdgeStackPayload
|
||||||
|
@ -210,7 +207,7 @@ func TestUpdateWithInvalidPayload(t *testing.T) {
|
||||||
"Update with empty StackFileContent",
|
"Update with empty StackFileContent",
|
||||||
updateEdgeStackPayload{
|
updateEdgeStackPayload{
|
||||||
StackFileContent: "",
|
StackFileContent: "",
|
||||||
Version: &newVersion,
|
UpdateVersion: true,
|
||||||
EdgeGroups: edgeStack.EdgeGroups,
|
EdgeGroups: edgeStack.EdgeGroups,
|
||||||
DeploymentType: edgeStack.DeploymentType,
|
DeploymentType: edgeStack.DeploymentType,
|
||||||
},
|
},
|
||||||
|
@ -220,7 +217,7 @@ func TestUpdateWithInvalidPayload(t *testing.T) {
|
||||||
"Update with empty EdgeGroups",
|
"Update with empty EdgeGroups",
|
||||||
updateEdgeStackPayload{
|
updateEdgeStackPayload{
|
||||||
StackFileContent: "error-test",
|
StackFileContent: "error-test",
|
||||||
Version: &newVersion,
|
UpdateVersion: true,
|
||||||
EdgeGroups: []portainer.EdgeGroupID{},
|
EdgeGroups: []portainer.EdgeGroupID{},
|
||||||
DeploymentType: edgeStack.DeploymentType,
|
DeploymentType: edgeStack.DeploymentType,
|
||||||
},
|
},
|
||||||
|
|
|
@ -25,6 +25,8 @@ type Handler struct {
|
||||||
KubernetesDeployer portainer.KubernetesDeployer
|
KubernetesDeployer portainer.KubernetesDeployer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const contextKey = "edgeStack_item"
|
||||||
|
|
||||||
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
// NewHandler creates a handler to manage environment(endpoint) group operations.
|
||||||
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
|
func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler {
|
||||||
h := &Handler{
|
h := &Handler{
|
||||||
|
|
|
@ -63,7 +63,7 @@ func validateUniqueName(edgeStacksGetter func() ([]portainer.EdgeStack, error),
|
||||||
|
|
||||||
for _, stack := range edgeStacks {
|
for _, stack := range edgeStacks {
|
||||||
if strings.EqualFold(stack.Name, name) {
|
if strings.EqualFold(stack.Name, name) {
|
||||||
return errors.New("Edge stack name must be unique")
|
return httperrors.NewConflictError("Edge stack name must be unique")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,13 +5,13 @@ import (
|
||||||
|
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
portainer "github.com/portainer/portainer/api"
|
portainer "github.com/portainer/portainer/api"
|
||||||
|
"github.com/portainer/portainer/api/git"
|
||||||
gittypes "github.com/portainer/portainer/api/git/types"
|
gittypes "github.com/portainer/portainer/api/git/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrStackAlreadyExists = errors.New("A stack already exists with this name")
|
ErrStackAlreadyExists = errors.New("A stack already exists with this name")
|
||||||
ErrWebhookIDAlreadyExists = errors.New("A webhook ID already exists")
|
ErrWebhookIDAlreadyExists = errors.New("A webhook ID already exists")
|
||||||
ErrInvalidGitCredential = errors.New("Invalid git credential")
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// DownloadGitRepository downloads the target git repository on the disk
|
// DownloadGitRepository downloads the target git repository on the disk
|
||||||
|
@ -28,7 +28,7 @@ func DownloadGitRepository(config gittypes.RepoConfig, gitService portainer.GitS
|
||||||
err := gitService.CloneRepository(projectPath, config.URL, config.ReferenceName, username, password, config.TLSSkipVerify)
|
err := gitService.CloneRepository(projectPath, config.URL, config.ReferenceName, username, password, config.TLSSkipVerify)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gittypes.ErrAuthenticationFailure) {
|
if errors.Is(err, gittypes.ErrAuthenticationFailure) {
|
||||||
newErr := ErrInvalidGitCredential
|
newErr := git.ErrInvalidGitCredential
|
||||||
return "", newErr
|
return "", newErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -169,15 +169,7 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- !upload -->
|
<!-- !upload -->
|
||||||
|
|
||||||
<div class="col-sm-12 form-section-title"> Edge Groups </div>
|
<edge-groups-selector ng-if="$ctrl.model.EdgeGroups" value="$ctrl.model.EdgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
|
||||||
<div class="form-group" ng-if="$ctrl.edgeGroups">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<edge-groups-selector ng-if="!$ctrl.noGroups" value="$ctrl.model.EdgeGroups" 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>
|
|
||||||
|
|
||||||
<div class="col-sm-12 form-section-title"> Target environments </div>
|
<div class="col-sm-12 form-section-title"> Target environments </div>
|
||||||
<!-- node-selection -->
|
<!-- node-selection -->
|
||||||
|
|
|
@ -6,11 +6,9 @@ import { cronMethodOptions } from '@/react/edge/edge-jobs/CreateView/cron-method
|
||||||
|
|
||||||
export class EdgeJobFormController {
|
export class EdgeJobFormController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
constructor($async, $scope, EdgeGroupService, Notifications) {
|
constructor($async, $scope) {
|
||||||
this.$scope = $scope;
|
this.$scope = $scope;
|
||||||
this.$async = $async;
|
this.$async = $async;
|
||||||
this.EdgeGroupService = EdgeGroupService;
|
|
||||||
this.Notifications = Notifications;
|
|
||||||
|
|
||||||
this.cronMethods = cronMethodOptions;
|
this.cronMethods = cronMethodOptions;
|
||||||
this.buildMethods = [editor, upload];
|
this.buildMethods = [editor, upload];
|
||||||
|
@ -127,18 +125,8 @@ export class EdgeJobFormController {
|
||||||
this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id);
|
this.model.Endpoints = _.filter(this.model.Endpoints, (id) => id !== endpoint.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getEdgeGroups() {
|
|
||||||
try {
|
|
||||||
this.edgeGroups = await this.EdgeGroupService.groups();
|
|
||||||
this.noGroups = this.edgeGroups.length === 0;
|
|
||||||
} catch (err) {
|
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve Edge groups');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$onInit() {
|
$onInit() {
|
||||||
this.onChangeModel(this.model);
|
this.onChangeModel(this.model);
|
||||||
this.getEdgeGroups();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,95 +0,0 @@
|
||||||
<form class="form-horizontal">
|
|
||||||
<edge-groups-selector value="$ctrl.model.EdgeGroups" items="$ctrl.edgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
|
|
||||||
|
|
||||||
<edge-stack-deployment-type-selector
|
|
||||||
allow-kube-to-select-compose="$ctrl.allowKubeToSelectCompose"
|
|
||||||
value="$ctrl.model.DeploymentType"
|
|
||||||
has-docker-endpoint="$ctrl.hasDockerEndpoint()"
|
|
||||||
has-kube-endpoint="$ctrl.hasKubeEndpoint()"
|
|
||||||
on-change="($ctrl.onChangeDeploymentType)"
|
|
||||||
read-only="$ctrl.state.readOnlyCompose"
|
|
||||||
></edge-stack-deployment-type-selector>
|
|
||||||
|
|
||||||
<div class="text-muted small flex gap-1" ng-show="!$ctrl.model.DeploymentType && $ctrl.hasKubeEndpoint()">
|
|
||||||
<pr-icon icon="'alert-circle'" mode="'warning'" class-name="'!mt-1'"></pr-icon>
|
|
||||||
<div>
|
|
||||||
<p>
|
|
||||||
Portainer no longer supports <a href="https://docs.docker.com/compose/compose-file/" target="_blank">docker-compose</a> format manifests for Kubernetes deployments, and we
|
|
||||||
have removed the <a href="https://kompose.io/" target="_blank">Kompose</a> conversion tool which enables this. The reason for this is because Kompose now poses a security
|
|
||||||
risk, since it has a number of Common Vulnerabilities and Exposures (CVEs).
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
>Unfortunately, while the Kompose project has a maintainer and is part of the CNCF, it is not being actively maintained. Releases are very infrequent and new pull requests
|
|
||||||
to the project (including ones we've submitted) are taking months to be merged, with new CVEs arising in the meantime.</p
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
We advise installing your own instance of Kompose in a sandbox environment, performing conversions of your Docker Compose files to Kubernetes manifests and using those
|
|
||||||
manifests to set up applications.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<web-editor-form
|
|
||||||
ng-if="$ctrl.model.DeploymentType === $ctrl.EditorType.Compose"
|
|
||||||
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)"
|
|
||||||
read-only="$ctrl.hasKubeEndpoint()"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div ng-if="$ctrl.model.DeploymentType === $ctrl.EditorType.Kubernetes">
|
|
||||||
<div class="form-group">
|
|
||||||
<div class="col-sm-12">
|
|
||||||
<por-switch-field
|
|
||||||
label="'Use namespace(s) specified from manifest'"
|
|
||||||
tooltip="'If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment'"
|
|
||||||
checked="$ctrl.formValues.UseManifestNamespaces"
|
|
||||||
on-change="($ctrl.onChangeUseManifestNamespaces)"
|
|
||||||
></por-switch-field>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<web-editor-form
|
|
||||||
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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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 !ml-0"
|
|
||||||
ng-disabled="$ctrl.actionInProgress || !$ctrl.isFormValid() || (!$ctrl.model.DeploymentType && $ctrl.hasKubeEndpoint())"
|
|
||||||
ng-click="$ctrl.submitAction()"
|
|
||||||
button-spinner="$ctrl.actionInProgress"
|
|
||||||
>
|
|
||||||
<span ng-hide="$ctrl.actionInProgress">Update the stack</span>
|
|
||||||
<span ng-show="$ctrl.actionInProgress">Update in progress...</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<!-- !actions -->
|
|
||||||
</form>
|
|
|
@ -1,116 +0,0 @@
|
||||||
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
|
|
||||||
import { EditorType } from '@/react/edge/edge-stacks/types';
|
|
||||||
import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils';
|
|
||||||
export class EditEdgeStackFormController {
|
|
||||||
/* @ngInject */
|
|
||||||
constructor($scope) {
|
|
||||||
this.$scope = $scope;
|
|
||||||
this.state = {
|
|
||||||
endpointTypes: [],
|
|
||||||
readOnlyCompose: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.fileContents = {
|
|
||||||
0: '',
|
|
||||||
1: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
this.EditorType = EditorType;
|
|
||||||
|
|
||||||
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);
|
|
||||||
this.onChangeUseManifestNamespaces = this.onChangeUseManifestNamespaces.bind(this);
|
|
||||||
this.selectValidDeploymentType = this.selectValidDeploymentType.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeUseManifestNamespaces(value) {
|
|
||||||
this.$scope.$evalAsync(() => {
|
|
||||||
this.model.UseManifestNamespaces = value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
hasKubeEndpoint() {
|
|
||||||
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment);
|
|
||||||
}
|
|
||||||
|
|
||||||
hasDockerEndpoint() {
|
|
||||||
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnDockerEnvironment);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeGroups(groups) {
|
|
||||||
return this.$scope.$evalAsync(() => {
|
|
||||||
this.model.EdgeGroups = groups;
|
|
||||||
this.setEnvironmentTypesInSelection(groups);
|
|
||||||
this.selectValidDeploymentType();
|
|
||||||
this.state.readOnlyCompose = this.hasKubeEndpoint();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
isFormValid() {
|
|
||||||
return this.model.EdgeGroups.length && this.model.StackFileContent && this.validateEndpointsForDeployment();
|
|
||||||
}
|
|
||||||
|
|
||||||
setEnvironmentTypesInSelection(groups) {
|
|
||||||
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
|
||||||
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
|
||||||
}
|
|
||||||
|
|
||||||
selectValidDeploymentType() {
|
|
||||||
const validTypes = getValidEditorTypes(this.state.endpointTypes, this.allowKubeToSelectCompose);
|
|
||||||
|
|
||||||
if (!validTypes.includes(this.model.DeploymentType)) {
|
|
||||||
this.onChangeDeploymentType(validTypes[0]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeKubeManifest(value) {
|
|
||||||
this.onChangeFileContent(1, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeComposeConfig(value) {
|
|
||||||
this.onChangeFileContent(0, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
onChangeDeploymentType(deploymentType) {
|
|
||||||
return this.$scope.$evalAsync(() => {
|
|
||||||
this.model.DeploymentType = deploymentType;
|
|
||||||
this.model.StackFileContent = this.fileContents[deploymentType];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
validateEndpointsForDeployment() {
|
|
||||||
return this.model.DeploymentType == 0 || !this.hasDockerEndpoint();
|
|
||||||
}
|
|
||||||
|
|
||||||
$onInit() {
|
|
||||||
this.setEnvironmentTypesInSelection(this.model.EdgeGroups);
|
|
||||||
this.fileContents[this.model.DeploymentType] = this.model.StackFileContent;
|
|
||||||
|
|
||||||
// allow kube to view compose if it's an existing kube compose stack
|
|
||||||
const initiallyContainsKubeEnv = this.hasKubeEndpoint();
|
|
||||||
const isComposeStack = this.model.DeploymentType === 0;
|
|
||||||
this.allowKubeToSelectCompose = initiallyContainsKubeEnv && isComposeStack;
|
|
||||||
this.state.readOnlyCompose = this.allowKubeToSelectCompose;
|
|
||||||
this.selectValidDeploymentType();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,15 +0,0 @@
|
||||||
import angular from 'angular';
|
|
||||||
|
|
||||||
import { EditEdgeStackFormController } from './editEdgeStackFormController';
|
|
||||||
|
|
||||||
angular.module('portainer.edge').component('editEdgeStackForm', {
|
|
||||||
templateUrl: './editEdgeStackForm.html',
|
|
||||||
controller: EditEdgeStackFormController,
|
|
||||||
bindings: {
|
|
||||||
model: '<',
|
|
||||||
actionInProgress: '<',
|
|
||||||
submitAction: '<',
|
|
||||||
edgeGroups: '<',
|
|
||||||
isEditorDirty: '=',
|
|
||||||
},
|
|
||||||
});
|
|
|
@ -7,6 +7,8 @@ import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInInt
|
||||||
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
|
import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm';
|
||||||
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
|
import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm';
|
||||||
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
|
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
|
||||||
|
import { EditEdgeStackForm } from '@/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm';
|
||||||
|
import { withCurrentUser } from '@/react-tools/withCurrentUser';
|
||||||
import { withUIRouter } from '@/react-tools/withUIRouter';
|
import { withUIRouter } from '@/react-tools/withUIRouter';
|
||||||
|
|
||||||
export const componentsModule = angular
|
export const componentsModule = angular
|
||||||
|
@ -60,6 +62,18 @@ export const componentsModule = angular
|
||||||
'onChange',
|
'onChange',
|
||||||
'hasDockerEndpoint',
|
'hasDockerEndpoint',
|
||||||
'hasKubeEndpoint',
|
'hasKubeEndpoint',
|
||||||
|
'hasNomadEndpoint',
|
||||||
|
'allowKubeToSelectCompose',
|
||||||
|
])
|
||||||
|
)
|
||||||
|
.component(
|
||||||
|
'editEdgeStackForm',
|
||||||
|
r2a(withUIRouter(withReactQuery(withCurrentUser(EditEdgeStackForm))), [
|
||||||
|
'edgeStack',
|
||||||
|
'fileContent',
|
||||||
|
'isSubmitting',
|
||||||
|
'onEditorChange',
|
||||||
|
'onSubmit',
|
||||||
'allowKubeToSelectCompose',
|
'allowKubeToSelectCompose',
|
||||||
])
|
])
|
||||||
).name;
|
).name;
|
||||||
|
|
|
@ -65,9 +65,5 @@ angular.module('portainer.edge').factory('EdgeStackService', function EdgeStackS
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
service.update = function update(stack) {
|
|
||||||
return EdgeStacks.update(stack).$promise;
|
|
||||||
};
|
|
||||||
|
|
||||||
return service;
|
return service;
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
import { EditorType } from '@/react/edge/edge-stacks/types';
|
import { EditorType } from '@/react/edge/edge-stacks/types';
|
||||||
import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models';
|
|
||||||
import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils';
|
import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils';
|
||||||
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
|
import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants';
|
||||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||||
|
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||||
|
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
export default class CreateEdgeStackViewController {
|
export default class CreateEdgeStackViewController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
|
@ -27,6 +28,7 @@ export default class CreateEdgeStackViewController {
|
||||||
};
|
};
|
||||||
|
|
||||||
this.EditorType = EditorType;
|
this.EditorType = EditorType;
|
||||||
|
this.EnvironmentType = EnvironmentType;
|
||||||
|
|
||||||
this.state = {
|
this.state = {
|
||||||
Method: 'editor',
|
Method: 'editor',
|
||||||
|
@ -36,6 +38,7 @@ export default class CreateEdgeStackViewController {
|
||||||
isEditorDirty: false,
|
isEditorDirty: false,
|
||||||
hasKubeEndpoint: false,
|
hasKubeEndpoint: false,
|
||||||
endpointTypes: [],
|
endpointTypes: [],
|
||||||
|
baseWebhookUrl: baseEdgeStackWebhookUrl(),
|
||||||
};
|
};
|
||||||
|
|
||||||
this.edgeGroups = null;
|
this.edgeGroups = null;
|
||||||
|
@ -49,8 +52,7 @@ export default class CreateEdgeStackViewController {
|
||||||
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.onChangeGroups = this.onChangeGroups.bind(this);
|
this.onChangeGroups = this.onChangeGroups.bind(this);
|
||||||
this.hasDockerEndpoint = this.hasDockerEndpoint.bind(this);
|
this.hasType = this.hasType.bind(this);
|
||||||
this.hasKubeEndpoint = this.hasKubeEndpoint.bind(this);
|
|
||||||
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -139,9 +141,11 @@ export default class CreateEdgeStackViewController {
|
||||||
}
|
}
|
||||||
|
|
||||||
checkIfEndpointTypes(groups) {
|
checkIfEndpointTypes(groups) {
|
||||||
|
return this.$scope.$evalAsync(() => {
|
||||||
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
||||||
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes);
|
||||||
this.selectValidDeploymentType();
|
this.selectValidDeploymentType();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
selectValidDeploymentType() {
|
selectValidDeploymentType() {
|
||||||
|
@ -152,12 +156,8 @@ export default class CreateEdgeStackViewController {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hasKubeEndpoint() {
|
hasType(envType) {
|
||||||
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment);
|
return this.state.endpointTypes.includes(envType);
|
||||||
}
|
|
||||||
|
|
||||||
hasDockerEndpoint() {
|
|
||||||
return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnDockerEnvironment);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
validateForm(method) {
|
validateForm(method) {
|
||||||
|
|
|
@ -39,12 +39,18 @@
|
||||||
</div>
|
</div>
|
||||||
<!-- !name-input -->
|
<!-- !name-input -->
|
||||||
|
|
||||||
<edge-groups-selector value="$ctrl.formValues.Groups" on-change="($ctrl.onChangeGroups)" items="$ctrl.edgeGroups"></edge-groups-selector>
|
<edge-groups-selector ng-if="$ctrl.formValues.Groups" value="$ctrl.formValues.Groups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
|
||||||
|
|
||||||
|
<p class="col-sm-12 vertical-center help-block small text-warning" ng-if="$ctrl.formValues.DeploymentType === undefined">
|
||||||
|
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> There are no available deployment types when there is more than one type of environment in your edge group
|
||||||
|
selection (e.g. Kubernetes and Docker environments). Please select edge groups that have environments of the same type.
|
||||||
|
</p>
|
||||||
|
|
||||||
<edge-stack-deployment-type-selector
|
<edge-stack-deployment-type-selector
|
||||||
value="$ctrl.formValues.DeploymentType"
|
value="$ctrl.formValues.DeploymentType"
|
||||||
has-docker-endpoint="$ctrl.hasDockerEndpoint()"
|
has-docker-endpoint="$ctrl.hasType($ctrl.EnvironmentType.EdgeAgentOnDocker)"
|
||||||
has-kube-endpoint="$ctrl.hasKubeEndpoint()"
|
has-kube-endpoint="$ctrl.hasType($ctrl.EnvironmentType.EdgeAgentOnKubernetes)"
|
||||||
|
has-nomad-endpoint="$ctrl.hasType($ctrl.EnvironmentType.EdgeAgentOnNomad)"
|
||||||
on-change="($ctrl.onChangeDeploymentType)"
|
on-change="($ctrl.onChangeDeploymentType)"
|
||||||
></edge-stack-deployment-type-selector>
|
></edge-stack-deployment-type-selector>
|
||||||
|
|
||||||
|
|
|
@ -21,7 +21,14 @@
|
||||||
<file-upload-description> You can upload a Compose file from your computer. </file-upload-description>
|
<file-upload-description> You can upload a Compose file from your computer. </file-upload-description>
|
||||||
</file-upload-form>
|
</file-upload-form>
|
||||||
|
|
||||||
<git-form ng-if="$ctrl.state.Method === 'repository'" value="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
|
<git-form
|
||||||
|
ng-if="$ctrl.state.Method === 'repository'"
|
||||||
|
value="$ctrl.formValues"
|
||||||
|
on-change="($ctrl.onChangeFormValues)"
|
||||||
|
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
|
||||||
|
webhook-id="{{ $ctrl.state.webhookId }}"
|
||||||
|
docs-links
|
||||||
|
></git-form>
|
||||||
|
|
||||||
<!-- template -->
|
<!-- template -->
|
||||||
<div ng-if="$ctrl.state.Method === 'template'">
|
<div ng-if="$ctrl.state.Method === 'template'">
|
||||||
|
|
|
@ -32,4 +32,11 @@
|
||||||
</file-upload-description>
|
</file-upload-description>
|
||||||
</file-upload-form>
|
</file-upload-form>
|
||||||
|
|
||||||
<git-form ng-if="$ctrl.state.Method === 'repository'" deploy-method="kubernetes" value="$ctrl.formValues" on-change="($ctrl.onChangeFormValues)"></git-form>
|
<git-form
|
||||||
|
ng-if="$ctrl.state.Method === 'repository'"
|
||||||
|
deploy-method="kubernetes"
|
||||||
|
value="$ctrl.formValues"
|
||||||
|
on-change="($ctrl.onChangeFormValues)"
|
||||||
|
base-webhook-url="{{ $ctrl.state.baseWebhookUrl }}"
|
||||||
|
webhook-id="{{ $ctrl.state.webhookId }}"
|
||||||
|
></git-form>
|
||||||
|
|
|
@ -12,11 +12,14 @@
|
||||||
|
|
||||||
<div style="padding: 20px">
|
<div style="padding: 20px">
|
||||||
<edit-edge-stack-form
|
<edit-edge-stack-form
|
||||||
|
ng-if="$ctrl.edgeGroups && $ctrl.stack && $ctrl.formValues.content"
|
||||||
edge-groups="$ctrl.edgeGroups"
|
edge-groups="$ctrl.edgeGroups"
|
||||||
model="$ctrl.formValues"
|
edge-stack="$ctrl.stack"
|
||||||
action-in-progress="$ctrl.state.actionInProgress"
|
is-submitting="$ctrl.state.actionInProgress"
|
||||||
submit-action="$ctrl.deployStack"
|
on-submit="($ctrl.deployStack)"
|
||||||
is-editor-dirty="$ctrl.state.isEditorDirty"
|
on-editor-change="($ctrl.onEditorChange)"
|
||||||
|
file-content="$ctrl.formValues.content"
|
||||||
|
allow-kube-to-select-compose="$ctrl.allowKubeToSelectCompose"
|
||||||
></edit-edge-stack-form>
|
></edit-edge-stack-form>
|
||||||
</div>
|
</div>
|
||||||
</uib-tab>
|
</uib-tab>
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import _ from 'lodash-es';
|
import _ from 'lodash-es';
|
||||||
import { getEnvironments } from '@/react/portainer/environments/environment.service';
|
import { getEnvironments } from '@/react/portainer/environments/environment.service';
|
||||||
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
import { confirmWebEditorDiscard } from '@@/modals/confirm';
|
||||||
|
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||||
|
import { createWebhookId } from '@/portainer/helpers/webhookHelper';
|
||||||
|
|
||||||
export class EditEdgeStackViewController {
|
export class EditEdgeStackViewController {
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
|
@ -18,56 +20,74 @@ export class EditEdgeStackViewController {
|
||||||
this.state = {
|
this.state = {
|
||||||
actionInProgress: false,
|
actionInProgress: false,
|
||||||
activeTab: 0,
|
activeTab: 0,
|
||||||
isEditorDirty: false,
|
isStackDeployed: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.formValues = {
|
||||||
|
content: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
this.deployStack = this.deployStack.bind(this);
|
this.deployStack = this.deployStack.bind(this);
|
||||||
this.deployStackAsync = this.deployStackAsync.bind(this);
|
this.deployStackAsync = this.deployStackAsync.bind(this);
|
||||||
this.getPaginatedEndpoints = this.getPaginatedEndpoints.bind(this);
|
this.getPaginatedEndpoints = this.getPaginatedEndpoints.bind(this);
|
||||||
this.getPaginatedEndpointsAsync = this.getPaginatedEndpointsAsync.bind(this);
|
this.getPaginatedEndpointsAsync = this.getPaginatedEndpointsAsync.bind(this);
|
||||||
|
this.onEditorChange = this.onEditorChange.bind(this);
|
||||||
|
this.isEditorDirty = this.isEditorDirty.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
async $onInit() {
|
async $onInit() {
|
||||||
|
return this.$async(async () => {
|
||||||
const { stackId, tab } = this.$state.params;
|
const { stackId, tab } = this.$state.params;
|
||||||
this.state.activeTab = tab;
|
this.state.activeTab = tab;
|
||||||
try {
|
try {
|
||||||
const [edgeGroups, model, file] = await Promise.all([this.EdgeGroupService.groups(), this.EdgeStackService.stack(stackId), this.EdgeStackService.stackFile(stackId)]);
|
const [edgeGroups, model, file] = await Promise.all([this.EdgeGroupService.groups(), this.EdgeStackService.stack(stackId), this.EdgeStackService.stackFile(stackId)]);
|
||||||
|
|
||||||
this.edgeGroups = edgeGroups;
|
this.edgeGroups = edgeGroups;
|
||||||
this.stack = model;
|
this.stack = model;
|
||||||
this.stackEndpointIds = this.filterStackEndpoints(model.EdgeGroups, edgeGroups);
|
this.stackEndpointIds = this.filterStackEndpoints(model.EdgeGroups, edgeGroups);
|
||||||
this.originalFileContent = file;
|
this.originalFileContent = file;
|
||||||
this.formValues = {
|
this.formValues = {
|
||||||
StackFileContent: file,
|
content: file,
|
||||||
EdgeGroups: this.stack.EdgeGroups,
|
|
||||||
UseManifestNamespaces: this.stack.UseManifestNamespaces,
|
|
||||||
DeploymentType: this.stack.DeploymentType,
|
|
||||||
};
|
};
|
||||||
this.oldFileContent = this.formValues.StackFileContent;
|
|
||||||
|
const stackEdgeGroups = model.EdgeGroups.map((id) => this.edgeGroups.find((e) => e.Id === id));
|
||||||
|
const endpointTypes = stackEdgeGroups.flatMap((group) => group.EndpointTypes);
|
||||||
|
const initiallyContainsKubeEnv = endpointTypes.includes(EnvironmentType.EdgeAgentOnKubernetes);
|
||||||
|
const isComposeStack = this.stack.DeploymentType === 0;
|
||||||
|
|
||||||
|
this.allowKubeToSelectCompose = initiallyContainsKubeEnv && isComposeStack;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.Notifications.error('Failure', err, 'Unable to retrieve stack data');
|
this.Notifications.error('Failure', err, 'Unable to retrieve stack data');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.oldFileContent = this.formValues.StackFileContent;
|
||||||
|
|
||||||
this.$window.onbeforeunload = () => {
|
this.$window.onbeforeunload = () => {
|
||||||
if (this.formValues.StackFileContent !== this.oldFileContent && this.state.isEditorDirty) {
|
if (this.isEditorDirty()) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
$onDestroy() {
|
$onDestroy() {
|
||||||
this.state.isEditorDirty = false;
|
this.$window.onbeforeunload = undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async uiCanExit() {
|
async uiCanExit() {
|
||||||
if (
|
if (this.isEditorDirty()) {
|
||||||
this.formValues.StackFileContent &&
|
|
||||||
this.formValues.StackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== this.oldFileContent.replace(/(\r\n|\n|\r)/gm, '') &&
|
|
||||||
this.state.isEditorDirty
|
|
||||||
) {
|
|
||||||
return confirmWebEditorDiscard();
|
return confirmWebEditorDiscard();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onEditorChange(content) {
|
||||||
|
this.formValues.content = content;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEditorDirty() {
|
||||||
|
return !this.state.isStackDeployed && this.formValues.content.replace(/(\r\n|\n|\r)/gm, '') !== this.originalFileContent.replace(/(\r\n|\n|\r)/gm, '');
|
||||||
|
}
|
||||||
|
|
||||||
filterStackEndpoints(groupIds, groups) {
|
filterStackEndpoints(groupIds, groups) {
|
||||||
return _.flatten(
|
return _.flatten(
|
||||||
_.map(groupIds, (Id) => {
|
_.map(groupIds, (Id) => {
|
||||||
|
@ -77,19 +97,24 @@ export class EditEdgeStackViewController {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
deployStack() {
|
deployStack(values) {
|
||||||
return this.$async(this.deployStackAsync);
|
return this.deployStackAsync(values);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deployStackAsync() {
|
async deployStackAsync(values) {
|
||||||
this.state.actionInProgress = true;
|
this.state.actionInProgress = true;
|
||||||
try {
|
try {
|
||||||
if (this.originalFileContent != this.formValues.StackFileContent || this.formValues.UseManifestNamespaces !== this.stack.UseManifestNamespaces) {
|
const updateVersion = !!(this.originalFileContent != values.content || values.useManifestNamespaces !== this.stack.UseManifestNamespaces);
|
||||||
this.formValues.Version = this.stack.Version + 1;
|
|
||||||
}
|
await this.EdgeStackService.updateStack(this.stack.Id, {
|
||||||
await this.EdgeStackService.updateStack(this.stack.Id, this.formValues);
|
stackFileContent: values.content,
|
||||||
|
edgeGroups: values.edgeGroups,
|
||||||
|
deploymentType: values.deploymentType,
|
||||||
|
updateVersion,
|
||||||
|
webhook: values.webhookEnabled ? this.stack.Webhook || createWebhookId() : '',
|
||||||
|
});
|
||||||
this.Notifications.success('Success', 'Stack successfully deployed');
|
this.Notifications.success('Success', 'Stack successfully deployed');
|
||||||
this.state.isEditorDirty = false;
|
this.state.isStackDeployed = true;
|
||||||
this.$state.go('edge.stacks');
|
this.$state.go('edge.stacks');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.Notifications.error('Deployment error', err, 'Unable to deploy stack');
|
this.Notifications.error('Deployment error', err, 'Unable to deploy stack');
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
import uuid from 'uuid';
|
import uuid from 'uuid';
|
||||||
|
|
||||||
import { API_ENDPOINT_STACKS, API_ENDPOINT_WEBHOOKS } from '@/constants';
|
import {
|
||||||
|
API_ENDPOINT_EDGE_STACKS,
|
||||||
|
API_ENDPOINT_STACKS,
|
||||||
|
API_ENDPOINT_WEBHOOKS,
|
||||||
|
} from '@/constants';
|
||||||
|
|
||||||
import { baseHref } from './pathHelper';
|
import { baseHref } from './pathHelper';
|
||||||
|
|
||||||
|
@ -22,6 +26,10 @@ export function createWebhookId() {
|
||||||
return uuid();
|
return uuid();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function baseEdgeStackWebhookUrl() {
|
||||||
|
return `${baseUrl}${API_ENDPOINT_EDGE_STACKS}/webhooks`;
|
||||||
|
}
|
||||||
|
|
||||||
/* @ngInject */
|
/* @ngInject */
|
||||||
export function WebhookHelperFactory() {
|
export function WebhookHelperFactory() {
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -130,6 +130,10 @@ angular.module('portainer.app').factory('Authentication', [
|
||||||
return !!user && user.role === 1;
|
return !!user && user.role === 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
window.login = loginAsync;
|
||||||
|
}
|
||||||
|
|
||||||
return service;
|
return service;
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
@ -4,7 +4,7 @@ import clsx from 'clsx';
|
||||||
|
|
||||||
import { Icon, IconMode } from '@@/Icon';
|
import { Icon, IconMode } from '@@/Icon';
|
||||||
|
|
||||||
type Color = 'orange' | 'blue';
|
type Color = 'orange' | 'blue' | 'red' | 'green';
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
icon?: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
|
@ -33,6 +33,10 @@ function getMode(color: Color): IconMode {
|
||||||
switch (color) {
|
switch (color) {
|
||||||
case 'blue':
|
case 'blue':
|
||||||
return 'primary';
|
return 'primary';
|
||||||
|
case 'red':
|
||||||
|
return 'danger';
|
||||||
|
case 'green':
|
||||||
|
return 'success';
|
||||||
case 'orange':
|
case 'orange':
|
||||||
default:
|
default:
|
||||||
return 'warning';
|
return 'warning';
|
||||||
|
|
|
@ -0,0 +1,111 @@
|
||||||
|
import { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
import { BROWSER_OS_PLATFORM } from '@/react/constants';
|
||||||
|
|
||||||
|
import { CodeEditor } from '@@/CodeEditor';
|
||||||
|
import { Tooltip } from '@@/Tip/Tooltip';
|
||||||
|
|
||||||
|
import { FormSectionTitle } from './form-components/FormSectionTitle';
|
||||||
|
|
||||||
|
const otherEditorConfig = {
|
||||||
|
tooltip: (
|
||||||
|
<>
|
||||||
|
<div>Ctrl+F - Start searching</div>
|
||||||
|
<div>Ctrl+G - Find next</div>
|
||||||
|
<div>Ctrl+Shift+G - Find previous</div>
|
||||||
|
<div>Ctrl+Shift+F - Replace</div>
|
||||||
|
<div>Ctrl+Shift+R - Replace all</div>
|
||||||
|
<div>Alt+G - Jump to line</div>
|
||||||
|
<div>Persistent search:</div>
|
||||||
|
<div className="ml-5">Enter - Find next</div>
|
||||||
|
<div className="ml-5">Shift+Enter - Find previous</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
searchCmdLabel: 'Ctrl+F for search',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const editorConfig = {
|
||||||
|
mac: {
|
||||||
|
tooltip: (
|
||||||
|
<>
|
||||||
|
<div>Cmd+F - Start searching</div>
|
||||||
|
<div>Cmd+G - Find next</div>
|
||||||
|
<div>Cmd+Shift+G - Find previous</div>
|
||||||
|
<div>Cmd+Option+F - Replace</div>
|
||||||
|
<div>Cmd+Option+R - Replace all</div>
|
||||||
|
<div>Option+G - Jump to line</div>
|
||||||
|
<div>Persistent search:</div>
|
||||||
|
<div className="ml-5">Enter - Find next</div>
|
||||||
|
<div className="ml-5">Shift+Enter - Find previous</div>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
searchCmdLabel: 'Cmd+F for search',
|
||||||
|
},
|
||||||
|
|
||||||
|
lin: otherEditorConfig,
|
||||||
|
win: otherEditorConfig,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
|
||||||
|
id: string;
|
||||||
|
placeholder?: string;
|
||||||
|
yaml?: boolean;
|
||||||
|
readonly?: boolean;
|
||||||
|
hideTitle?: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WebEditorForm({
|
||||||
|
id,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
value,
|
||||||
|
hideTitle,
|
||||||
|
readonly,
|
||||||
|
yaml,
|
||||||
|
children,
|
||||||
|
error,
|
||||||
|
}: PropsWithChildren<Props>) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="web-editor overflow-x-hidden">
|
||||||
|
{!hideTitle && (
|
||||||
|
<FormSectionTitle htmlFor={id}>
|
||||||
|
Web editor
|
||||||
|
<div className="text-muted small vertical-center ml-auto">
|
||||||
|
{editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel}
|
||||||
|
|
||||||
|
<Tooltip message={editorConfig[BROWSER_OS_PLATFORM].tooltip} />
|
||||||
|
</div>
|
||||||
|
</FormSectionTitle>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{children && (
|
||||||
|
<div className="form-group text-muted small">
|
||||||
|
<div className="col-sm-12 col-lg-12">{children}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12 col-lg-12">
|
||||||
|
<CodeEditor
|
||||||
|
id={id}
|
||||||
|
placeholder={placeholder}
|
||||||
|
readonly={readonly}
|
||||||
|
yaml={yaml}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12 col-lg-12">{error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { Registry } from '@/react/portainer/environments/environment.service/registries';
|
import { Registry } from '@/react/portainer/registries/types';
|
||||||
|
|
||||||
import { Modal, OnSubmit, openModal } from '@@/modals';
|
import { Modal, OnSubmit, openModal } from '@@/modals';
|
||||||
import { Button } from '@@/buttons';
|
import { Button } from '@@/buttons';
|
||||||
|
|
|
@ -0,0 +1,79 @@
|
||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { WebEditorForm } from '@@/WebEditorForm';
|
||||||
|
|
||||||
|
import { DeploymentType } from '../../types';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
|
||||||
|
export function ComposeForm({
|
||||||
|
handleContentChange,
|
||||||
|
hasKubeEndpoint,
|
||||||
|
}: {
|
||||||
|
hasKubeEndpoint: boolean;
|
||||||
|
handleContentChange: (type: DeploymentType, content: string) => void;
|
||||||
|
}) {
|
||||||
|
const { errors, values } = useFormikContext<FormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{hasKubeEndpoint && (
|
||||||
|
<TextTip>
|
||||||
|
<p>
|
||||||
|
Portainer no longer supports{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.docker.com/compose/compose-file/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
docker-compose
|
||||||
|
</a>{' '}
|
||||||
|
format manifests for Kubernetes deployments, and we have removed the{' '}
|
||||||
|
<a href="https://kompose.io/" target="_blank" rel="noreferrer">
|
||||||
|
Kompose
|
||||||
|
</a>{' '}
|
||||||
|
conversion tool which enables this. The reason for this is because
|
||||||
|
Kompose now poses a security risk, since it has a number of Common
|
||||||
|
Vulnerabilities and Exposures (CVEs).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Unfortunately, while the Kompose project has a maintainer and is
|
||||||
|
part of the CNCF, it is not being actively maintained. Releases are
|
||||||
|
very infrequent and new pull requests to the project (including ones
|
||||||
|
we've submitted) are taking months to be merged, with new CVEs
|
||||||
|
arising in the meantime.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We advise installing your own instance of Kompose in a sandbox
|
||||||
|
environment, performing conversions of your Docker Compose files to
|
||||||
|
Kubernetes manifests and using those manifests to set up
|
||||||
|
applications.
|
||||||
|
</p>
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<WebEditorForm
|
||||||
|
value={values.content}
|
||||||
|
yaml
|
||||||
|
id="compose-editor"
|
||||||
|
placeholder="Define or paste the content of your docker compose file here"
|
||||||
|
onChange={(value) => handleContentChange(DeploymentType.Compose, value)}
|
||||||
|
error={errors.content}
|
||||||
|
readonly={hasKubeEndpoint}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
You can get more information about Compose file format in the{' '}
|
||||||
|
<a
|
||||||
|
href="https://docs.docker.com/compose/compose-file/"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
official documentation
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</WebEditorForm>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,267 @@
|
||||||
|
import { Form, Formik, useFormikContext } from 'formik';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { array, boolean, number, object, SchemaOf, string } from 'yup';
|
||||||
|
|
||||||
|
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
|
||||||
|
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
|
||||||
|
import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
|
||||||
|
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||||
|
import { WebhookSettings } from '@/react/portainer/gitops/AutoUpdateFieldset/WebhookSettings';
|
||||||
|
import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||||
|
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||||
|
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
|
||||||
|
import { PrivateRegistryFieldsetWrapper } from './PrivateRegistryFieldsetWrapper';
|
||||||
|
import { FormValues } from './types';
|
||||||
|
import { ComposeForm } from './ComposeForm';
|
||||||
|
import { KubernetesForm } from './KubernetesForm';
|
||||||
|
import { NomadForm } from './NomadForm';
|
||||||
|
import { GitForm } from './GitForm';
|
||||||
|
import { useValidateEnvironmentTypes } from './useEdgeGroupHasType';
|
||||||
|
import { atLeastTwo } from './atLeastTwo';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
edgeStack: EdgeStack;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onSubmit: (values: FormValues) => void;
|
||||||
|
onEditorChange: (content: string) => void;
|
||||||
|
fileContent: string;
|
||||||
|
allowKubeToSelectCompose: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forms = {
|
||||||
|
[DeploymentType.Compose]: ComposeForm,
|
||||||
|
[DeploymentType.Kubernetes]: KubernetesForm,
|
||||||
|
[DeploymentType.Nomad]: NomadForm,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function EditEdgeStackForm({
|
||||||
|
isSubmitting,
|
||||||
|
edgeStack,
|
||||||
|
onSubmit,
|
||||||
|
onEditorChange,
|
||||||
|
fileContent,
|
||||||
|
allowKubeToSelectCompose,
|
||||||
|
}: Props) {
|
||||||
|
if (edgeStack.GitConfig) {
|
||||||
|
return <GitForm stack={edgeStack} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formValues: FormValues = {
|
||||||
|
edgeGroups: edgeStack.EdgeGroups,
|
||||||
|
deploymentType: edgeStack.DeploymentType,
|
||||||
|
privateRegistryId: edgeStack.Registries?.[0],
|
||||||
|
content: fileContent,
|
||||||
|
useManifestNamespaces: edgeStack.UseManifestNamespaces,
|
||||||
|
prePullImage: edgeStack.PrePullImage,
|
||||||
|
retryDeploy: edgeStack.RetryDeploy,
|
||||||
|
webhookEnabled: !!edgeStack.Webhook,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik
|
||||||
|
initialValues={formValues}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
validationSchema={formValidation()}
|
||||||
|
>
|
||||||
|
<InnerForm
|
||||||
|
edgeStack={edgeStack}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
onEditorChange={onEditorChange}
|
||||||
|
allowKubeToSelectCompose={allowKubeToSelectCompose}
|
||||||
|
/>
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InnerForm({
|
||||||
|
onEditorChange,
|
||||||
|
edgeStack,
|
||||||
|
isSubmitting,
|
||||||
|
allowKubeToSelectCompose,
|
||||||
|
}: {
|
||||||
|
edgeStack: EdgeStack;
|
||||||
|
isSubmitting: boolean;
|
||||||
|
onEditorChange: (content: string) => void;
|
||||||
|
allowKubeToSelectCompose: boolean;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
values,
|
||||||
|
setFieldValue,
|
||||||
|
isValid,
|
||||||
|
|
||||||
|
errors,
|
||||||
|
setFieldError,
|
||||||
|
} = useFormikContext<FormValues>();
|
||||||
|
const { getCachedContent, setContentCache } = useCachedContent();
|
||||||
|
const { hasType } = useValidateEnvironmentTypes(values.edgeGroups);
|
||||||
|
|
||||||
|
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
|
||||||
|
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
|
||||||
|
const hasNomadEndpoint = hasType(EnvironmentType.EdgeAgentOnNomad);
|
||||||
|
|
||||||
|
const DeploymentForm = forms[values.deploymentType];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form className="form-horizontal">
|
||||||
|
<EdgeGroupsSelector
|
||||||
|
value={values.edgeGroups}
|
||||||
|
onChange={(value) => setFieldValue('edgeGroups', value)}
|
||||||
|
error={errors.edgeGroups}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{atLeastTwo(hasKubeEndpoint, hasDockerEndpoint, hasNomadEndpoint) && (
|
||||||
|
<TextTip>
|
||||||
|
There are no available deployment types when there is more than one
|
||||||
|
type of environment in your edge group selection (e.g. Kubernetes and
|
||||||
|
Docker environments). Please select edge groups that have environments
|
||||||
|
of the same type.
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && (
|
||||||
|
<FormError>
|
||||||
|
Edge groups with kubernetes environments no longer support compose
|
||||||
|
deployment types in Portainer. Please select edge groups that only
|
||||||
|
have docker environments when using compose deployment types.
|
||||||
|
</FormError>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EdgeStackDeploymentTypeSelector
|
||||||
|
allowKubeToSelectCompose={allowKubeToSelectCompose}
|
||||||
|
value={values.deploymentType}
|
||||||
|
hasDockerEndpoint={hasType(EnvironmentType.EdgeAgentOnDocker)}
|
||||||
|
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
|
||||||
|
hasNomadEndpoint={hasType(EnvironmentType.EdgeAgentOnNomad)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFieldValue('content', getCachedContent(value));
|
||||||
|
setFieldValue('deploymentType', value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeploymentForm
|
||||||
|
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
|
||||||
|
handleContentChange={handleContentChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{isBE && (
|
||||||
|
<>
|
||||||
|
<FormSection title="Webhooks">
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
label="Create an Edge stack webhook"
|
||||||
|
checked={values.webhookEnabled}
|
||||||
|
labelClass="col-sm-3 col-lg-2"
|
||||||
|
onChange={(value) => setFieldValue('webhookEnabled', value)}
|
||||||
|
tooltip="Create a webhook (or callback URI) to automate the update of this stack. Sending a POST request to this callback URI (without requiring any authentication) will pull the most up-to-date version of the associated image and re-deploy this stack."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{edgeStack.Webhook && (
|
||||||
|
<WebhookSettings
|
||||||
|
baseUrl={baseEdgeStackWebhookUrl()}
|
||||||
|
value={edgeStack.Webhook}
|
||||||
|
docsLink="todo"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormSection>
|
||||||
|
<PrivateRegistryFieldsetWrapper
|
||||||
|
value={values.privateRegistryId}
|
||||||
|
onChange={(value) => setFieldValue('privateRegistryId', value)}
|
||||||
|
isValid={isValid}
|
||||||
|
values={values}
|
||||||
|
stackName={edgeStack.Name}
|
||||||
|
onFieldError={(error) => setFieldError('privateRegistryId', error)}
|
||||||
|
error={errors.privateRegistryId}
|
||||||
|
/>
|
||||||
|
{values.deploymentType === DeploymentType.Compose && (
|
||||||
|
<>
|
||||||
|
<SwitchField
|
||||||
|
checked={values.prePullImage}
|
||||||
|
name="prePullImage"
|
||||||
|
label="Pre-pull images"
|
||||||
|
tooltip="When enabled, redeployment will be executed when image(s) is pulled successfully"
|
||||||
|
label-Class="col-sm-3 col-lg-2"
|
||||||
|
onChange={(value) => setFieldValue('prePullImage', value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SwitchField
|
||||||
|
checked={values.retryDeploy}
|
||||||
|
name="retryDeploy"
|
||||||
|
label="Retry deployment"
|
||||||
|
tooltip="When enabled, this will allow edge agent keep retrying deployment if failure occur"
|
||||||
|
label-Class="col-sm-3 col-lg-2"
|
||||||
|
onChange={(value) => setFieldValue('retryDeploy', value)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormSection title="Actions">
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<LoadingButton
|
||||||
|
className="!ml-0"
|
||||||
|
size="small"
|
||||||
|
disabled={!isValid}
|
||||||
|
isLoading={isSubmitting}
|
||||||
|
button-spinner="$ctrl.actionInProgress"
|
||||||
|
loadingText="Update in progress..."
|
||||||
|
>
|
||||||
|
Update the stack
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</FormSection>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handleContentChange(type: DeploymentType, content: string) {
|
||||||
|
setFieldValue('content', content);
|
||||||
|
setContentCache(type, content);
|
||||||
|
onEditorChange(content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function useCachedContent() {
|
||||||
|
const [cachedContent, setCachedContent] = useState({
|
||||||
|
[DeploymentType.Compose]: '',
|
||||||
|
[DeploymentType.Kubernetes]: '',
|
||||||
|
[DeploymentType.Nomad]: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleChangeContent(type: DeploymentType, content: string) {
|
||||||
|
setCachedContent((cache) => ({ ...cache, [type]: content }));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
setContentCache: handleChangeContent,
|
||||||
|
getCachedContent: (type: DeploymentType) => cachedContent[type],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formValidation(): SchemaOf<FormValues> {
|
||||||
|
return object({
|
||||||
|
content: string().required('Content is required'),
|
||||||
|
deploymentType: number()
|
||||||
|
.oneOf([0, 1, 2])
|
||||||
|
.required('Deployment type is required'),
|
||||||
|
privateRegistryId: number().optional(),
|
||||||
|
prePullImage: boolean().default(false),
|
||||||
|
retryDeploy: boolean().default(false),
|
||||||
|
useManifestNamespaces: boolean().default(false),
|
||||||
|
edgeGroups: array()
|
||||||
|
.of(number().required())
|
||||||
|
.required()
|
||||||
|
.min(1, 'At least one edge group is required'),
|
||||||
|
webhookEnabled: boolean().default(false),
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,279 @@
|
||||||
|
import { Form, Formik, useFormikContext } from 'formik';
|
||||||
|
import { useRouter } from '@uirouter/react';
|
||||||
|
|
||||||
|
import { AuthFieldset } from '@/react/portainer/gitops/AuthFieldset';
|
||||||
|
import { AutoUpdateFieldset } from '@/react/portainer/gitops/AutoUpdateFieldset';
|
||||||
|
import {
|
||||||
|
parseAutoUpdateResponse,
|
||||||
|
transformAutoUpdateViewModel,
|
||||||
|
} from '@/react/portainer/gitops/AutoUpdateFieldset/utils';
|
||||||
|
import { InfoPanel } from '@/react/portainer/gitops/InfoPanel';
|
||||||
|
import { RefField } from '@/react/portainer/gitops/RefField';
|
||||||
|
import { AutoUpdateModel, GitAuthModel } from '@/react/portainer/gitops/types';
|
||||||
|
import {
|
||||||
|
baseEdgeStackWebhookUrl,
|
||||||
|
createWebhookId,
|
||||||
|
} from '@/portainer/helpers/webhookHelper';
|
||||||
|
import {
|
||||||
|
parseAuthResponse,
|
||||||
|
transformGitAuthenticationViewModel,
|
||||||
|
} from '@/react/portainer/gitops/AuthFieldset/utils';
|
||||||
|
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
|
import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
|
||||||
|
import { EdgeGroupsSelector } from '@/react/edge/edge-stacks/components/EdgeGroupsSelector';
|
||||||
|
import { EdgeStackDeploymentTypeSelector } from '@/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector';
|
||||||
|
import { useCurrentUser } from '@/react/hooks/useUser';
|
||||||
|
import { useCreateGitCredentialMutation } from '@/react/portainer/account/git-credentials/git-credentials.service';
|
||||||
|
import { notifyError, notifySuccess } from '@/portainer/services/notifications';
|
||||||
|
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { LoadingButton } from '@@/buttons';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
|
||||||
|
import { useValidateEnvironmentTypes } from '../useEdgeGroupHasType';
|
||||||
|
import { atLeastTwo } from '../atLeastTwo';
|
||||||
|
|
||||||
|
import { useUpdateEdgeStackGitMutation } from './useUpdateEdgeStackGitMutation';
|
||||||
|
|
||||||
|
interface FormValues {
|
||||||
|
groupIds: EdgeGroup['Id'][];
|
||||||
|
deploymentType: DeploymentType;
|
||||||
|
autoUpdate: AutoUpdateModel;
|
||||||
|
refName: string;
|
||||||
|
authentication: GitAuthModel;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GitForm({ stack }: { stack: EdgeStack }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const updateStackMutation = useUpdateEdgeStackGitMutation();
|
||||||
|
const saveCredentialsMutation = useCreateGitCredentialMutation();
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
|
||||||
|
if (!stack.GitConfig) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitConfig = stack.GitConfig;
|
||||||
|
|
||||||
|
const initialValues: FormValues = {
|
||||||
|
groupIds: stack.EdgeGroups,
|
||||||
|
deploymentType: stack.DeploymentType,
|
||||||
|
autoUpdate: parseAutoUpdateResponse(stack.AutoUpdate),
|
||||||
|
refName: stack.GitConfig.ReferenceName,
|
||||||
|
authentication: parseAuthResponse(stack.GitConfig.Authentication),
|
||||||
|
};
|
||||||
|
|
||||||
|
const webhookId = stack.AutoUpdate?.Webhook || createWebhookId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Formik initialValues={initialValues} onSubmit={handleSubmit}>
|
||||||
|
{({ values, isValid }) => {
|
||||||
|
return (
|
||||||
|
<InnerForm
|
||||||
|
webhookId={webhookId}
|
||||||
|
onUpdateSettingsClick={handleUpdateSettings}
|
||||||
|
gitPath={gitConfig.ConfigFilePath}
|
||||||
|
gitUrl={gitConfig.URL}
|
||||||
|
isLoading={updateStackMutation.isLoading}
|
||||||
|
isUpdateVersion={!!updateStackMutation.variables?.updateVersion}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleUpdateSettings() {
|
||||||
|
if (!isValid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const credentialId = await saveCredentialsIfRequired(
|
||||||
|
values.authentication
|
||||||
|
);
|
||||||
|
|
||||||
|
updateStackMutation.mutate(getPayload(values, credentialId, false), {
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Success', 'Stack updated successfully');
|
||||||
|
router.stateService.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</Formik>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function handleSubmit(values: FormValues) {
|
||||||
|
const credentialId = await saveCredentialsIfRequired(values.authentication);
|
||||||
|
|
||||||
|
updateStackMutation.mutate(getPayload(values, credentialId, true), {
|
||||||
|
onSuccess() {
|
||||||
|
notifySuccess('Success', 'Stack updated successfully');
|
||||||
|
router.stateService.reload();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPayload(
|
||||||
|
{ authentication, autoUpdate, ...values }: FormValues,
|
||||||
|
credentialId: number | undefined,
|
||||||
|
updateVersion: boolean
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
updateVersion,
|
||||||
|
id: stack.Id,
|
||||||
|
authentication: transformGitAuthenticationViewModel({
|
||||||
|
...authentication,
|
||||||
|
RepositoryGitCredentialID: credentialId,
|
||||||
|
}),
|
||||||
|
autoUpdate: transformAutoUpdateViewModel(autoUpdate, webhookId),
|
||||||
|
...values,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveCredentialsIfRequired(authentication: GitAuthModel) {
|
||||||
|
if (
|
||||||
|
!authentication.SaveCredential ||
|
||||||
|
!authentication.RepositoryPassword ||
|
||||||
|
!authentication.NewCredentialName
|
||||||
|
) {
|
||||||
|
return authentication.RepositoryGitCredentialID;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await saveCredentialsMutation.mutateAsync({
|
||||||
|
userId: user.Id,
|
||||||
|
username: authentication.RepositoryUsername,
|
||||||
|
password: authentication.RepositoryPassword,
|
||||||
|
name: authentication.NewCredentialName,
|
||||||
|
});
|
||||||
|
return credential.id;
|
||||||
|
} catch (err) {
|
||||||
|
notifyError('Error', err as Error, 'Unable to save credentials');
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function InnerForm({
|
||||||
|
gitUrl,
|
||||||
|
gitPath,
|
||||||
|
isLoading,
|
||||||
|
isUpdateVersion,
|
||||||
|
onUpdateSettingsClick,
|
||||||
|
webhookId,
|
||||||
|
}: {
|
||||||
|
gitUrl: string;
|
||||||
|
gitPath: string;
|
||||||
|
|
||||||
|
isLoading: boolean;
|
||||||
|
isUpdateVersion: boolean;
|
||||||
|
onUpdateSettingsClick(): void;
|
||||||
|
webhookId: string;
|
||||||
|
}) {
|
||||||
|
const { values, setFieldValue, isValid, handleSubmit, errors, dirty } =
|
||||||
|
useFormikContext<FormValues>();
|
||||||
|
|
||||||
|
const { hasType } = useValidateEnvironmentTypes(values.groupIds);
|
||||||
|
|
||||||
|
const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes);
|
||||||
|
const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker);
|
||||||
|
const hasNomadEndpoint = hasType(EnvironmentType.EdgeAgentOnNomad);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form className="form-horizontal" onSubmit={handleSubmit}>
|
||||||
|
<EdgeGroupsSelector
|
||||||
|
value={values.groupIds}
|
||||||
|
onChange={(value) => setFieldValue('groupIds', value)}
|
||||||
|
error={errors.groupIds}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{atLeastTwo(hasKubeEndpoint, hasDockerEndpoint, hasNomadEndpoint) && (
|
||||||
|
<TextTip>
|
||||||
|
There are no available deployment types when there is more than one
|
||||||
|
type of environment in your edge group selection (e.g. Kubernetes and
|
||||||
|
Docker environments). Please select edge groups that have environments
|
||||||
|
of the same type.
|
||||||
|
</TextTip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && (
|
||||||
|
<FormError>
|
||||||
|
Edge groups with kubernetes environments no longer support compose
|
||||||
|
deployment types in Portainer. Please select edge groups that only
|
||||||
|
have docker environments when using compose deployment types.
|
||||||
|
</FormError>
|
||||||
|
)}
|
||||||
|
<EdgeStackDeploymentTypeSelector
|
||||||
|
value={values.deploymentType}
|
||||||
|
hasDockerEndpoint={hasType(EnvironmentType.EdgeAgentOnDocker)}
|
||||||
|
hasKubeEndpoint={hasType(EnvironmentType.EdgeAgentOnKubernetes)}
|
||||||
|
hasNomadEndpoint={hasType(EnvironmentType.EdgeAgentOnNomad)}
|
||||||
|
onChange={(value) => {
|
||||||
|
setFieldValue('deploymentType', value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormSection title="Update from git repository">
|
||||||
|
<InfoPanel
|
||||||
|
className="text-muted small"
|
||||||
|
url={gitUrl}
|
||||||
|
type="Edge stack"
|
||||||
|
configFilePath={gitPath}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AutoUpdateFieldset
|
||||||
|
webhookId={webhookId}
|
||||||
|
value={values.autoUpdate}
|
||||||
|
onChange={(value) =>
|
||||||
|
setFieldValue('autoUpdate', {
|
||||||
|
...values.autoUpdate,
|
||||||
|
...value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
baseWebhookUrl={baseEdgeStackWebhookUrl()}
|
||||||
|
errors={errors.autoUpdate}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title="Advanced configuration" isFoldable>
|
||||||
|
<RefField
|
||||||
|
value={values.refName}
|
||||||
|
onChange={(value) => setFieldValue('refName', value)}
|
||||||
|
model={{ ...values.authentication, RepositoryURL: gitUrl }}
|
||||||
|
error={errors.refName}
|
||||||
|
isUrlValid
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AuthFieldset
|
||||||
|
value={values.authentication}
|
||||||
|
isAuthExplanationVisible
|
||||||
|
onChange={(value) =>
|
||||||
|
Object.entries(value).forEach(([key, value]) => {
|
||||||
|
setFieldValue(`authentication.${key}`, value);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
errors={errors.authentication}
|
||||||
|
/>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title="Actions">
|
||||||
|
<LoadingButton
|
||||||
|
disabled={!dirty || !isValid || isLoading}
|
||||||
|
isLoading={isUpdateVersion && isLoading}
|
||||||
|
loadingText="updating stack..."
|
||||||
|
>
|
||||||
|
Pull and update stack
|
||||||
|
</LoadingButton>
|
||||||
|
|
||||||
|
<LoadingButton
|
||||||
|
type="button"
|
||||||
|
disabled={!dirty || !isValid || isLoading}
|
||||||
|
isLoading={!isUpdateVersion && isLoading}
|
||||||
|
loadingText="updating settings..."
|
||||||
|
onClick={onUpdateSettingsClick}
|
||||||
|
>
|
||||||
|
Update settings
|
||||||
|
</LoadingButton>
|
||||||
|
</FormSection>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { GitForm } from './GitForm';
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { useMutation } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { mutationOptions, withError } from '@/react-tools/react-query';
|
||||||
|
import {
|
||||||
|
AutoUpdateResponse,
|
||||||
|
GitAuthenticationResponse,
|
||||||
|
} from '@/react/portainer/gitops/types';
|
||||||
|
import { buildUrl } from '@/react/edge/edge-stacks/queries/buildUrl';
|
||||||
|
import { DeploymentType, EdgeStack } from '@/react/edge/edge-stacks/types';
|
||||||
|
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
|
|
||||||
|
interface UpdateEdgeStackGitPayload {
|
||||||
|
id: EdgeStack['Id'];
|
||||||
|
autoUpdate: AutoUpdateResponse | null;
|
||||||
|
refName: string;
|
||||||
|
authentication: GitAuthenticationResponse | null;
|
||||||
|
groupIds: EdgeGroup['Id'][];
|
||||||
|
deploymentType: DeploymentType;
|
||||||
|
updateVersion: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateEdgeStackGitMutation() {
|
||||||
|
return useMutation(
|
||||||
|
updateEdgeStackGit,
|
||||||
|
mutationOptions(withError('Failed updating stack'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateEdgeStackGit({
|
||||||
|
id,
|
||||||
|
...payload
|
||||||
|
}: UpdateEdgeStackGitPayload) {
|
||||||
|
try {
|
||||||
|
await axios.put(buildUrl(id, 'git'), payload);
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err as Error, 'Failed updating stack');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,54 @@
|
||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
import { WebEditorForm } from '@@/WebEditorForm';
|
||||||
|
|
||||||
|
import { DeploymentType } from '../../types';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
|
||||||
|
export function KubernetesForm({
|
||||||
|
handleContentChange,
|
||||||
|
}: {
|
||||||
|
handleContentChange: (type: DeploymentType, content: string) => void;
|
||||||
|
}) {
|
||||||
|
const { errors, values, setFieldValue } = useFormikContext<FormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
label="Use namespace(s) specified from manifest"
|
||||||
|
tooltip="If you have defined namespaces in your deployment file turning this on will enforce the use of those only in the deployment"
|
||||||
|
checked={values.useManifestNamespaces}
|
||||||
|
onChange={(value) => setFieldValue('useManifestNamespaces', value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<WebEditorForm
|
||||||
|
value={values.content}
|
||||||
|
yaml
|
||||||
|
id="kube-manifest-editor"
|
||||||
|
placeholder="Define or paste the content of your manifest here"
|
||||||
|
onChange={(value) =>
|
||||||
|
handleContentChange(DeploymentType.Kubernetes, value)
|
||||||
|
}
|
||||||
|
error={errors.content}
|
||||||
|
>
|
||||||
|
<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"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
official documentation
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</WebEditorForm>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,38 @@
|
||||||
|
import { useFormikContext } from 'formik';
|
||||||
|
|
||||||
|
import { WebEditorForm } from '@@/WebEditorForm';
|
||||||
|
|
||||||
|
import { DeploymentType } from '../../types';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
|
||||||
|
export function NomadForm({
|
||||||
|
handleContentChange,
|
||||||
|
}: {
|
||||||
|
handleContentChange: (type: DeploymentType, content: string) => void;
|
||||||
|
}) {
|
||||||
|
const { errors, values } = useFormikContext<FormValues>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<WebEditorForm
|
||||||
|
value={values.content}
|
||||||
|
yaml
|
||||||
|
id="kube-manifest-editor"
|
||||||
|
placeholder="Define or paste the content of your manifest here"
|
||||||
|
onChange={(value) => handleContentChange(DeploymentType.Nomad, value)}
|
||||||
|
error={errors.content}
|
||||||
|
>
|
||||||
|
<p>
|
||||||
|
You can get more information about Nomad file format in the{' '}
|
||||||
|
<a
|
||||||
|
href="https://www.nomadproject.io/docs/job-specification"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
official documentation
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</WebEditorForm>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { notifyError } from '@/portainer/services/notifications';
|
||||||
|
import { PrivateRegistryFieldset } from '@/react/edge/edge-stacks/components/PrivateRegistryFieldset';
|
||||||
|
import { useCreateStackFromFileContent } from '@/react/edge/edge-stacks/queries/useCreateStackFromFileContent';
|
||||||
|
import { useRegistries } from '@/react/portainer/registries/queries/useRegistries';
|
||||||
|
|
||||||
|
import { FormValues } from './types';
|
||||||
|
|
||||||
|
export function PrivateRegistryFieldsetWrapper({
|
||||||
|
value,
|
||||||
|
isValid,
|
||||||
|
error,
|
||||||
|
onChange,
|
||||||
|
values,
|
||||||
|
stackName,
|
||||||
|
onFieldError,
|
||||||
|
}: {
|
||||||
|
value: FormValues['privateRegistryId'];
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
onChange: (value?: number) => void;
|
||||||
|
values: FormValues;
|
||||||
|
stackName: string;
|
||||||
|
onFieldError: (message: string) => void;
|
||||||
|
}) {
|
||||||
|
const dryRunMutation = useCreateStackFromFileContent();
|
||||||
|
|
||||||
|
const registriesQuery = useRegistries();
|
||||||
|
|
||||||
|
if (!registriesQuery.data) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrivateRegistryFieldset
|
||||||
|
value={value}
|
||||||
|
formInvalid={!isValid}
|
||||||
|
errorMessage={error}
|
||||||
|
registries={registriesQuery.data}
|
||||||
|
onChange={() => matchRegistry()}
|
||||||
|
onSelect={(value) => onChange(value)}
|
||||||
|
isActive={!!value}
|
||||||
|
clearRegistries={() => onChange(undefined)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
async function matchRegistry() {
|
||||||
|
try {
|
||||||
|
const response = await dryRunMutation.mutateAsync({
|
||||||
|
name: `${stackName}-dryrun`,
|
||||||
|
stackFileContent: values.content,
|
||||||
|
edgeGroups: values.edgeGroups,
|
||||||
|
deploymentType: values.deploymentType,
|
||||||
|
dryRun: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.Registries.length === 0) {
|
||||||
|
onChange(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validRegistry = onlyOne(response.Registries);
|
||||||
|
if (validRegistry) {
|
||||||
|
onChange(response.Registries[0]);
|
||||||
|
} else {
|
||||||
|
onChange(undefined);
|
||||||
|
onFieldError(
|
||||||
|
'Images need to be from a single registry, please edit and reload'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
notifyError('Failure', err as Error, 'Unable to retrieve registries');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onlyOne<T extends string | number>(arr: Array<T>) {
|
||||||
|
return _.uniq(arr).length === 1;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function atLeastTwo(a: boolean, b: boolean, c: boolean) {
|
||||||
|
return (a && b) || (a && c) || (b && c);
|
||||||
|
}
|
|
@ -0,0 +1,13 @@
|
||||||
|
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
|
import { DeploymentType } from '@/react/edge/edge-stacks/types';
|
||||||
|
|
||||||
|
export interface FormValues {
|
||||||
|
edgeGroups: EdgeGroup['Id'][];
|
||||||
|
deploymentType: DeploymentType;
|
||||||
|
privateRegistryId?: number;
|
||||||
|
content: string;
|
||||||
|
useManifestNamespaces: boolean;
|
||||||
|
prePullImage: boolean;
|
||||||
|
retryDeploy: boolean;
|
||||||
|
webhookEnabled: boolean;
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
import { useCallback } from 'react';
|
||||||
|
|
||||||
|
import { useEdgeGroups } from '@/react/edge/edge-groups/queries/useEdgeGroups';
|
||||||
|
import { EdgeGroup } from '@/react/edge/edge-groups/types';
|
||||||
|
import { EnvironmentType } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
export function useValidateEnvironmentTypes(groupIds: Array<EdgeGroup['Id']>) {
|
||||||
|
const edgeGroupsQuery = useEdgeGroups();
|
||||||
|
|
||||||
|
const edgeGroups = edgeGroupsQuery.data || [];
|
||||||
|
|
||||||
|
const modelEdgeGroups = _.compact(
|
||||||
|
groupIds.map((id) => edgeGroups.find((e) => e.Id === id))
|
||||||
|
);
|
||||||
|
const endpointTypes = modelEdgeGroups.flatMap((group) => group.EndpointTypes);
|
||||||
|
|
||||||
|
const hasType = useCallback(
|
||||||
|
(type: EnvironmentType) => endpointTypes.includes(type),
|
||||||
|
[endpointTypes]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
hasType,
|
||||||
|
};
|
||||||
|
}
|
|
@ -1,4 +1,8 @@
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
import { EditorType } from '@/react/edge/edge-stacks/types';
|
import { EditorType } from '@/react/edge/edge-stacks/types';
|
||||||
|
import NomadIcon from '@/assets/ico/vendor/nomad.svg?c';
|
||||||
|
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
|
||||||
|
|
||||||
import { BoxSelector } from '@@/BoxSelector';
|
import { BoxSelector } from '@@/BoxSelector';
|
||||||
import { BoxSelectorOption } from '@@/BoxSelector/types';
|
import { BoxSelectorOption } from '@@/BoxSelector/types';
|
||||||
|
@ -12,6 +16,7 @@ interface Props {
|
||||||
onChange(value: number): void;
|
onChange(value: number): void;
|
||||||
hasDockerEndpoint: boolean;
|
hasDockerEndpoint: boolean;
|
||||||
hasKubeEndpoint: boolean;
|
hasKubeEndpoint: boolean;
|
||||||
|
hasNomadEndpoint: boolean;
|
||||||
allowKubeToSelectCompose?: boolean;
|
allowKubeToSelectCompose?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -20,29 +25,45 @@ export function EdgeStackDeploymentTypeSelector({
|
||||||
onChange,
|
onChange,
|
||||||
hasDockerEndpoint,
|
hasDockerEndpoint,
|
||||||
hasKubeEndpoint,
|
hasKubeEndpoint,
|
||||||
|
hasNomadEndpoint,
|
||||||
allowKubeToSelectCompose,
|
allowKubeToSelectCompose,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const deploymentOptions: BoxSelectorOption<number>[] = [
|
const deploymentOptions: BoxSelectorOption<number>[] = _.compact([
|
||||||
{
|
{
|
||||||
...compose,
|
...compose,
|
||||||
value: EditorType.Compose,
|
value: EditorType.Compose,
|
||||||
disabled: () => (allowKubeToSelectCompose ? false : hasKubeEndpoint),
|
disabled: () =>
|
||||||
|
allowKubeToSelectCompose
|
||||||
|
? hasNomadEndpoint
|
||||||
|
: hasNomadEndpoint || hasKubeEndpoint,
|
||||||
tooltip: () =>
|
tooltip: () =>
|
||||||
hasKubeEndpoint
|
hasNomadEndpoint || hasKubeEndpoint
|
||||||
? 'Cannot use this option with Edge Kubernetes environments'
|
? 'Cannot use this option with Edge Kubernetes or Edge Nomad environments'
|
||||||
: '',
|
: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
...kubernetes,
|
...kubernetes,
|
||||||
value: EditorType.Kubernetes,
|
value: EditorType.Kubernetes,
|
||||||
disabled: () => hasDockerEndpoint,
|
disabled: () => hasDockerEndpoint || hasNomadEndpoint,
|
||||||
tooltip: () =>
|
tooltip: () =>
|
||||||
hasDockerEndpoint
|
hasDockerEndpoint || hasNomadEndpoint
|
||||||
? 'Cannot use this option with Edge Docker environments'
|
? 'Cannot use this option with Edge Docker or Edge Nomad environments'
|
||||||
: '',
|
: '',
|
||||||
iconType: 'logo',
|
iconType: 'logo',
|
||||||
},
|
},
|
||||||
];
|
isBE && {
|
||||||
|
id: 'deployment_nomad',
|
||||||
|
icon: NomadIcon,
|
||||||
|
label: 'Nomad',
|
||||||
|
description: 'Nomad HCL format',
|
||||||
|
value: EditorType.Nomad,
|
||||||
|
disabled: () => hasDockerEndpoint || hasKubeEndpoint,
|
||||||
|
tooltip: () =>
|
||||||
|
hasDockerEndpoint || hasKubeEndpoint
|
||||||
|
? 'Cannot use this option with Edge Docker or Edge Kubernetes environments'
|
||||||
|
: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|
|
@ -0,0 +1,106 @@
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
import { Registry } from '@/react/portainer/registries/types';
|
||||||
|
|
||||||
|
import { Select } from '@@/form-components/ReactSelect';
|
||||||
|
import { FormControl } from '@@/form-components/FormControl';
|
||||||
|
import { Button } from '@@/buttons';
|
||||||
|
import { FormError } from '@@/form-components/FormError';
|
||||||
|
import { SwitchField } from '@@/form-components/SwitchField';
|
||||||
|
import { TextTip } from '@@/Tip/TextTip';
|
||||||
|
import { FormSection } from '@@/form-components/FormSection';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
value?: number;
|
||||||
|
registries: Registry[];
|
||||||
|
onChange: () => void;
|
||||||
|
formInvalid?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
onSelect: (value?: number) => void;
|
||||||
|
isActive?: boolean;
|
||||||
|
clearRegistries: () => void;
|
||||||
|
method?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrivateRegistryFieldset({
|
||||||
|
value,
|
||||||
|
registries,
|
||||||
|
onChange,
|
||||||
|
formInvalid,
|
||||||
|
errorMessage,
|
||||||
|
onSelect,
|
||||||
|
isActive,
|
||||||
|
clearRegistries,
|
||||||
|
method,
|
||||||
|
}: Props) {
|
||||||
|
const [checked, setChecked] = useState(isActive || false);
|
||||||
|
const [selected, setSelected] = useState(value);
|
||||||
|
|
||||||
|
const tooltipMessage =
|
||||||
|
'Use this when using a private registry that requires credentials';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (checked) {
|
||||||
|
onChange();
|
||||||
|
} else {
|
||||||
|
clearRegistries();
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [checked]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSelected(value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
function reload() {
|
||||||
|
onChange();
|
||||||
|
setSelected(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormSection title="Registry">
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="col-sm-12">
|
||||||
|
<SwitchField
|
||||||
|
checked={checked}
|
||||||
|
onChange={(value) => setChecked(value)}
|
||||||
|
tooltip={tooltipMessage}
|
||||||
|
label="Use Credentials"
|
||||||
|
labelClass="col-sm-3 col-lg-2"
|
||||||
|
disabled={formInvalid}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{checked && (
|
||||||
|
<>
|
||||||
|
{method !== 'repository' && (
|
||||||
|
<>
|
||||||
|
<TextTip color="blue">
|
||||||
|
If you make any changes to the image urls in your yaml, please
|
||||||
|
reload or select registry manually
|
||||||
|
</TextTip>
|
||||||
|
|
||||||
|
<Button onClick={reload}>Reload</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!errorMessage ? (
|
||||||
|
<FormControl label="Registry" inputId="users-selector">
|
||||||
|
<Select
|
||||||
|
value={registries.filter(
|
||||||
|
(registry) => registry.Id === selected
|
||||||
|
)}
|
||||||
|
options={registries}
|
||||||
|
getOptionLabel={(registry) => registry.Name}
|
||||||
|
getOptionValue={(registry) => registry.Id.toString()}
|
||||||
|
onChange={(value) => onSelect(value?.Id)}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
) : (
|
||||||
|
<FormError>{errorMessage}</FormError>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</FormSection>
|
||||||
|
);
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { EdgeStack } from '../types';
|
||||||
|
|
||||||
|
export function buildUrl(id?: EdgeStack['Id'], action?: string) {
|
||||||
|
const baseUrl = '/edge_stacks';
|
||||||
|
const url = id ? `${baseUrl}/${id}` : baseUrl;
|
||||||
|
return action ? `${url}/${action}` : url;
|
||||||
|
}
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
|
||||||
|
import { EdgeStack } from '../types';
|
||||||
|
|
||||||
|
export const queryKeys = {
|
||||||
|
base: () => ['edge-stacks'] as const,
|
||||||
|
item: (id: EdgeStack['Id']) => [...queryKeys.base(), id] as const,
|
||||||
|
logsStatus: (edgeStackId: EdgeStack['Id'], environmentId: EnvironmentId) =>
|
||||||
|
[...queryKeys.item(edgeStackId), 'logs', environmentId] as const,
|
||||||
|
};
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { useMutation } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
import { withError } from '@/react-tools/react-query';
|
||||||
|
import { RegistryId } from '@/react/portainer/registries/types';
|
||||||
|
|
||||||
|
import { EdgeGroup } from '../../edge-groups/types';
|
||||||
|
import { DeploymentType, EdgeStack } from '../types';
|
||||||
|
|
||||||
|
import { buildUrl } from './buildUrl';
|
||||||
|
|
||||||
|
export function useCreateStackFromFileContent() {
|
||||||
|
return useMutation(createStackFromFileContent, {
|
||||||
|
...withError('Failed creating Edge stack'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileContentPayload {
|
||||||
|
name: string;
|
||||||
|
stackFileContent: string;
|
||||||
|
edgeGroups: EdgeGroup['Id'][];
|
||||||
|
deploymentType: DeploymentType;
|
||||||
|
registries?: RegistryId[];
|
||||||
|
useManifestNamespaces?: boolean;
|
||||||
|
prePullImage?: boolean;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createStackFromFileContent({
|
||||||
|
dryRun,
|
||||||
|
...payload
|
||||||
|
}: FileContentPayload) {
|
||||||
|
try {
|
||||||
|
const { data } = await axios.post<EdgeStack>(buildUrl(), payload, {
|
||||||
|
params: { method: 'string', dryrun: dryRun ? 'true' : 'false' },
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
} catch (e) {
|
||||||
|
throw parseAxiosError(e as Error);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,3 +1,62 @@
|
||||||
|
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||||
|
import {
|
||||||
|
AutoUpdateResponse,
|
||||||
|
RepoConfigResponse,
|
||||||
|
} from '@/react/portainer/gitops/types';
|
||||||
|
import { RegistryId } from '@/react/portainer/registries/types';
|
||||||
|
|
||||||
|
import { EdgeGroup } from '../edge-groups/types';
|
||||||
|
|
||||||
|
interface EdgeStackStatusDetails {
|
||||||
|
Pending: boolean;
|
||||||
|
Ok: boolean;
|
||||||
|
Error: boolean;
|
||||||
|
Acknowledged: boolean;
|
||||||
|
Remove: boolean;
|
||||||
|
RemoteUpdateSuccess: boolean;
|
||||||
|
ImagesPulled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EdgeStackStatus {
|
||||||
|
Details: EdgeStackStatusDetails;
|
||||||
|
Error: string;
|
||||||
|
EndpointID: EnvironmentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum DeploymentType {
|
||||||
|
/** represent an edge stack deployed using a compose file */
|
||||||
|
Compose,
|
||||||
|
/** represent an edge stack deployed using a kubernetes manifest file */
|
||||||
|
Kubernetes,
|
||||||
|
/** represent an edge stack deployed using a nomad hcl job file */
|
||||||
|
Nomad,
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EdgeStack = {
|
||||||
|
Id: number;
|
||||||
|
Name: string;
|
||||||
|
Status: { [key: EnvironmentId]: EdgeStackStatus };
|
||||||
|
CreationDate: number;
|
||||||
|
EdgeGroups: Array<EdgeGroup['Id']>;
|
||||||
|
Registries: RegistryId[];
|
||||||
|
ProjectPath: string;
|
||||||
|
EntryPoint: string;
|
||||||
|
Version: number;
|
||||||
|
NumDeployments: number;
|
||||||
|
ManifestPath: string;
|
||||||
|
DeploymentType: DeploymentType;
|
||||||
|
EdgeUpdateID: number;
|
||||||
|
ScheduledTime: string;
|
||||||
|
UseManifestNamespaces: boolean;
|
||||||
|
PrePullImage: boolean;
|
||||||
|
RePullImage: boolean;
|
||||||
|
AutoUpdate?: AutoUpdateResponse;
|
||||||
|
GitConfig?: RepoConfigResponse;
|
||||||
|
Prune: boolean;
|
||||||
|
RetryDeploy: boolean;
|
||||||
|
Webhook?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export enum EditorType {
|
export enum EditorType {
|
||||||
Compose,
|
Compose,
|
||||||
Kubernetes,
|
Kubernetes,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { Registry } from '@/react/portainer/environments/environment.service/registries';
|
import { Registry } from '@/react/portainer/registries/types';
|
||||||
|
|
||||||
import { Select } from '@@/form-components/ReactSelect';
|
import { Select } from '@@/form-components/ReactSelect';
|
||||||
|
|
||||||
|
|
|
@ -13,7 +13,11 @@ export async function createGitCredential(
|
||||||
gitCredential: CreateGitCredentialPayload
|
gitCredential: CreateGitCredentialPayload
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
await axios.post(buildGitUrl(gitCredential.userId), gitCredential);
|
const { data } = await axios.post<{ gitCredential: GitCredential }>(
|
||||||
|
buildGitUrl(gitCredential.userId),
|
||||||
|
gitCredential
|
||||||
|
);
|
||||||
|
return data.gitCredential;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw parseAxiosError(e as Error, 'Unable to create git credential');
|
throw parseAxiosError(e as Error, 'Unable to create git credential');
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
import { TeamId } from '@/react/portainer/users/teams/types';
|
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||||
import { UserId } from '@/portainer/users/types';
|
import { UserId } from '@/portainer/users/types';
|
||||||
|
import { RegistryId, Registry } from '@/react/portainer/registries/types';
|
||||||
|
|
||||||
import { EnvironmentId } from '../types';
|
import { EnvironmentId } from '../types';
|
||||||
|
|
||||||
|
@ -14,12 +15,6 @@ interface AccessPolicy {
|
||||||
type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy
|
type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy
|
||||||
type TeamAccessPolicies = Record<TeamId, AccessPolicy>;
|
type TeamAccessPolicies = Record<TeamId, AccessPolicy>;
|
||||||
|
|
||||||
export type RegistryId = number;
|
|
||||||
export interface Registry {
|
|
||||||
Id: RegistryId;
|
|
||||||
Name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RegistryAccess {
|
interface RegistryAccess {
|
||||||
UserAccessPolicies: UserAccessPolicies;
|
UserAccessPolicies: UserAccessPolicies;
|
||||||
TeamAccessPolicies: TeamAccessPolicies;
|
TeamAccessPolicies: TeamAccessPolicies;
|
||||||
|
|
|
@ -22,3 +22,27 @@ export function parseAuthResponse(
|
||||||
RepositoryUsername: auth.Username,
|
RepositoryUsername: auth.Username,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function transformGitAuthenticationViewModel(
|
||||||
|
auth?: GitAuthModel
|
||||||
|
): GitAuthenticationResponse | null {
|
||||||
|
if (
|
||||||
|
!auth ||
|
||||||
|
!auth.RepositoryAuthentication ||
|
||||||
|
typeof auth.RepositoryGitCredentialID === 'undefined' ||
|
||||||
|
(auth.RepositoryGitCredentialID === 0 && auth.RepositoryPassword === '')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (auth.RepositoryGitCredentialID !== 0) {
|
||||||
|
return {
|
||||||
|
GitCredentialID: auth.RepositoryGitCredentialID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
Username: auth.RepositoryUsername,
|
||||||
|
Password: auth.RepositoryPassword,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
@ -15,9 +15,9 @@ export interface AutoUpdateResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitAuthenticationResponse {
|
export interface GitAuthenticationResponse {
|
||||||
Username: string;
|
Username?: string;
|
||||||
Password: string;
|
Password?: string;
|
||||||
GitCredentialID: number;
|
GitCredentialID?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RepoConfigResponse {
|
export interface RepoConfigResponse {
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
export const queryKeys = {
|
||||||
|
registries: () => ['registries'] as const,
|
||||||
|
};
|
|
@ -0,0 +1,20 @@
|
||||||
|
import { useQuery } from 'react-query';
|
||||||
|
|
||||||
|
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||||
|
|
||||||
|
import { Registry } from '../types';
|
||||||
|
|
||||||
|
import { queryKeys } from './queryKeys';
|
||||||
|
|
||||||
|
export function useRegistries() {
|
||||||
|
return useQuery(queryKeys.registries(), getRegistries);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRegistries() {
|
||||||
|
try {
|
||||||
|
const response = await axios.get<Array<Registry>>('/registries');
|
||||||
|
return response.data;
|
||||||
|
} catch (err) {
|
||||||
|
throw parseAxiosError(err as Error, 'Unable to retrieve registries');
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,5 @@
|
||||||
|
export type RegistryId = number;
|
||||||
|
export interface Registry {
|
||||||
|
Id: RegistryId;
|
||||||
|
Name: string;
|
||||||
|
}
|
Loading…
Reference in New Issue