1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-08-05 13:55:21 +02:00

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

This commit is contained in:
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

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