1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-27 17:29:39 +02:00

fix(custom-templates): relax custom template validation and enforce stack name validation [EE-7102] (#11937)
Some checks failed
/ triage (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
ci / build_images (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
ci / build_images (map[arch:arm platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:arm64 platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:ppc64le platform:linux version:]) (push) Has been cancelled
ci / build_images (map[arch:s390x platform:linux version:]) (push) Has been cancelled
Lint / Run linters (push) Has been cancelled
Test / test-client (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:linux]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:1809]) (push) Has been cancelled
Test / test-server (map[arch:amd64 platform:windows version:ltsc2022]) (push) Has been cancelled
Test / test-server (map[arch:arm64 platform:linux]) (push) Has been cancelled
ci / build_manifests (push) Has been cancelled

Co-authored-by: testa113 <testa113>
This commit is contained in:
Ali 2024-06-17 09:24:50 +12:00 committed by GitHub
parent 5182220d0a
commit e7af3296fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 207 additions and 180 deletions

View file

@ -142,7 +142,14 @@ export const ngModule = angular
), ),
{ stackName: 'setStackName' } { stackName: 'setStackName' }
), ),
['setStackName', 'stackName', 'stacks', 'inputClassName', 'textTip'] [
'setStackName',
'stackName',
'stacks',
'inputClassName',
'textTip',
'error',
]
) )
) )
.component( .component(

View file

@ -172,6 +172,7 @@
text-tip="'Enter or select a \'stack\' name to group multiple deployments together, or else leave empty to ignore.'" text-tip="'Enter or select a \'stack\' name to group multiple deployments together, or else leave empty to ignore.'"
stacks="ctrl.stacks" stacks="ctrl.stacks"
input-class-name="'col-lg-10 col-sm-9'" input-class-name="'col-lg-10 col-sm-9'"
error="ctrl.state.stackNameError"
></kube-stack-name> ></kube-stack-name>
<!-- #endregion --> <!-- #endregion -->
@ -234,6 +235,7 @@
text-tip="'Enter or select a \'stack\' name to group multiple deployments together, or else leave empty to ignore.'" text-tip="'Enter or select a \'stack\' name to group multiple deployments together, or else leave empty to ignore.'"
stacks="ctrl.stacks" stacks="ctrl.stacks"
input-class-name="'col-lg-10 col-sm-9'" input-class-name="'col-lg-10 col-sm-9'"
error="ctrl.state.stackNameError"
></kube-stack-name> ></kube-stack-name>
<!-- #endregion --> <!-- #endregion -->

View file

@ -28,6 +28,7 @@ import { confirmUpdateAppIngress } from '@/react/kubernetes/applications/CreateV
import { confirm, confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm'; import { confirm, confirmUpdate, confirmWebEditorDiscard } from '@@/modals/confirm';
import { buildConfirmButton } from '@@/modals/utils'; import { buildConfirmButton } from '@@/modals/utils';
import { ModalType } from '@@/modals'; import { ModalType } from '@@/modals';
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
class KubernetesCreateApplicationController { class KubernetesCreateApplicationController {
/* #region CONSTRUCTOR */ /* #region CONSTRUCTOR */
@ -127,6 +128,7 @@ class KubernetesCreateApplicationController {
// a validation message will be shown. isExistingCPUReservationUnchanged and isExistingMemoryReservationUnchanged (with available resources being exceeded) is used to decide whether to show the message or not. // a validation message will be shown. isExistingCPUReservationUnchanged and isExistingMemoryReservationUnchanged (with available resources being exceeded) is used to decide whether to show the message or not.
isExistingCPUReservationUnchanged: false, isExistingCPUReservationUnchanged: false,
isExistingMemoryReservationUnchanged: false, isExistingMemoryReservationUnchanged: false,
stackNameError: '',
}; };
this.isAdmin = this.Authentication.isAdmin(); this.isAdmin = this.Authentication.isAdmin();
@ -186,9 +188,16 @@ class KubernetesCreateApplicationController {
} }
/* #endregion */ /* #endregion */
onChangeStackName(stackName) { onChangeStackName(name) {
return this.$async(async () => { return this.$async(async () => {
this.formValues.StackName = stackName; if (KUBE_STACK_NAME_VALIDATION_REGEX.test(name) || name === '') {
this.state.stackNameError = '';
} else {
this.state.stackNameError =
"Stack must consist of alphanumeric characters, '-', '_' or '.', must start and end with an alphanumeric character and must be 63 characters or less (e.g. 'my-name', or 'abc-123').";
}
this.formValues.StackName = name;
}); });
} }
@ -644,7 +653,8 @@ class KubernetesCreateApplicationController {
const invalid = !this.isValid(); const invalid = !this.isValid();
const hasNoChanges = this.isEditAndNoChangesMade(); const hasNoChanges = this.isEditAndNoChangesMade();
const nonScalable = this.isNonScalable(); const nonScalable = this.isNonScalable();
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable; const stackNameInvalid = this.state.stackNameError !== '';
return overflow || autoScalerOverflow || inProgress || invalid || hasNoChanges || nonScalable || stackNameInvalid;
} }
isUpdateApplicationViaWebEditorButtonDisabled() { isUpdateApplicationViaWebEditorButtonDisabled() {

View file

@ -90,7 +90,12 @@
<div class="w-fit mb-4"> <div class="w-fit mb-4">
<stack-name-label-insight></stack-name-label-insight> <stack-name-label-insight></stack-name-label-insight>
</div> </div>
<kube-stack-name stack-name="ctrl.formValues.StackName" set-stack-name="(ctrl.setStackName)" stacks="ctrl.stacks"></kube-stack-name> <kube-stack-name
stack-name="ctrl.formValues.StackName"
set-stack-name="(ctrl.setStackName)"
stacks="ctrl.stacks"
error="ctrl.state.stackNameError"
></kube-stack-name>
</div> </div>
<!-- !namespace --> <!-- !namespace -->

View file

@ -12,6 +12,7 @@ import { parseAutoUpdateResponse, transformAutoUpdateViewModel } from '@/react/p
import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper'; import { baseStackWebhookUrl, createWebhookId } from '@/portainer/helpers/webhookHelper';
import { confirmWebEditorDiscard } from '@@/modals/confirm'; import { confirmWebEditorDiscard } from '@@/modals/confirm';
import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField'; import { getVariablesFieldDefaultValues } from '@/react/portainer/custom-templates/components/CustomTemplatesVariablesField';
import { KUBE_STACK_NAME_VALIDATION_REGEX } from '@/react/kubernetes/DeployView/StackName/constants';
class KubernetesDeployController { class KubernetesDeployController {
/* @ngInject */ /* @ngInject */
@ -57,6 +58,7 @@ class KubernetesDeployController {
templateLoadFailed: false, templateLoadFailed: false,
isEditorReadOnly: false, isEditorReadOnly: false,
selectedHelmChart: '', selectedHelmChart: '',
stackNameError: '',
}; };
this.currentUser = { this.currentUser = {
@ -117,7 +119,16 @@ class KubernetesDeployController {
} }
setStackName(name) { setStackName(name) {
this.formValues.StackName = name; return this.$async(async () => {
if (KUBE_STACK_NAME_VALIDATION_REGEX.test(name) || name === '') {
this.state.stackNameError = '';
} else {
this.state.stackNameError =
"Stack must consist of alphanumeric characters, '-', '_' or '.', must start and end with an alphanumeric character and must be 63 characters or less (e.g. 'my-name', or 'abc-123').";
}
this.formValues.StackName = name;
});
} }
renderTemplate() { renderTemplate() {
@ -197,9 +208,9 @@ class KubernetesDeployController {
const isWebEditorInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent); const isWebEditorInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.WEB_EDITOR && _.isEmpty(this.formValues.EditorContent);
const isURLFormInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.URL && _.isEmpty(this.formValues.ManifestURL); const isURLFormInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.URL && _.isEmpty(this.formValues.ManifestURL);
const isCustomTemplateInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.CUSTOM_TEMPLATE && _.isEmpty(this.formValues.EditorContent); const isCustomTemplateInvalid = this.state.BuildMethod === KubernetesDeployBuildMethods.CUSTOM_TEMPLATE && _.isEmpty(this.formValues.EditorContent);
const isStackNameInvalid = this.state.stackNameError !== '';
const isNamespaceInvalid = _.isEmpty(this.formValues.Namespace); const isNamespaceInvalid = _.isEmpty(this.formValues.Namespace);
return isWebEditorInvalid || isURLFormInvalid || isCustomTemplateInvalid || this.state.actionInProgress || isNamespaceInvalid; return isWebEditorInvalid || isURLFormInvalid || isCustomTemplateInvalid || this.state.actionInProgress || isNamespaceInvalid || isStackNameInvalid;
} }
onChangeFormValues(newValues) { onChangeFormValues(newValues) {

View file

@ -1,86 +1,84 @@
<div class="col-sm-12"> <rd-widget>
<rd-widget> <rd-widget-custom-header icon="$ctrl.template.Logo" title-text="$ctrl.template.Title"></rd-widget-custom-header>
<rd-widget-custom-header icon="$ctrl.template.Logo" title-text="$ctrl.template.Title"></rd-widget-custom-header> <rd-widget-body classes="padding">
<rd-widget-body classes="padding"> <form class="form-horizontal" name="stackTemplateForm">
<form class="form-horizontal" name="stackTemplateForm"> <!-- description -->
<!-- description --> <div ng-if="$ctrl.template.Note">
<div ng-if="$ctrl.template.Note"> <div class="form-section-title"> Information </div>
<div class="form-section-title"> Information </div> <div class="col-sm-12 form-group">
<div class="col-sm-12 form-group"> <div class="template-note" ng-bind-html="$ctrl.template.Note"></div>
<div class="template-note" ng-bind-html="$ctrl.template.Note"></div>
</div>
</div> </div>
<!-- !description --> </div>
<div class="form-section-title"> Configuration </div> <!-- !description -->
<!-- name-input --> <div class="form-section-title"> Configuration </div>
<div class="form-group"> <!-- name-input -->
<label for="template_name" class="col-sm-2 control-label text-left">Name</label> <div class="form-group">
<div class="col-sm-6"> <label for="template_name" class="col-sm-2 control-label text-left">Name</label>
<input type="text" name="template_name" class="form-control" ng-model="$ctrl.formValues.name" ng-pattern="$ctrl.nameRegex" placeholder="e.g. myStack" required /> <div class="col-sm-6">
<div class="form-group" ng-if="stackTemplateForm.template_name.$invalid"> <input type="text" name="template_name" class="form-control" ng-model="$ctrl.formValues.name" ng-pattern="$ctrl.nameRegex" placeholder="e.g. mystack" required />
<div class="col-sm-12 small text-warning"> <div class="form-group" ng-if="stackTemplateForm.template_name.$invalid">
<div ng-messages="stackTemplateForm.template_name.$error"> <div class="col-sm-12 small text-warning">
<p ng-message="pattern" class="vertical-center"> <div ng-messages="stackTemplateForm.template_name.$error">
<pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon> <p ng-message="pattern" class="vertical-center">
<span>This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').</span> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>
</p> <span>This field must consist of lower case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').</span>
<p ng-message="required" class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This field is required. </p> </p>
</div> <p ng-message="required" class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This field is required. </p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div>
<!-- !name-input --> <!-- !name-input -->
<!-- env --> <!-- env -->
<div ng-repeat="var in $ctrl.template.Env" ng-if="!var.preset || var.select" class="form-group"> <div ng-repeat="var in $ctrl.template.Env" ng-if="!var.preset || var.select" class="form-group">
<label for="field_{{ $index }}" class="col-sm-2 control-label text-left"> <label for="field_{{ $index }}" class="col-sm-2 control-label text-left">
{{ var.label }} {{ var.label }}
<portainer-tooltip ng-if="var.description" message="var.description"></portainer-tooltip> <portainer-tooltip ng-if="var.description" message="var.description"></portainer-tooltip>
</label> </label>
<div class="col-sm-6"> <div class="col-sm-6">
<input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}" /> <input type="text" class="form-control" ng-if="!var.select" ng-model="var.value" id="field_{{ $index }}" />
<select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}"> <select class="form-control" ng-if="var.select" ng-model="var.value" id="field_{{ $index }}">
<option selected disabled hidden value="">Select value</option> <option selected disabled hidden value="">Select value</option>
<option ng-repeat="choice in var.select" value="{{ choice.value }}">{{ choice.text }}</option> <option ng-repeat="choice in var.select" value="{{ choice.value }}">{{ choice.text }}</option>
</select> </select>
</div>
</div> </div>
<!-- !env --> </div>
<ng-transclude ng-transclude-slot="advanced"></ng-transclude> <!-- !env -->
<ng-transclude ng-transclude-slot="advanced"></ng-transclude>
<!-- access-control --> <!-- access-control -->
<por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form> <por-access-control-form form-data="$ctrl.formValues.AccessControlData"></por-access-control-form>
<!-- !access-control --> <!-- !access-control -->
<!-- actions --> <!-- actions -->
<div class="form-section-title"> Actions </div> <div class="form-section-title"> Actions </div>
<div class="form-group"> <div class="form-group">
<div class="col-sm-12"> <div class="col-sm-12">
<button <button
type="button" type="button"
class="btn btn-primary" class="btn btn-primary"
ng-disabled="$ctrl.state.actionInProgress || !$ctrl.formValues.name || !$ctrl.state.deployable || stackTemplateForm.$invalid" ng-disabled="$ctrl.state.actionInProgress || !$ctrl.formValues.name || !$ctrl.state.deployable || stackTemplateForm.$invalid"
ng-click="$ctrl.createTemplate()" ng-click="$ctrl.createTemplate()"
button-spinner="$ctrl.state.actionInProgress" button-spinner="$ctrl.state.actionInProgress"
> >
<span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span> <span ng-hide="$ctrl.state.actionInProgress">Deploy the stack</span>
<span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span> <span ng-show="$ctrl.state.actionInProgress">Deployment in progress...</span>
</button> </button>
<button type="button" class="btn btn-default" ng-click="$ctrl.unselectTemplate($ctrl.template)">Hide</button> <button type="button" class="btn btn-default" ng-click="$ctrl.unselectTemplate($ctrl.template)">Hide</button>
<div class="form-group" ng-if="$ctrl.state.formValidationError"> <div class="form-group" ng-if="$ctrl.state.formValidationError">
<div class="col-sm-12 small text-danger" ng-if="$ctrl.state.formValidationError"> <div class="col-sm-12 small text-danger" ng-if="$ctrl.state.formValidationError">
<p class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>{{ $ctrl.state.formValidationError }} </p> <p class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>{{ $ctrl.state.formValidationError }} </p>
</div>
</div> </div>
<div class="form-group" ng-if="!$ctrl.state.deployable"> </div>
<div class="col-sm-12 small text-danger" ng-if="!$ctrl.state.deployable"> <div class="form-group" ng-if="!$ctrl.state.deployable">
<p class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This template type cannot be deployed on this environment. </p> <div class="col-sm-12 small text-danger" ng-if="!$ctrl.state.deployable">
</div> <p class="vertical-center"> <pr-icon icon="'alert-triangle'" mode="'warning'"></pr-icon>This template type cannot be deployed on this environment. </p>
</div> </div>
</div> </div>
</div> </div>
<!-- !actions --> </div>
</form> <!-- !actions -->
</rd-widget-body> </form>
</rd-widget> </rd-widget-body>
</div> </rd-widget>

View file

@ -1,66 +1,68 @@
<page-header title="'Custom Templates'" breadcrumbs="['Custom Templates']" reload="true"> </page-header> <page-header title="'Custom Templates'" breadcrumbs="['Custom Templates']" reload="true"> </page-header>
<div class="row"> <div class="row">
<stack-from-template-form <div class="col-sm-12">
ng-if="$ctrl.state.selectedTemplate" <stack-from-template-form
template="$ctrl.state.selectedTemplate" ng-if="$ctrl.state.selectedTemplate"
form-values="$ctrl.formValues" template="$ctrl.state.selectedTemplate"
name-regex="$ctrl.state.templateNameRegex" form-values="$ctrl.formValues"
state="$ctrl.state" name-regex="$ctrl.state.templateNameRegex"
create-template="$ctrl.createStack" state="$ctrl.state"
unselect-template="$ctrl.unselectTemplate" create-template="$ctrl.createStack"
> unselect-template="$ctrl.unselectTemplate"
<advanced-form> >
<custom-templates-variables-field <advanced-form>
ng-if="$ctrl.isTemplateVariablesEnabled" <custom-templates-variables-field
definitions="$ctrl.state.selectedTemplate.Variables" ng-if="$ctrl.isTemplateVariablesEnabled"
value="$ctrl.formValues.variables" definitions="$ctrl.state.selectedTemplate.Variables"
on-change="($ctrl.onChangeTemplateVariables)" value="$ctrl.formValues.variables"
></custom-templates-variables-field> on-change="($ctrl.onChangeTemplateVariables)"
></custom-templates-variables-field>
<div class="form-group" ng-if="$ctrl.state.selectedTemplate && !$ctrl.state.templateLoadFailed"> <div class="form-group" ng-if="$ctrl.state.selectedTemplate && !$ctrl.state.templateLoadFailed">
<div class="col-sm-12"> <div class="col-sm-12">
<a class="small interactive vertical-center" ng-show="!$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = true;"> <a class="small interactive vertical-center" ng-show="!$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = true;">
<pr-icon icon="'plus'" class-name="space-right" feather="true"></pr-icon> {{ $ctrl.state.selectedTemplate.GitConfig !== null ? 'View' : 'Customize' }} stack <pr-icon icon="'plus'" class-name="space-right" feather="true"></pr-icon> {{ $ctrl.state.selectedTemplate.GitConfig !== null ? 'View' : 'Customize' }} stack
</a> </a>
<a class="small interactive vertical-center" ng-show="$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = false;"> <a class="small interactive vertical-center" ng-show="$ctrl.state.showAdvancedOptions" ng-click="$ctrl.state.showAdvancedOptions = false;">
<pr-icon icon="'minus'" class-name="space-right" feather="true"></pr-icon> Hide {{ $ctrl.state.selectedTemplate.GitConfig === null ? 'custom' : '' }} stack <pr-icon icon="'minus'" class-name="space-right" feather="true"></pr-icon> Hide {{ $ctrl.state.selectedTemplate.GitConfig === null ? 'custom' : '' }} stack
</a> </a>
</div>
</div> </div>
</div>
<span ng-if="$ctrl.state.selectedTemplate && $ctrl.state.templateLoadFailed"> <span ng-if="$ctrl.state.selectedTemplate && $ctrl.state.templateLoadFailed">
<p class="small vertical-center text-danger mb-5" ng-if="$ctrl.currentUser.isAdmin || $ctrl.currentUser.id === $ctrl.state.selectedTemplate.CreatedByUserId"> <p class="small vertical-center text-danger mb-5" ng-if="$ctrl.currentUser.isAdmin || $ctrl.currentUser.id === $ctrl.state.selectedTemplate.CreatedByUserId">
<pr-icon icon="'alert-triangle'" mode="'danger'" size="'md'" feather="true"></pr-icon>Custom template could not be loaded, please <pr-icon icon="'alert-triangle'" mode="'danger'" size="'md'" feather="true"></pr-icon>Custom template could not be loaded, please
<a ui-sref="docker.templates.custom.edit({id: $ctrl.state.selectedTemplate.Id})">click here</a> for configuration.</p <a ui-sref="docker.templates.custom.edit({id: $ctrl.state.selectedTemplate.Id})">click here</a> for configuration.</p
> >
<p class="small vertical-center text-danger mb-5" ng-if="!($ctrl.currentUser.isAdmin || $ctrl.currentUser.id === $ctrl.state.selectedTemplate.CreatedByUserId)"> <p class="small vertical-center text-danger mb-5" ng-if="!($ctrl.currentUser.isAdmin || $ctrl.currentUser.id === $ctrl.state.selectedTemplate.CreatedByUserId)">
<pr-icon icon="'alert-triangle'" mode="'danger'" size="'md'" feather="true"></pr-icon>Custom template could not be loaded, please contact your administrator.</p <pr-icon icon="'alert-triangle'" mode="'danger'" size="'md'" feather="true"></pr-icon>Custom template could not be loaded, please contact your administrator.</p
> >
</span> </span>
<!-- web-editor --> <!-- web-editor -->
<web-editor-form <web-editor-form
ng-if="$ctrl.state.showAdvancedOptions" ng-if="$ctrl.state.showAdvancedOptions"
identifier="custom-template-creation-editor" identifier="custom-template-creation-editor"
value="$ctrl.formValues.fileContent" value="$ctrl.formValues.fileContent"
on-change="($ctrl.editorUpdate)" on-change="($ctrl.editorUpdate)"
ng-required="true" ng-required="true"
yml="true" yml="true"
placeholder="Define or paste the content of your docker compose file here" placeholder="Define or paste the content of your docker compose file here"
read-only="$ctrl.state.isEditorReadOnly" read-only="$ctrl.state.isEditorReadOnly"
> >
<editor-description> <editor-description>
<p> <p>
You can get more information about Compose file format in the 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> <a href="https://docs.docker.com/compose/compose-file/" target="_blank"> official documentation </a>
. .
</p> </p>
</editor-description> </editor-description>
</web-editor-form> </web-editor-form>
<!-- !web-editor --> <!-- !web-editor -->
</advanced-form> </advanced-form>
</stack-from-template-form> </stack-from-template-form>
</div>
</div> </div>
<custom-templates-list <custom-templates-list

View file

@ -218,7 +218,7 @@ class CustomTemplatesViewController {
return o.Name === 'bridge'; return o.Name === 'bridge';
}); });
this.formValues.name = template.Title ? template.Title : ''; this.formValues.name = '';
this.state.selectedTemplate = template; this.state.selectedTemplate = template;
this.$anchorScroll('view-top'); this.$anchorScroll('view-top');
const applicationState = this.StateManager.getState(); const applicationState = this.StateManager.getState();

View file

@ -2,15 +2,18 @@
<div class="row"> <div class="row">
<!-- stack-form --> <!-- stack-form -->
<stack-from-template-form <div class="col-sm-12">
ng-if="state.selectedTemplate && (state.selectedTemplate.Type === 2 || state.selectedTemplate.Type === 3)" <stack-from-template-form
template="state.selectedTemplate" ng-if="state.selectedTemplate && (state.selectedTemplate.Type === 2 || state.selectedTemplate.Type === 3)"
form-values="formValues" template="state.selectedTemplate"
state="state" form-values="formValues"
create-template="createTemplate" name-regex="state.templateNameRegex"
unselect-template="unselectTemplate" state="state"
> create-template="createTemplate"
</stack-from-template-form> unselect-template="unselectTemplate"
>
</stack-from-template-form>
</div>
<!-- !stack-form --> <!-- !stack-form -->
<!-- container-form --> <!-- container-form -->
<div class="col-sm-12" ng-if="state.selectedTemplate && state.selectedTemplate.Type === 1"> <div class="col-sm-12" ng-if="state.selectedTemplate && state.selectedTemplate.Type === 1">

View file

@ -1,5 +1,6 @@
import _ from 'lodash-es'; import _ from 'lodash-es';
import { TemplateType } from '@/react/portainer/templates/app-templates/types'; import { TemplateType } from '@/react/portainer/templates/app-templates/types';
import { TEMPLATE_NAME_VALIDATION_REGEX } from '@/react/portainer/custom-templates/components/CommonFields';
import { AccessControlFormData } from '../../components/accessControlForm/porAccessControlFormModel'; import { AccessControlFormData } from '../../components/accessControlForm/porAccessControlFormModel';
angular.module('portainer.app').controller('TemplatesController', [ angular.module('portainer.app').controller('TemplatesController', [
@ -47,6 +48,7 @@ angular.module('portainer.app').controller('TemplatesController', [
showAdvancedOptions: false, showAdvancedOptions: false,
formValidationError: '', formValidationError: '',
actionInProgress: false, actionInProgress: false,
templateNameRegex: TEMPLATE_NAME_VALIDATION_REGEX,
}; };
$scope.enabledTypes = [TemplateType.Container, TemplateType.ComposeStack]; $scope.enabledTypes = [TemplateType.Container, TemplateType.ComposeStack];

View file

@ -33,7 +33,7 @@ export const textByType = {
(Deployment, Secret, ConfigMap...) (Deployment, Secret, ConfigMap...)
</p> </p>
<p> <p>
You can get more information about Kubernetes file format in the You can get more information about Kubernetes file format in the{' '}
<a <a
href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/" href="https://kubernetes.io/docs/concepts/overview/working-with-objects/kubernetes-objects/"
target="_blank" target="_blank"

View file

@ -6,6 +6,7 @@ import { Link } from '@@/Link';
import { TextTip } from '@@/Tip/TextTip'; import { TextTip } from '@@/Tip/TextTip';
import { Tooltip } from '@@/Tip/Tooltip'; import { Tooltip } from '@@/Tip/Tooltip';
import { AutocompleteSelect } from '@@/form-components/AutocompleteSelect'; import { AutocompleteSelect } from '@@/form-components/AutocompleteSelect';
import { FormError } from '@@/form-components/FormError';
type Props = { type Props = {
stackName: string; stackName: string;
@ -13,6 +14,7 @@ type Props = {
stacks?: string[]; stacks?: string[];
inputClassName?: string; inputClassName?: string;
textTip?: string; textTip?: string;
error?: string;
}; };
export function StackName({ export function StackName({
@ -21,6 +23,7 @@ export function StackName({
stacks = [], stacks = [],
inputClassName, inputClassName,
textTip = "Enter or select a 'stack' name to group multiple deployments together, or else leave empty to ignore.", textTip = "Enter or select a 'stack' name to group multiple deployments together, or else leave empty to ignore.",
error = '',
}: Props) { }: Props) {
const isAdminQuery = useIsEdgeAdmin(); const isAdminQuery = useIsEdgeAdmin();
const stackResults = useMemo( const stackResults = useMemo(
@ -50,9 +53,11 @@ export function StackName({
return ( return (
<> <>
<TextTip className="mb-4" color="blue"> {textTip ? (
{textTip} <TextTip className="mb-4" color="blue">
</TextTip> {textTip}
</TextTip>
) : null}
<div className="form-group"> <div className="form-group">
<label <label
htmlFor="stack_name" htmlFor="stack_name"
@ -72,6 +77,7 @@ export function StackName({
placeholder="e.g. myStack" placeholder="e.g. myStack"
inputId="stack_name" inputId="stack_name"
/> />
{error ? <FormError>{error}</FormError> : null}
</div> </div>
</div> </div>
</> </>

View file

@ -0,0 +1,4 @@
// this regex is to satisfy k8s label validation rules
// alphanumeric, lowercase, uppercase, can contain dashes, dots and underscores, max 63 characters
export const KUBE_STACK_NAME_VALIDATION_REGEX =
/^(([a-zA-Z0-9](?:(?:[-a-zA-Z0-9_.]){0,61}[a-zA-Z0-9])?))$/;

View file

@ -91,14 +91,10 @@ export function CommonFields({
export function validation({ export function validation({
currentTemplateId, currentTemplateId,
templates = [], templates = [],
viewType = 'docker',
}: { }: {
currentTemplateId?: CustomTemplate['Id']; currentTemplateId?: CustomTemplate['Id'];
templates?: Array<CustomTemplate>; templates?: Array<CustomTemplate>;
viewType?: 'kube' | 'docker' | 'edge';
} = {}): SchemaOf<Values> { } = {}): SchemaOf<Values> {
const titlePattern = titlePatternValidation(viewType);
return object({ return object({
Title: string() Title: string()
.required('Title is required.') .required('Title is required.')
@ -112,7 +108,10 @@ export function validation({
template.Title === value && template.Id !== currentTemplateId template.Title === value && template.Id !== currentTemplateId
) )
) )
.matches(titlePattern.pattern, titlePattern.error), .max(
200,
'Custom template title must be less than or equal to 200 characters'
),
Description: string().required('Description is required.'), Description: string().required('Description is required.'),
Note: string().default(''), Note: string().default(''),
Logo: string().default(''), Logo: string().default(''),
@ -120,23 +119,3 @@ export function validation({
} }
export const TEMPLATE_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$'; export const TEMPLATE_NAME_VALIDATION_REGEX = '^[-_a-z0-9]+$';
const KUBE_TEMPLATE_NAME_VALIDATION_REGEX =
'^(([a-z0-9](?:(?:[-a-z0-9_.]){0,61}[a-z0-9])?))$'; // alphanumeric, lowercase, can contain dashes, dots and underscores, max 63 characters
function titlePatternValidation(type: 'kube' | 'docker' | 'edge') {
switch (type) {
case 'kube':
return {
pattern: new RegExp(KUBE_TEMPLATE_NAME_VALIDATION_REGEX),
error:
"This field must consist of lower-case alphanumeric characters, '.', '_' or '-', must start and end with an alphanumeric character and must be 63 characters or less (e.g. 'my-name', or 'abc-123').",
};
default:
return {
pattern: new RegExp(TEMPLATE_NAME_VALIDATION_REGEX),
error:
"This field must consist of lower-case alphanumeric characters, '_' or '-' (e.g. 'my-name', or 'abc-123').",
};
}
}

View file

@ -6,7 +6,7 @@ import { MetadataFieldset } from './MetadataFieldset';
export function MoreSettingsSection({ children }: PropsWithChildren<unknown>) { export function MoreSettingsSection({ children }: PropsWithChildren<unknown>) {
return ( return (
<FormSection title="More settings" isFoldable> <FormSection title="More settings" className="ml-0" isFoldable>
<div className="ml-8"> <div className="ml-8">
{children} {children}

View file

@ -65,7 +65,6 @@ export function useValidation({
}).concat( }).concat(
commonFieldsValidation({ commonFieldsValidation({
templates: customTemplatesQuery.data, templates: customTemplatesQuery.data,
viewType,
}) })
), ),
[customTemplatesQuery.data, gitCredentialsQuery.data, viewType] [customTemplatesQuery.data, gitCredentialsQuery.data, viewType]

View file

@ -55,7 +55,6 @@ export function useValidation({
commonFieldsValidation({ commonFieldsValidation({
templates: customTemplatesQuery.data, templates: customTemplatesQuery.data,
currentTemplateId: templateId, currentTemplateId: templateId,
viewType,
}) })
), ),
[ [