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)
|
||||
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
|
||||
|
|
|
@ -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 {
|
||||
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)
|
||||
}
|
||||
|
|
|
@ -105,7 +105,6 @@ func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolde
|
|||
}
|
||||
|
||||
return composePath, "", projectPath, nil
|
||||
|
||||
}
|
||||
|
||||
if deploymentType == portainer.EdgeStackDeploymentKubernetes {
|
||||
|
|
|
@ -144,7 +144,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
|
|||
DeploymentType: edgeStack.DeploymentType,
|
||||
},
|
||||
Method: "string",
|
||||
ExpectedStatusCode: 500,
|
||||
ExpectedStatusCode: http.StatusConflict,
|
||||
},
|
||||
{
|
||||
Name: "Empty EdgeStack Groups",
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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{
|
||||
|
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -169,15 +169,7 @@
|
|||
</div>
|
||||
<!-- !upload -->
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Edge Groups </div>
|
||||
<div class="form-group" ng-if="$ctrl.edgeGroups">
|
||||
<div class="col-sm-12">
|
||||
<edge-groups-selector ng-if="!$ctrl.noGroups" 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>
|
||||
<edge-groups-selector ng-if="$ctrl.model.EdgeGroups" value="$ctrl.model.EdgeGroups" on-change="($ctrl.onChangeGroups)"></edge-groups-selector>
|
||||
|
||||
<div class="col-sm-12 form-section-title"> Target environments </div>
|
||||
<!-- node-selection -->
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 { 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;
|
||||
|
|
|
@ -65,9 +65,5 @@ angular.module('portainer.edge').factory('EdgeStackService', function EdgeStackS
|
|||
}
|
||||
};
|
||||
|
||||
service.update = function update(stack) {
|
||||
return EdgeStacks.update(stack).$promise;
|
||||
};
|
||||
|
||||
return service;
|
||||
});
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -39,12 +39,18 @@
|
|||
</div>
|
||||
<!-- !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
|
||||
value="$ctrl.formValues.DeploymentType"
|
||||
has-docker-endpoint="$ctrl.hasDockerEndpoint()"
|
||||
has-kube-endpoint="$ctrl.hasKubeEndpoint()"
|
||||
has-docker-endpoint="$ctrl.hasType($ctrl.EnvironmentType.EdgeAgentOnDocker)"
|
||||
has-kube-endpoint="$ctrl.hasType($ctrl.EnvironmentType.EdgeAgentOnKubernetes)"
|
||||
has-nomad-endpoint="$ctrl.hasType($ctrl.EnvironmentType.EdgeAgentOnNomad)"
|
||||
on-change="($ctrl.onChangeDeploymentType)"
|
||||
></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-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 -->
|
||||
<div ng-if="$ctrl.state.Method === 'template'">
|
||||
|
|
|
@ -32,4 +32,11 @@
|
|||
</file-upload-description>
|
||||
</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">
|
||||
<edit-edge-stack-form
|
||||
ng-if="$ctrl.edgeGroups && $ctrl.stack && $ctrl.formValues.content"
|
||||
edge-groups="$ctrl.edgeGroups"
|
||||
model="$ctrl.formValues"
|
||||
action-in-progress="$ctrl.state.actionInProgress"
|
||||
submit-action="$ctrl.deployStack"
|
||||
is-editor-dirty="$ctrl.state.isEditorDirty"
|
||||
edge-stack="$ctrl.stack"
|
||||
is-submitting="$ctrl.state.actionInProgress"
|
||||
on-submit="($ctrl.deployStack)"
|
||||
on-editor-change="($ctrl.onEditorChange)"
|
||||
file-content="$ctrl.formValues.content"
|
||||
allow-kube-to-select-compose="$ctrl.allowKubeToSelectCompose"
|
||||
></edit-edge-stack-form>
|
||||
</div>
|
||||
</uib-tab>
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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;
|
||||
},
|
||||
]);
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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 { 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';
|
||||
|
|
|
@ -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 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<number>[] = [
|
||||
const deploymentOptions: BoxSelectorOption<number>[] = _.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 (
|
||||
<>
|
||||
|
|
|
@ -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 {
|
||||
Compose,
|
||||
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';
|
||||
|
||||
|
|
|
@ -13,7 +13,11 @@ export async function createGitCredential(
|
|||
gitCredential: CreateGitCredentialPayload
|
||||
) {
|
||||
try {
|
||||
await axios.post(buildGitUrl(gitCredential.userId), gitCredential);
|
||||
const { data } = await axios.post<{ gitCredential: GitCredential }>(
|
||||
buildGitUrl(gitCredential.userId),
|
||||
gitCredential
|
||||
);
|
||||
return data.gitCredential;
|
||||
} catch (e) {
|
||||
throw parseAxiosError(e as Error, 'Unable to create git credential');
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import axios, { parseAxiosError } from '@/portainer/services/axios';
|
||||
import { TeamId } from '@/react/portainer/users/teams/types';
|
||||
import { UserId } from '@/portainer/users/types';
|
||||
import { RegistryId, Registry } from '@/react/portainer/registries/types';
|
||||
|
||||
import { EnvironmentId } from '../types';
|
||||
|
||||
|
@ -14,12 +15,6 @@ interface AccessPolicy {
|
|||
type UserAccessPolicies = Record<UserId, AccessPolicy>; // map[UserID]AccessPolicy
|
||||
type TeamAccessPolicies = Record<TeamId, AccessPolicy>;
|
||||
|
||||
export type RegistryId = number;
|
||||
export interface Registry {
|
||||
Id: RegistryId;
|
||||
Name: string;
|
||||
}
|
||||
|
||||
interface RegistryAccess {
|
||||
UserAccessPolicies: UserAccessPolicies;
|
||||
TeamAccessPolicies: TeamAccessPolicies;
|
||||
|
|
|
@ -22,3 +22,27 @@ export function parseAuthResponse(
|
|||
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 {
|
||||
Username: string;
|
||||
Password: string;
|
||||
GitCredentialID: number;
|
||||
Username?: string;
|
||||
Password?: string;
|
||||
GitCredentialID?: number;
|
||||
}
|
||||
|
||||
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