feat(edge/stacks): sync EE codechanges [EE-498] (#8580)

pull/8605/head
Chaim Lev-Ari 2023-05-31 01:33:22 +07:00 committed by GitHub
parent 0ec7dfce69
commit 93bf630105
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 1572 additions and 424 deletions

View File

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

20
api/http/errors/tx.go Normal file
View File

@ -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()
}

View File

@ -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)
}

View File

@ -105,7 +105,6 @@ func (handler *Handler) storeFileContent(tx dataservices.DataStoreTx, stackFolde
}
return composePath, "", projectPath, nil
}
if deploymentType == portainer.EdgeStackDeploymentKubernetes {

View File

@ -144,7 +144,7 @@ func TestCreateWithInvalidPayload(t *testing.T) {
DeploymentType: edgeStack.DeploymentType,
},
Method: "string",
ExpectedStatusCode: 500,
ExpectedStatusCode: http.StatusConflict,
},
{
Name: "Empty EdgeStack Groups",

View File

@ -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
}

View File

@ -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,
},

View File

@ -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{

View File

@ -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")
}
}

View File

@ -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
}

View File

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

View File

@ -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();
}
}

View File

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

View File

@ -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();
}
}

View File

@ -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: '=',
},
});

View File

@ -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;

View File

@ -65,9 +65,5 @@ angular.module('portainer.edge').factory('EdgeStackService', function EdgeStackS
}
};
service.update = function update(stack) {
return EdgeStacks.update(stack).$promise;
};
return service;
});

View File

@ -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) {

View File

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

View File

@ -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'">

View File

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

View File

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

View File

@ -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');

View File

@ -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 {

View File

@ -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;
},
]);

View File

@ -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';

View File

@ -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>
);
}

View File

@ -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';

View File

@ -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&apos;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>
</>
);
}

View File

@ -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),
});
}

View File

@ -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>
);
}

View File

@ -0,0 +1 @@
export { GitForm } from './GitForm';

View File

@ -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');
}
}

View File

@ -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>
</>
);
}

View File

@ -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>
);
}

View File

@ -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;
}
}

View File

@ -0,0 +1,3 @@
export function atLeastTwo(a: boolean, b: boolean, c: boolean) {
return (a && b) || (a && c) || (b && c);
}

View File

@ -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;
}

View File

@ -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,
};
}

View File

@ -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 (
<>

View File

@ -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>
);
}

View File

@ -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;
}

View File

@ -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,
};

View File

@ -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);
}
}

View File

@ -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,

View File

@ -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';

View File

@ -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');
}

View File

@ -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;

View File

@ -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,
};
}

View File

@ -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 {

View File

@ -0,0 +1,3 @@
export const queryKeys = {
registries: () => ['registries'] as const,
};

View File

@ -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');
}
}

View File

@ -0,0 +1,5 @@
export type RegistryId = number;
export interface Registry {
Id: RegistryId;
Name: string;
}