diff --git a/api/dataservices/interface.go b/api/dataservices/interface.go index 02334c6de..cacd332ae 100644 --- a/api/dataservices/interface.go +++ b/api/dataservices/interface.go @@ -95,6 +95,7 @@ type ( EdgeStack(ID portainer.EdgeStackID) (*portainer.EdgeStack, error) EdgeStackVersion(ID portainer.EdgeStackID) (int, bool) Create(id portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error + // Deprecated: Use UpdateEdgeStackFunc instead. UpdateEdgeStack(ID portainer.EdgeStackID, edgeStack *portainer.EdgeStack) error UpdateEdgeStackFunc(ID portainer.EdgeStackID, updateFunc func(edgeStack *portainer.EdgeStack)) error DeleteEdgeStack(ID portainer.EdgeStackID) error diff --git a/api/http/errors/tx.go b/api/http/errors/tx.go new file mode 100644 index 000000000..037cb95d0 --- /dev/null +++ b/api/http/errors/tx.go @@ -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() +} diff --git a/api/http/handler/edgestacks/edgestack_create.go b/api/http/handler/edgestacks/edgestack_create.go index e64b39a96..997234eb9 100644 --- a/api/http/handler/edgestacks/edgestack_create.go +++ b/api/http/handler/edgestacks/edgestack_create.go @@ -38,6 +38,8 @@ func (handler *Handler) edgeStackCreate(w http.ResponseWriter, r *http.Request) switch { case httperrors.IsInvalidPayloadError(err): return httperror.BadRequest("Invalid payload", err) + case httperrors.IsConflictError(err): + return httperror.NewError(http.StatusConflict, err.Error(), err) default: return httperror.InternalServerError("Unable to create Edge stack", err) } diff --git a/api/http/handler/edgestacks/edgestack_create_string.go b/api/http/handler/edgestacks/edgestack_create_string.go index a3e2bc918..45ee1240e 100644 --- a/api/http/handler/edgestacks/edgestack_create_string.go +++ b/api/http/handler/edgestacks/edgestack_create_string.go @@ -105,7 +105,6 @@ func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolde } return composePath, "", projectPath, nil - } if deploymentType == portainer.EdgeStackDeploymentKubernetes { diff --git a/api/http/handler/edgestacks/edgestack_create_test.go b/api/http/handler/edgestacks/edgestack_create_test.go index 2a8910982..cc62de94e 100644 --- a/api/http/handler/edgestacks/edgestack_create_test.go +++ b/api/http/handler/edgestacks/edgestack_create_test.go @@ -144,7 +144,7 @@ func TestCreateWithInvalidPayload(t *testing.T) { DeploymentType: edgeStack.DeploymentType, }, Method: "string", - ExpectedStatusCode: 500, + ExpectedStatusCode: http.StatusConflict, }, { Name: "Empty EdgeStack Groups", diff --git a/api/http/handler/edgestacks/edgestack_update.go b/api/http/handler/edgestacks/edgestack_update.go index cf75fd637..98a5c396b 100644 --- a/api/http/handler/edgestacks/edgestack_update.go +++ b/api/http/handler/edgestacks/edgestack_update.go @@ -1,10 +1,10 @@ package edgestacks import ( - "errors" "net/http" "strconv" + "github.com/pkg/errors" httperror "github.com/portainer/libhttp/error" "github.com/portainer/libhttp/request" "github.com/portainer/libhttp/response" @@ -12,7 +12,7 @@ import ( "github.com/portainer/portainer/api/dataservices" "github.com/portainer/portainer/api/filesystem" "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/rs/zerolog/log" @@ -20,7 +20,7 @@ import ( type updateEdgeStackPayload struct { StackFileContent string - Version *int + UpdateVersion bool EdgeGroups []portainer.EdgeGroupID DeploymentType portainer.EdgeStackDeploymentType // 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) } - endpointsToAdd := map[portainer.EndpointID]bool{} - + groupsIds := stack.EdgeGroups 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 { - 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) - 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 + groupsIds = payload.EdgeGroups 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 err = handler.FileService.RemoveDirectory(stack.ProjectPath) if err != nil { log.Warn().Err(err).Msg("Unable to clear old files") } - stack.EntryPoint = "" - stack.ManifestPath = "" - stack.DeploymentType = payload.DeploymentType + entryPoint = "" + manifestPath = "" + deploymentType = payload.DeploymentType } 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 stack.EntryPoint == "" { - stack.EntryPoint = filesystem.ComposeFileDefaultName + if entryPoint == "" { + 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 { 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 { 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 stack.ManifestPath == "" { - stack.ManifestPath = filesystem.ManifestFileDefaultName + if deploymentType == portainer.EdgeStackDeploymentKubernetes { + if manifestPath == "" { + manifestPath = filesystem.ManifestFileDefaultName } - stack.UseManifestNamespaces = payload.UseManifestNamespaces - - _, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, stack.ManifestPath, []byte(payload.StackFileContent)) + _, err = handler.FileService.StoreEdgeStackFileFromBytes(stackFolder, manifestPath, []byte(payload.StackFileContent)) if err != nil { return nil, httperror.InternalServerError("Unable to persist updated Kubernetes manifest file on disk", err) } } - versionUpdated := payload.Version != nil && *payload.Version != stack.Version - if versionUpdated { - stack.Version = *payload.Version - stack.Status = map[portainer.EndpointID]portainer.EdgeStackStatus{} - } + err = tx.EdgeStack().UpdateEdgeStackFunc(stack.ID, func(edgeStack *portainer.EdgeStack) { + edgeStack.NumDeployments = len(relatedEndpointIds) + if payload.UpdateVersion { + edgeStack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus) + edgeStack.Version++ + } - stack.NumDeployments = len(relatedEndpointIds) + edgeStack.UseManifestNamespaces = payload.UseManifestNamespaces - if versionUpdated { - stack.Status = make(map[portainer.EndpointID]portainer.EdgeStackStatus) - } + edgeStack.DeploymentType = deploymentType + edgeStack.EntryPoint = entryPoint + edgeStack.ManifestPath = manifestPath - err = tx.EdgeStack().UpdateEdgeStack(stack.ID, stack) + edgeStack.EdgeGroups = groupsIds + }) if err != nil { return nil, httperror.InternalServerError("Unable to persist the stack changes inside the database", err) } return stack, nil } + +func (handler *Handler) handleChangeEdgeGroups(tx dataservices.DataStoreTx, 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 +} diff --git a/api/http/handler/edgestacks/edgestack_update_test.go b/api/http/handler/edgestacks/edgestack_update_test.go index deccb3690..34b7359a1 100644 --- a/api/http/handler/edgestacks/edgestack_update_test.go +++ b/api/http/handler/edgestacks/edgestack_update_test.go @@ -54,10 +54,9 @@ func TestUpdateAndInspect(t *testing.T) { t.Fatal(err) } - newVersion := 238 payload := updateEdgeStackPayload{ StackFileContent: "update-test", - Version: &newVersion, + UpdateVersion: true, EdgeGroups: append(edgeStack.EdgeGroups, newEdgeGroup.ID), DeploymentType: portainer.EdgeStackDeploymentCompose, } @@ -101,7 +100,7 @@ func TestUpdateAndInspect(t *testing.T) { 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) } @@ -132,7 +131,6 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) { handler.DataStore.EdgeGroup().Create(&newEdgeGroup) - newVersion := 238 cases := []struct { Name string Payload updateEdgeStackPayload @@ -142,7 +140,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) { "Update with non-existing EdgeGroupID", updateEdgeStackPayload{ StackFileContent: "error-test", - Version: &newVersion, + UpdateVersion: true, EdgeGroups: []portainer.EdgeGroupID{9999}, DeploymentType: edgeStack.DeploymentType, }, @@ -152,7 +150,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) { "Update with invalid EdgeGroup (non-existing Endpoint)", updateEdgeStackPayload{ StackFileContent: "error-test", - Version: &newVersion, + UpdateVersion: true, EdgeGroups: []portainer.EdgeGroupID{2}, DeploymentType: edgeStack.DeploymentType, }, @@ -162,7 +160,7 @@ func TestUpdateWithInvalidEdgeGroups(t *testing.T) { "Update DeploymentType from Docker to Kubernetes", updateEdgeStackPayload{ StackFileContent: "error-test", - Version: &newVersion, + UpdateVersion: true, EdgeGroups: []portainer.EdgeGroupID{1}, DeploymentType: portainer.EdgeStackDeploymentKubernetes, }, @@ -200,7 +198,6 @@ func TestUpdateWithInvalidPayload(t *testing.T) { endpoint := createEndpoint(t, handler.DataStore) edgeStack := createEdgeStack(t, handler.DataStore, endpoint.ID) - newVersion := 238 cases := []struct { Name string Payload updateEdgeStackPayload @@ -210,7 +207,7 @@ func TestUpdateWithInvalidPayload(t *testing.T) { "Update with empty StackFileContent", updateEdgeStackPayload{ StackFileContent: "", - Version: &newVersion, + UpdateVersion: true, EdgeGroups: edgeStack.EdgeGroups, DeploymentType: edgeStack.DeploymentType, }, @@ -220,7 +217,7 @@ func TestUpdateWithInvalidPayload(t *testing.T) { "Update with empty EdgeGroups", updateEdgeStackPayload{ StackFileContent: "error-test", - Version: &newVersion, + UpdateVersion: true, EdgeGroups: []portainer.EdgeGroupID{}, DeploymentType: edgeStack.DeploymentType, }, diff --git a/api/http/handler/edgestacks/handler.go b/api/http/handler/edgestacks/handler.go index 141eec911..b504794bc 100644 --- a/api/http/handler/edgestacks/handler.go +++ b/api/http/handler/edgestacks/handler.go @@ -25,6 +25,8 @@ type Handler struct { KubernetesDeployer portainer.KubernetesDeployer } +const contextKey = "edgeStack_item" + // NewHandler creates a handler to manage environment(endpoint) group operations. func NewHandler(bouncer *security.RequestBouncer, dataStore dataservices.DataStore, edgeStacksService *edgestackservice.Service) *Handler { h := &Handler{ diff --git a/api/internal/edge/edgestacks/service.go b/api/internal/edge/edgestacks/service.go index 7ad2180fe..38e0b6ddd 100644 --- a/api/internal/edge/edgestacks/service.go +++ b/api/internal/edge/edgestacks/service.go @@ -63,7 +63,7 @@ func validateUniqueName(edgeStacksGetter func() ([]portainer.EdgeStack, error), for _, stack := range edgeStacks { if strings.EqualFold(stack.Name, name) { - return errors.New("Edge stack name must be unique") + return httperrors.NewConflictError("Edge stack name must be unique") } } diff --git a/api/stacks/stackutils/gitops.go b/api/stacks/stackutils/gitops.go index e80d38da7..d035b783d 100644 --- a/api/stacks/stackutils/gitops.go +++ b/api/stacks/stackutils/gitops.go @@ -5,13 +5,13 @@ import ( "github.com/pkg/errors" portainer "github.com/portainer/portainer/api" + "github.com/portainer/portainer/api/git" gittypes "github.com/portainer/portainer/api/git/types" ) var ( ErrStackAlreadyExists = errors.New("A stack already exists with this name") ErrWebhookIDAlreadyExists = errors.New("A webhook ID already exists") - ErrInvalidGitCredential = errors.New("Invalid git credential") ) // 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) if err != nil { if errors.Is(err, gittypes.ErrAuthenticationFailure) { - newErr := ErrInvalidGitCredential + newErr := git.ErrInvalidGitCredential return "", newErr } diff --git a/app/edge/components/edge-job-form/edgeJobForm.html b/app/edge/components/edge-job-form/edgeJobForm.html index 3f5828532..01f5a2be2 100644 --- a/app/edge/components/edge-job-form/edgeJobForm.html +++ b/app/edge/components/edge-job-form/edgeJobForm.html @@ -169,15 +169,7 @@ -
Edge Groups
-
-
- -
-
- No Edge groups are available. Head over to the Edge groups view to create one. -
-
+
Target environments
diff --git a/app/edge/components/edge-job-form/edgeJobFormController.js b/app/edge/components/edge-job-form/edgeJobFormController.js index 68fecebb6..4c0b22aff 100644 --- a/app/edge/components/edge-job-form/edgeJobFormController.js +++ b/app/edge/components/edge-job-form/edgeJobFormController.js @@ -6,11 +6,9 @@ import { cronMethodOptions } from '@/react/edge/edge-jobs/CreateView/cron-method export class EdgeJobFormController { /* @ngInject */ - constructor($async, $scope, EdgeGroupService, Notifications) { + constructor($async, $scope) { this.$scope = $scope; this.$async = $async; - this.EdgeGroupService = EdgeGroupService; - this.Notifications = Notifications; this.cronMethods = cronMethodOptions; this.buildMethods = [editor, upload]; @@ -127,18 +125,8 @@ export class EdgeJobFormController { 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() { this.onChangeModel(this.model); - this.getEdgeGroups(); } } diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html b/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html deleted file mode 100644 index be3d8482e..000000000 --- a/app/edge/components/edit-edge-stack-form/editEdgeStackForm.html +++ /dev/null @@ -1,95 +0,0 @@ -
- - - - -
- -
-

- Portainer no longer supports docker-compose format manifests for Kubernetes deployments, and we - have removed the Kompose 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). -

-

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.

-

- 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. -

-
-
- - - -
- You can get more information about Compose file format in the - official documentation - . -
-
-
- -
-
-
- -
-
- - - -

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

-
-
-
- - -
Actions
-
-
- -
-
- -
diff --git a/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js b/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js deleted file mode 100644 index a34039713..000000000 --- a/app/edge/components/edit-edge-stack-form/editEdgeStackFormController.js +++ /dev/null @@ -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(); - } -} diff --git a/app/edge/components/edit-edge-stack-form/index.js b/app/edge/components/edit-edge-stack-form/index.js deleted file mode 100644 index 5f3d23628..000000000 --- a/app/edge/components/edit-edge-stack-form/index.js +++ /dev/null @@ -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: '=', - }, -}); diff --git a/app/edge/react/components/index.ts b/app/edge/react/components/index.ts index 1fe7e475b..673a0daca 100644 --- a/app/edge/react/components/index.ts +++ b/app/edge/react/components/index.ts @@ -7,6 +7,8 @@ import { EdgeCheckinIntervalField } from '@/react/edge/components/EdgeCheckInInt import { EdgeScriptForm } from '@/react/edge/components/EdgeScriptForm'; import { EdgeAsyncIntervalsForm } from '@/react/edge/components/EdgeAsyncIntervalsForm'; 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'; export const componentsModule = angular @@ -60,6 +62,18 @@ export const componentsModule = angular 'onChange', 'hasDockerEndpoint', 'hasKubeEndpoint', + 'hasNomadEndpoint', + 'allowKubeToSelectCompose', + ]) + ) + .component( + 'editEdgeStackForm', + r2a(withUIRouter(withReactQuery(withCurrentUser(EditEdgeStackForm))), [ + 'edgeStack', + 'fileContent', + 'isSubmitting', + 'onEditorChange', + 'onSubmit', 'allowKubeToSelectCompose', ]) ).name; diff --git a/app/edge/services/edge-stack.js b/app/edge/services/edge-stack.js index ecacda4d6..821026631 100644 --- a/app/edge/services/edge-stack.js +++ b/app/edge/services/edge-stack.js @@ -65,9 +65,5 @@ angular.module('portainer.edge').factory('EdgeStackService', function EdgeStackS } }; - service.update = function update(stack) { - return EdgeStacks.update(stack).$promise; - }; - return service; }); diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js index 2d4a78fa3..44047571c 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.controller.js @@ -1,8 +1,9 @@ import { EditorType } from '@/react/edge/edge-stacks/types'; -import { PortainerEndpointTypes } from '@/portainer/models/endpoint/models'; import { getValidEditorTypes } from '@/react/edge/edge-stacks/utils'; import { STACK_NAME_VALIDATION_REGEX } from '@/react/constants'; import { confirmWebEditorDiscard } from '@@/modals/confirm'; +import { baseEdgeStackWebhookUrl } from '@/portainer/helpers/webhookHelper'; +import { EnvironmentType } from '@/react/portainer/environments/types'; export default class CreateEdgeStackViewController { /* @ngInject */ @@ -27,6 +28,7 @@ export default class CreateEdgeStackViewController { }; this.EditorType = EditorType; + this.EnvironmentType = EnvironmentType; this.state = { Method: 'editor', @@ -36,6 +38,7 @@ export default class CreateEdgeStackViewController { isEditorDirty: false, hasKubeEndpoint: false, endpointTypes: [], + baseWebhookUrl: baseEdgeStackWebhookUrl(), }; this.edgeGroups = null; @@ -49,8 +52,7 @@ export default class CreateEdgeStackViewController { this.createStackFromFileUpload = this.createStackFromFileUpload.bind(this); this.createStackFromGitRepository = this.createStackFromGitRepository.bind(this); this.onChangeGroups = this.onChangeGroups.bind(this); - this.hasDockerEndpoint = this.hasDockerEndpoint.bind(this); - this.hasKubeEndpoint = this.hasKubeEndpoint.bind(this); + this.hasType = this.hasType.bind(this); this.onChangeDeploymentType = this.onChangeDeploymentType.bind(this); } @@ -139,9 +141,11 @@ export default class CreateEdgeStackViewController { } checkIfEndpointTypes(groups) { - const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id)); - this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes); - this.selectValidDeploymentType(); + return this.$scope.$evalAsync(() => { + const edgeGroups = groups.map((id) => this.edgeGroups.find((e) => e.Id === id)); + this.state.endpointTypes = edgeGroups.flatMap((group) => group.EndpointTypes); + this.selectValidDeploymentType(); + }); } selectValidDeploymentType() { @@ -152,12 +156,8 @@ export default class CreateEdgeStackViewController { } } - hasKubeEndpoint() { - return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnKubernetesEnvironment); - } - - hasDockerEndpoint() { - return this.state.endpointTypes.includes(PortainerEndpointTypes.EdgeAgentOnDockerEnvironment); + hasType(envType) { + return this.state.endpointTypes.includes(envType); } validateForm(method) { diff --git a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html index 0c406ad96..63b8bdab5 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/create-edge-stack-view.html @@ -39,12 +39,18 @@ - + + +

+ 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. +

diff --git a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html index 93de92133..328a6ec28 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/docker-compose-form/docker-compose-form.html @@ -21,7 +21,14 @@ You can upload a Compose file from your computer. - +
diff --git a/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.html b/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.html index d8ab1fe3d..41f8c7530 100644 --- a/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.html +++ b/app/edge/views/edge-stacks/createEdgeStackView/kube-manifest-form/kube-manifest-form.html @@ -32,4 +32,11 @@ - + diff --git a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html index c70193915..341bc2640 100644 --- a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html +++ b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackView.html @@ -12,11 +12,14 @@
diff --git a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js index dc79edab8..255c51c54 100644 --- a/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js +++ b/app/edge/views/edge-stacks/editEdgeStackView/editEdgeStackViewController.js @@ -1,6 +1,8 @@ import _ from 'lodash-es'; import { getEnvironments } from '@/react/portainer/environments/environment.service'; import { confirmWebEditorDiscard } from '@@/modals/confirm'; +import { EnvironmentType } from '@/react/portainer/environments/types'; +import { createWebhookId } from '@/portainer/helpers/webhookHelper'; export class EditEdgeStackViewController { /* @ngInject */ @@ -18,56 +20,74 @@ export class EditEdgeStackViewController { this.state = { actionInProgress: false, activeTab: 0, - isEditorDirty: false, + isStackDeployed: false, + }; + + this.formValues = { + content: '', }; this.deployStack = this.deployStack.bind(this); this.deployStackAsync = this.deployStackAsync.bind(this); this.getPaginatedEndpoints = this.getPaginatedEndpoints.bind(this); this.getPaginatedEndpointsAsync = this.getPaginatedEndpointsAsync.bind(this); + this.onEditorChange = this.onEditorChange.bind(this); + this.isEditorDirty = this.isEditorDirty.bind(this); } async $onInit() { - const { stackId, tab } = this.$state.params; - this.state.activeTab = tab; - try { - const [edgeGroups, model, file] = await Promise.all([this.EdgeGroupService.groups(), this.EdgeStackService.stack(stackId), this.EdgeStackService.stackFile(stackId)]); - this.edgeGroups = edgeGroups; - this.stack = model; - this.stackEndpointIds = this.filterStackEndpoints(model.EdgeGroups, edgeGroups); - this.originalFileContent = file; - this.formValues = { - StackFileContent: file, - EdgeGroups: this.stack.EdgeGroups, - UseManifestNamespaces: this.stack.UseManifestNamespaces, - DeploymentType: this.stack.DeploymentType, - }; - this.oldFileContent = this.formValues.StackFileContent; - } catch (err) { - this.Notifications.error('Failure', err, 'Unable to retrieve stack data'); - } + return this.$async(async () => { + const { stackId, tab } = this.$state.params; + this.state.activeTab = tab; + try { + const [edgeGroups, model, file] = await Promise.all([this.EdgeGroupService.groups(), this.EdgeStackService.stack(stackId), this.EdgeStackService.stackFile(stackId)]); - this.$window.onbeforeunload = () => { - if (this.formValues.StackFileContent !== this.oldFileContent && this.state.isEditorDirty) { - return ''; + this.edgeGroups = edgeGroups; + this.stack = model; + this.stackEndpointIds = this.filterStackEndpoints(model.EdgeGroups, edgeGroups); + this.originalFileContent = file; + this.formValues = { + content: file, + }; + + 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) { + this.Notifications.error('Failure', err, 'Unable to retrieve stack data'); } - }; + + this.oldFileContent = this.formValues.StackFileContent; + + this.$window.onbeforeunload = () => { + if (this.isEditorDirty()) { + return ''; + } + }; + }); } $onDestroy() { - this.state.isEditorDirty = false; + this.$window.onbeforeunload = undefined; } async uiCanExit() { - if ( - this.formValues.StackFileContent && - this.formValues.StackFileContent.replace(/(\r\n|\n|\r)/gm, '') !== this.oldFileContent.replace(/(\r\n|\n|\r)/gm, '') && - this.state.isEditorDirty - ) { + if (this.isEditorDirty()) { 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) { return _.flatten( _.map(groupIds, (Id) => { @@ -77,19 +97,24 @@ export class EditEdgeStackViewController { ); } - deployStack() { - return this.$async(this.deployStackAsync); + deployStack(values) { + return this.deployStackAsync(values); } - async deployStackAsync() { + async deployStackAsync(values) { this.state.actionInProgress = true; try { - if (this.originalFileContent != this.formValues.StackFileContent || this.formValues.UseManifestNamespaces !== this.stack.UseManifestNamespaces) { - this.formValues.Version = this.stack.Version + 1; - } - await this.EdgeStackService.updateStack(this.stack.Id, this.formValues); + const updateVersion = !!(this.originalFileContent != values.content || values.useManifestNamespaces !== this.stack.UseManifestNamespaces); + + await this.EdgeStackService.updateStack(this.stack.Id, { + 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.state.isEditorDirty = false; + this.state.isStackDeployed = true; this.$state.go('edge.stacks'); } catch (err) { this.Notifications.error('Deployment error', err, 'Unable to deploy stack'); diff --git a/app/portainer/helpers/webhookHelper.ts b/app/portainer/helpers/webhookHelper.ts index a6dc7fb49..b0ff5f661 100644 --- a/app/portainer/helpers/webhookHelper.ts +++ b/app/portainer/helpers/webhookHelper.ts @@ -1,6 +1,10 @@ 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'; @@ -22,6 +26,10 @@ export function createWebhookId() { return uuid(); } +export function baseEdgeStackWebhookUrl() { + return `${baseUrl}${API_ENDPOINT_EDGE_STACKS}/webhooks`; +} + /* @ngInject */ export function WebhookHelperFactory() { return { diff --git a/app/portainer/services/authentication.js b/app/portainer/services/authentication.js index 02f85654d..79abfcd19 100644 --- a/app/portainer/services/authentication.js +++ b/app/portainer/services/authentication.js @@ -130,6 +130,10 @@ angular.module('portainer.app').factory('Authentication', [ return !!user && user.role === 1; } + if (process.env.NODE_ENV === 'development') { + window.login = loginAsync; + } + return service; }, ]); diff --git a/app/react/components/Tip/TextTip/TextTip.tsx b/app/react/components/Tip/TextTip/TextTip.tsx index a19e12280..97c914c61 100644 --- a/app/react/components/Tip/TextTip/TextTip.tsx +++ b/app/react/components/Tip/TextTip/TextTip.tsx @@ -4,7 +4,7 @@ import clsx from 'clsx'; import { Icon, IconMode } from '@@/Icon'; -type Color = 'orange' | 'blue'; +type Color = 'orange' | 'blue' | 'red' | 'green'; export interface Props { icon?: React.ReactNode; @@ -33,6 +33,10 @@ function getMode(color: Color): IconMode { switch (color) { case 'blue': return 'primary'; + case 'red': + return 'danger'; + case 'green': + return 'success'; case 'orange': default: return 'warning'; diff --git a/app/react/components/WebEditorForm.tsx b/app/react/components/WebEditorForm.tsx new file mode 100644 index 000000000..fe35f0597 --- /dev/null +++ b/app/react/components/WebEditorForm.tsx @@ -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: ( + <> +
Ctrl+F - Start searching
+
Ctrl+G - Find next
+
Ctrl+Shift+G - Find previous
+
Ctrl+Shift+F - Replace
+
Ctrl+Shift+R - Replace all
+
Alt+G - Jump to line
+
Persistent search:
+
Enter - Find next
+
Shift+Enter - Find previous
+ + ), + searchCmdLabel: 'Ctrl+F for search', +} as const; + +const editorConfig = { + mac: { + tooltip: ( + <> +
Cmd+F - Start searching
+
Cmd+G - Find next
+
Cmd+Shift+G - Find previous
+
Cmd+Option+F - Replace
+
Cmd+Option+R - Replace all
+
Option+G - Jump to line
+
Persistent search:
+
Enter - Find next
+
Shift+Enter - Find previous
+ + ), + 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) { + return ( +
+
+ {!hideTitle && ( + + Web editor +
+ {editorConfig[BROWSER_OS_PLATFORM].searchCmdLabel} + + +
+
+ )} + + {children && ( +
+
{children}
+
+ )} + +
+
+ +
+
+ +
+
{error}
+
+
+
+ ); +} diff --git a/app/react/docker/images/ItemView/RegistrySelectPrompt.tsx b/app/react/docker/images/ItemView/RegistrySelectPrompt.tsx index ea26c517d..024af8454 100644 --- a/app/react/docker/images/ItemView/RegistrySelectPrompt.tsx +++ b/app/react/docker/images/ItemView/RegistrySelectPrompt.tsx @@ -1,6 +1,6 @@ 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 { Button } from '@@/buttons'; diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx new file mode 100644 index 000000000..28776e35e --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/ComposeForm.tsx @@ -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(); + + return ( + <> + {hasKubeEndpoint && ( + +

+ Portainer no longer supports{' '} + + docker-compose + {' '} + format manifests for Kubernetes deployments, and we have removed the{' '} + + Kompose + {' '} + 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). +

+

+ 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. +

+

+ 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. +

+
+ )} + + handleContentChange(DeploymentType.Compose, value)} + error={errors.content} + readonly={hasKubeEndpoint} + > +
+ You can get more information about Compose file format in the{' '} + + official documentation + + . +
+
+ + ); +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx new file mode 100644 index 000000000..24d277547 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/EditEdgeStackForm.tsx @@ -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 ; + } + + 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 ( + + + + ); +} + +function InnerForm({ + onEditorChange, + edgeStack, + isSubmitting, + allowKubeToSelectCompose, +}: { + edgeStack: EdgeStack; + isSubmitting: boolean; + onEditorChange: (content: string) => void; + allowKubeToSelectCompose: boolean; +}) { + const { + values, + setFieldValue, + isValid, + + errors, + setFieldError, + } = useFormikContext(); + 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 ( +
+ setFieldValue('edgeGroups', value)} + error={errors.edgeGroups} + /> + + {atLeastTwo(hasKubeEndpoint, hasDockerEndpoint, hasNomadEndpoint) && ( + + 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. + + )} + + {values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && ( + + 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. + + )} + + { + setFieldValue('content', getCachedContent(value)); + setFieldValue('deploymentType', value); + }} + /> + + + + {isBE && ( + <> + +
+
+ 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." + /> +
+
+ + {edgeStack.Webhook && ( + + )} +
+ setFieldValue('privateRegistryId', value)} + isValid={isValid} + values={values} + stackName={edgeStack.Name} + onFieldError={(error) => setFieldError('privateRegistryId', error)} + error={errors.privateRegistryId} + /> + {values.deploymentType === DeploymentType.Compose && ( + <> + setFieldValue('prePullImage', value)} + /> + + setFieldValue('retryDeploy', value)} + /> + + )} + + )} + + +
+
+ + Update the stack + +
+
+
+ + ); + + 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 { + 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), + }); +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx new file mode 100644 index 000000000..cce19747c --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/GitForm.tsx @@ -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 ( + + {({ values, isValid }) => { + return ( + + ); + + 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(); + }, + }); + } + }} + + ); + + 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(); + + const { hasType } = useValidateEnvironmentTypes(values.groupIds); + + const hasKubeEndpoint = hasType(EnvironmentType.EdgeAgentOnKubernetes); + const hasDockerEndpoint = hasType(EnvironmentType.EdgeAgentOnDocker); + const hasNomadEndpoint = hasType(EnvironmentType.EdgeAgentOnNomad); + + return ( +
+ setFieldValue('groupIds', value)} + error={errors.groupIds} + /> + + {atLeastTwo(hasKubeEndpoint, hasDockerEndpoint, hasNomadEndpoint) && ( + + 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. + + )} + + {values.deploymentType === DeploymentType.Compose && hasKubeEndpoint && ( + + 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. + + )} + { + setFieldValue('deploymentType', value); + }} + /> + + + + + + setFieldValue('autoUpdate', { + ...values.autoUpdate, + ...value, + }) + } + baseWebhookUrl={baseEdgeStackWebhookUrl()} + errors={errors.autoUpdate} + /> + + + + setFieldValue('refName', value)} + model={{ ...values.authentication, RepositoryURL: gitUrl }} + error={errors.refName} + isUrlValid + /> + + + Object.entries(value).forEach(([key, value]) => { + setFieldValue(`authentication.${key}`, value); + }) + } + errors={errors.authentication} + /> + + + + + Pull and update stack + + + + Update settings + + + + ); +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/index.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/index.ts new file mode 100644 index 000000000..9b69bdbc8 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/index.ts @@ -0,0 +1 @@ +export { GitForm } from './GitForm'; diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/useUpdateEdgeStackGitMutation.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/useUpdateEdgeStackGitMutation.ts new file mode 100644 index 000000000..2416c0934 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/GitForm/useUpdateEdgeStackGitMutation.ts @@ -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'); + } +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx new file mode 100644 index 000000000..aab1504a3 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/KubernetesForm.tsx @@ -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(); + + return ( + <> +
+
+ setFieldValue('useManifestNamespaces', value)} + /> +
+
+ + + handleContentChange(DeploymentType.Kubernetes, value) + } + error={errors.content} + > +

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

+
+ + ); +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NomadForm.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NomadForm.tsx new file mode 100644 index 000000000..9a526e159 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/NomadForm.tsx @@ -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(); + + return ( + handleContentChange(DeploymentType.Nomad, value)} + error={errors.content} + > +

+ You can get more information about Nomad file format in the{' '} + + official documentation + + . +

+
+ ); +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx new file mode 100644 index 000000000..3eb58cadc --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/PrivateRegistryFieldsetWrapper.tsx @@ -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 ( + 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(arr: Array) { + return _.uniq(arr).length === 1; + } +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/atLeastTwo.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/atLeastTwo.ts new file mode 100644 index 000000000..d99d90f2f --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/atLeastTwo.ts @@ -0,0 +1,3 @@ +export function atLeastTwo(a: boolean, b: boolean, c: boolean) { + return (a && b) || (a && c) || (b && c); +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/types.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/types.ts new file mode 100644 index 000000000..85b6328cd --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/types.ts @@ -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; +} diff --git a/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useEdgeGroupHasType.ts b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useEdgeGroupHasType.ts new file mode 100644 index 000000000..dd5910837 --- /dev/null +++ b/app/react/edge/edge-stacks/ItemView/EditEdgeStackForm/useEdgeGroupHasType.ts @@ -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) { + 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, + }; +} diff --git a/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx b/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx index 85a2423fe..c2376c3dc 100644 --- a/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx +++ b/app/react/edge/edge-stacks/components/EdgeStackDeploymentTypeSelector.tsx @@ -1,4 +1,8 @@ +import _ from 'lodash'; + 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 { BoxSelectorOption } from '@@/BoxSelector/types'; @@ -12,6 +16,7 @@ interface Props { onChange(value: number): void; hasDockerEndpoint: boolean; hasKubeEndpoint: boolean; + hasNomadEndpoint: boolean; allowKubeToSelectCompose?: boolean; } @@ -20,29 +25,45 @@ export function EdgeStackDeploymentTypeSelector({ onChange, hasDockerEndpoint, hasKubeEndpoint, + hasNomadEndpoint, allowKubeToSelectCompose, }: Props) { - const deploymentOptions: BoxSelectorOption[] = [ + const deploymentOptions: BoxSelectorOption[] = _.compact([ { ...compose, value: EditorType.Compose, - disabled: () => (allowKubeToSelectCompose ? false : hasKubeEndpoint), + disabled: () => + allowKubeToSelectCompose + ? hasNomadEndpoint + : hasNomadEndpoint || hasKubeEndpoint, tooltip: () => - hasKubeEndpoint - ? 'Cannot use this option with Edge Kubernetes environments' + hasNomadEndpoint || hasKubeEndpoint + ? 'Cannot use this option with Edge Kubernetes or Edge Nomad environments' : '', }, { ...kubernetes, value: EditorType.Kubernetes, - disabled: () => hasDockerEndpoint, + disabled: () => hasDockerEndpoint || hasNomadEndpoint, tooltip: () => - hasDockerEndpoint - ? 'Cannot use this option with Edge Docker environments' + hasDockerEndpoint || hasNomadEndpoint + ? 'Cannot use this option with Edge Docker or Edge Nomad environments' : '', 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 ( <> diff --git a/app/react/edge/edge-stacks/components/PrivateRegistryFieldset.tsx b/app/react/edge/edge-stacks/components/PrivateRegistryFieldset.tsx new file mode 100644 index 000000000..6a50d3e19 --- /dev/null +++ b/app/react/edge/edge-stacks/components/PrivateRegistryFieldset.tsx @@ -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 ( + +
+
+ setChecked(value)} + tooltip={tooltipMessage} + label="Use Credentials" + labelClass="col-sm-3 col-lg-2" + disabled={formInvalid} + /> +
+
+ + {checked && ( + <> + {method !== 'repository' && ( + <> + + If you make any changes to the image urls in your yaml, please + reload or select registry manually + + + + + )} + {!errorMessage ? ( + +