feature(kubernetes): stack name made optional & add toggle to disable stack in kubernetes [EE-6170] (#10436)

pull/10479/head
Prabhat Khera 2023-10-16 14:08:06 +13:00 committed by GitHub
parent 44d66cc633
commit 7840e0bfe1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 305 additions and 47 deletions

View File

@ -602,6 +602,9 @@
"EnableTelemetry": true, "EnableTelemetry": true,
"EnforceEdgeID": false, "EnforceEdgeID": false,
"FeatureFlagSettings": null, "FeatureFlagSettings": null,
"GlobalDeploymentOptions": {
"hideStacksFunctionality": false
},
"HelmRepositoryURL": "https://charts.bitnami.com/bitnami", "HelmRepositoryURL": "https://charts.bitnami.com/bitnami",
"InternalAuthSettings": { "InternalAuthSettings": {
"RequiredPasswordLength": 12 "RequiredPasswordLength": 12

View File

@ -17,6 +17,8 @@ type publicSettingsResponse struct {
AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod" example:"1"` AuthenticationMethod portainer.AuthenticationMethod `json:"AuthenticationMethod" example:"1"`
// The minimum required length for a password of any user when using internal auth mode // The minimum required length for a password of any user when using internal auth mode
RequiredPasswordLength int `json:"RequiredPasswordLength" example:"1"` RequiredPasswordLength int `json:"RequiredPasswordLength" example:"1"`
// Deployment options for encouraging deployment as code
GlobalDeploymentOptions portainer.GlobalDeploymentOptions `json:"GlobalDeploymentOptions"`
// Show the Kompose build option (discontinued in 2.18) // Show the Kompose build option (discontinued in 2.18)
ShowKomposeBuildOption bool `json:"ShowKomposeBuildOption" example:"false"` ShowKomposeBuildOption bool `json:"ShowKomposeBuildOption" example:"false"`
// Whether edge compute features are enabled // Whether edge compute features are enabled
@ -78,6 +80,7 @@ func generatePublicSettings(appSettings *portainer.Settings) *publicSettingsResp
AuthenticationMethod: appSettings.AuthenticationMethod, AuthenticationMethod: appSettings.AuthenticationMethod,
RequiredPasswordLength: appSettings.InternalAuthSettings.RequiredPasswordLength, RequiredPasswordLength: appSettings.InternalAuthSettings.RequiredPasswordLength,
EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures, EnableEdgeComputeFeatures: appSettings.EnableEdgeComputeFeatures,
GlobalDeploymentOptions: appSettings.GlobalDeploymentOptions,
ShowKomposeBuildOption: appSettings.ShowKomposeBuildOption, ShowKomposeBuildOption: appSettings.ShowKomposeBuildOption,
EnableTelemetry: appSettings.EnableTelemetry, EnableTelemetry: appSettings.EnableTelemetry,
KubeconfigExpiry: appSettings.KubeconfigExpiry, KubeconfigExpiry: appSettings.KubeconfigExpiry,

View File

@ -32,7 +32,8 @@ type settingsUpdatePayload struct {
SnapshotInterval *string `example:"5m"` SnapshotInterval *string `example:"5m"`
// URL to the templates that will be displayed in the UI when navigating to App Templates // URL to the templates that will be displayed in the UI when navigating to App Templates
TemplatesURL *string `example:"https://raw.githubusercontent.com/portainer/templates/master/templates.json"` TemplatesURL *string `example:"https://raw.githubusercontent.com/portainer/templates/master/templates.json"`
// The default check in interval for edge agent (in seconds) // Deployment options for encouraging deployment as code
GlobalDeploymentOptions *portainer.GlobalDeploymentOptions // The default check in interval for edge agent (in seconds)
EdgeAgentCheckinInterval *int `example:"5"` EdgeAgentCheckinInterval *int `example:"5"`
// Show the Kompose build option (discontinued in 2.18) // Show the Kompose build option (discontinued in 2.18)
ShowKomposeBuildOption *bool `json:"ShowKomposeBuildOption" example:"false"` ShowKomposeBuildOption *bool `json:"ShowKomposeBuildOption" example:"false"`
@ -159,6 +160,11 @@ func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload sett
settings.TemplatesURL = *payload.TemplatesURL settings.TemplatesURL = *payload.TemplatesURL
} }
// update the global deployment options, and the environment deployment options if they have changed
if payload.GlobalDeploymentOptions != nil {
settings.GlobalDeploymentOptions = *payload.GlobalDeploymentOptions
}
if payload.ShowKomposeBuildOption != nil { if payload.ShowKomposeBuildOption != nil {
settings.ShowKomposeBuildOption = *payload.ShowKomposeBuildOption settings.ShowKomposeBuildOption = *payload.ShowKomposeBuildOption
} }

View File

@ -94,9 +94,7 @@ func (payload *kubernetesStringDeploymentPayload) Validate(r *http.Request) erro
if govalidator.IsNull(payload.StackFileContent) { if govalidator.IsNull(payload.StackFileContent) {
return errors.New("Invalid stack file content") return errors.New("Invalid stack file content")
} }
if govalidator.IsNull(payload.StackName) {
return errors.New("Invalid stack name")
}
return nil return nil
} }
@ -113,9 +111,6 @@ func (payload *kubernetesGitDeploymentPayload) Validate(r *http.Request) error {
if err := update.ValidateAutoUpdateSettings(payload.AutoUpdate); err != nil { if err := update.ValidateAutoUpdateSettings(payload.AutoUpdate); err != nil {
return err return err
} }
if govalidator.IsNull(payload.StackName) {
return errors.New("Invalid stack name")
}
return nil return nil
} }
@ -123,9 +118,6 @@ func (payload *kubernetesManifestURLDeploymentPayload) Validate(r *http.Request)
if govalidator.IsNull(payload.ManifestURL) || !govalidator.IsURL(payload.ManifestURL) { if govalidator.IsNull(payload.ManifestURL) || !govalidator.IsURL(payload.ManifestURL) {
return errors.New("Invalid manifest URL") return errors.New("Invalid manifest URL")
} }
if govalidator.IsNull(payload.StackName) {
return errors.New("Invalid stack name")
}
return nil return nil
} }

View File

@ -24,6 +24,8 @@ import (
type kubernetesFileStackUpdatePayload struct { type kubernetesFileStackUpdatePayload struct {
StackFileContent string StackFileContent string
// Name of the stack
StackName string
} }
type kubernetesGitStackUpdatePayload struct { type kubernetesGitStackUpdatePayload struct {
@ -39,6 +41,9 @@ func (payload *kubernetesFileStackUpdatePayload) Validate(r *http.Request) error
if govalidator.IsNull(payload.StackFileContent) { if govalidator.IsNull(payload.StackFileContent) {
return errors.New("Invalid stack file content") return errors.New("Invalid stack file content")
} }
if govalidator.IsNull(payload.StackName) {
return errors.New("Invalid stack name")
}
return nil return nil
} }
@ -114,6 +119,14 @@ func (handler *Handler) updateKubernetesStack(r *http.Request, stack *portainer.
return httperror.InternalServerError("Failed to persist deployment file in a temp directory", err) return httperror.InternalServerError("Failed to persist deployment file in a temp directory", err)
} }
if payload.StackName != stack.Name {
stack.Name = payload.StackName
err := handler.DataStore.Stack().Update(stack.ID, stack)
if err != nil {
return httperror.InternalServerError("Failed to update stack name", err)
}
}
// Refresh ECR registry secret if needed // Refresh ECR registry secret if needed
// RefreshEcrSecret method checks if the namespace has any ECR registry // RefreshEcrSecret method checks if the namespace has any ECR registry
// otherwise return nil // otherwise return nil

View File

@ -933,6 +933,10 @@ type (
RetryInterval int RetryInterval int
} }
GlobalDeploymentOptions struct {
HideStacksFunctionality bool `json:"hideStacksFunctionality" example:"false"`
}
// Settings represents the application settings // Settings represents the application settings
Settings struct { Settings struct {
// URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string // URL to a logo that will be displayed on the login page as well as on top of the sidebar. Will use default Portainer logo when value is empty string
@ -951,6 +955,8 @@ type (
SnapshotInterval string `json:"SnapshotInterval" example:"5m"` SnapshotInterval string `json:"SnapshotInterval" example:"5m"`
// URL to the templates that will be displayed in the UI when navigating to App Templates // URL to the templates that will be displayed in the UI when navigating to App Templates
TemplatesURL string `json:"TemplatesURL" example:"https://raw.githubusercontent.com/portainer/templates/master/templates.json"` TemplatesURL string `json:"TemplatesURL" example:"https://raw.githubusercontent.com/portainer/templates/master/templates.json"`
// Deployment options for encouraging git ops workflows
GlobalDeploymentOptions GlobalDeploymentOptions `json:"GlobalDeploymentOptions"`
// The default check in interval for edge agent (in seconds) // The default check in interval for edge agent (in seconds)
EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval" example:"5"` EdgeAgentCheckinInterval int `json:"EdgeAgentCheckinInterval" example:"5"`
// Show the Kompose build option (discontinued in 2.18) // Show the Kompose build option (discontinued in 2.18)

View File

@ -172,7 +172,7 @@
ng-click="$ctrl.changeOrderBy('Name')" ng-click="$ctrl.changeOrderBy('Name')"
></table-column-header> ></table-column-header>
</th> </th>
<th> <th ng-if="!$ctrl.hideStacksFunctionality">
<table-column-header <table-column-header
col-title="'Stack'" col-title="'Stack'"
can-sort="true" can-sort="true"
@ -290,7 +290,7 @@
<span class="label label-info image-tag label-margins" ng-if="$ctrl.isSystemNamespace(item)">system</span> <span class="label label-info image-tag label-margins" ng-if="$ctrl.isSystemNamespace(item)">system</span>
<span class="label label-primary image-tag label-margins" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span> <span class="label label-primary image-tag label-margins" ng-if="!$ctrl.isSystemNamespace(item) && $ctrl.isExternalApplication(item)">external</span>
</td> </td>
<td>{{ item.StackName || '-' }}</td> <td ng-if="!$ctrl.hideStacksFunctionality">{{ item.StackName || '-' }}</td>
<td> <td>
<a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })" ng-click="$event.stopPropagation()">{{ item.ResourcePool }}</a> <a ui-sref="kubernetes.resourcePools.resourcePool({ id: item.ResourcePool })" ng-click="$event.stopPropagation()">{{ item.ResourcePool }}</a>
</td> </td>
@ -330,6 +330,7 @@
refresh-callback="$ctrl.refreshCallback" refresh-callback="$ctrl.refreshCallback"
on-publishing-mode-click="($ctrl.onPublishingModeClick)" on-publishing-mode-click="($ctrl.onPublishingModeClick)"
is-primary="false" is-primary="false"
hide-stacks-functionality="$ctrl.hideStacksFunctionality"
> >
</kubernetes-applications-datatable> </kubernetes-applications-datatable>
</span> </span>

View File

@ -21,5 +21,6 @@ angular.module('portainer.kubernetes').component('kubernetesApplicationsDatatabl
isAppsLoading: '<', isAppsLoading: '<',
isSystemResources: '<', isSystemResources: '<',
setSystemResources: '<', setSystemResources: '<',
hideStacksFunctionality: '<',
}, },
}); });

View File

@ -185,8 +185,9 @@ export default class HelmTemplatesController {
}; };
const helmRepos = await this.getHelmRepoURLs(); const helmRepos = await this.getHelmRepoURLs();
if (helmRepos) {
await Promise.all([this.getLatestCharts(helmRepos), this.getResourcePools()]); await Promise.all([this.getLatestCharts(helmRepos), this.getResourcePools()]);
}
if (this.state.charts.length > 0 && this.$state.params.chartName) { if (this.state.charts.length > 0 && this.$state.params.chartName) {
const chart = this.state.charts.find((chart) => chart.name === this.$state.params.chartName); const chart = this.state.charts.find((chart) => chart.name === this.$state.params.chartName);
if (chart) { if (chart) {

View File

@ -42,7 +42,9 @@ class KubernetesDaemonSetConverter {
const payload = new KubernetesDaemonSetCreatePayload(); const payload = new KubernetesDaemonSetCreatePayload();
payload.metadata.name = daemonSet.Name; payload.metadata.name = daemonSet.Name;
payload.metadata.namespace = daemonSet.Namespace; payload.metadata.namespace = daemonSet.Namespace;
if (daemonSet.StackName) {
payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = daemonSet.StackName; payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = daemonSet.StackName;
}
payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = daemonSet.ApplicationName; payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = daemonSet.ApplicationName;
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = daemonSet.ApplicationOwner; payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = daemonSet.ApplicationOwner;
payload.metadata.annotations[KubernetesPortainerApplicationNote] = daemonSet.Note; payload.metadata.annotations[KubernetesPortainerApplicationNote] = daemonSet.Note;

View File

@ -45,7 +45,9 @@ class KubernetesDeploymentConverter {
const payload = new KubernetesDeploymentCreatePayload(); const payload = new KubernetesDeploymentCreatePayload();
payload.metadata.name = deployment.Name; payload.metadata.name = deployment.Name;
payload.metadata.namespace = deployment.Namespace; payload.metadata.namespace = deployment.Namespace;
if (deployment.StackName) {
payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = deployment.StackName; payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = deployment.StackName;
}
payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = deployment.ApplicationName; payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = deployment.ApplicationName;
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = deployment.ApplicationOwner; payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = deployment.ApplicationOwner;
payload.metadata.annotations[KubernetesPortainerApplicationNote] = deployment.Note; payload.metadata.annotations[KubernetesPortainerApplicationNote] = deployment.Note;

View File

@ -46,7 +46,9 @@ class KubernetesStatefulSetConverter {
const payload = new KubernetesStatefulSetCreatePayload(); const payload = new KubernetesStatefulSetCreatePayload();
payload.metadata.name = statefulSet.Name; payload.metadata.name = statefulSet.Name;
payload.metadata.namespace = statefulSet.Namespace; payload.metadata.namespace = statefulSet.Namespace;
if (statefulSet.StackName) {
payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = statefulSet.StackName; payload.metadata.labels[KubernetesPortainerApplicationStackNameLabel] = statefulSet.StackName;
}
payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = statefulSet.ApplicationName; payload.metadata.labels[KubernetesPortainerApplicationNameLabel] = statefulSet.ApplicationName;
payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = statefulSet.ApplicationOwner; payload.metadata.labels[KubernetesPortainerApplicationOwnerLabel] = statefulSet.ApplicationOwner;
payload.metadata.annotations[KubernetesPortainerApplicationNote] = statefulSet.Note; payload.metadata.annotations[KubernetesPortainerApplicationNote] = statefulSet.Note;

View File

@ -22,6 +22,7 @@ import { withFormValidation } from '@/react-tools/withFormValidation';
import { withCurrentUser } from '@/react-tools/withCurrentUser'; import { withCurrentUser } from '@/react-tools/withCurrentUser';
import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector'; import { YAMLInspector } from '@/react/kubernetes/components/YAMLInspector';
import { ApplicationsStacksDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable'; import { ApplicationsStacksDatatable } from '@/react/kubernetes/applications/ListView/ApplicationsStacksDatatable';
import { StackName } from '@/react/kubernetes/DeployView/StackName/StackName';
export const ngModule = angular export const ngModule = angular
.module('portainer.kubernetes.react.components', []) .module('portainer.kubernetes.react.components', [])
@ -101,6 +102,14 @@ export const ngModule = angular
'hideMessage', 'hideMessage',
]) ])
) )
.component(
'kubeStackName',
r2a(withUIRouter(withReactQuery(withCurrentUser(StackName))), [
'setStackName',
'isAdmin',
'stackName',
])
)
.component( .component(
'applicationSummaryWidget', 'applicationSummaryWidget',
r2a( r2a(

View File

@ -210,10 +210,14 @@ class KubernetesApplicationService {
* To synchronise with kubernetes resource creation summary output, any new resources created in this method should * To synchronise with kubernetes resource creation summary output, any new resources created in this method should
* also be displayed in the summary output (getCreatedApplicationResources) * also be displayed in the summary output (getCreatedApplicationResources)
*/ */
async createAsync(formValues) { async createAsync(formValues, hideStacks) {
// formValues -> Application // formValues -> Application
let [app, headlessService, services, , claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues); let [app, headlessService, services, , claims] = KubernetesApplicationConverter.applicationFormValuesToApplication(formValues);
if (hideStacks) {
app.StackName = '';
}
if (services) { if (services) {
services.forEach(async (service) => { services.forEach(async (service) => {
try { try {
@ -264,8 +268,8 @@ class KubernetesApplicationService {
await apiService.create(app); await apiService.create(app);
} }
create(formValues) { create(formValues, _, hideStacks) {
return this.$async(this.createAsync, formValues); return this.$async(this.createAsync, formValues, hideStacks);
} }
/* #endregion */ /* #endregion */

View File

@ -25,6 +25,7 @@
is-apps-loading="ctrl.state.isAppsLoading" is-apps-loading="ctrl.state.isAppsLoading"
is-system-resources="ctrl.state.isSystemResources" is-system-resources="ctrl.state.isSystemResources"
set-system-resources="(ctrl.setSystemResources)" set-system-resources="(ctrl.setSystemResources)"
hide-stacks-functionality="ctrl.deploymentOptions.hideStacksFunctionality"
> >
</kubernetes-applications-datatable> </kubernetes-applications-datatable>
</uib-tab> </uib-tab>

View File

@ -6,6 +6,7 @@ import KubernetesConfigurationHelper from 'Kubernetes/helpers/configurationHelpe
import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models'; import { KubernetesApplicationTypes } from 'Kubernetes/models/application/models';
import { KubernetesPortainerApplicationStackNameLabel } from 'Kubernetes/models/application/models'; import { KubernetesPortainerApplicationStackNameLabel } from 'Kubernetes/models/application/models';
import { confirmDelete } from '@@/modals/confirm'; import { confirmDelete } from '@@/modals/confirm';
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
class KubernetesApplicationsController { class KubernetesApplicationsController {
/* @ngInject */ /* @ngInject */
@ -196,6 +197,8 @@ class KubernetesApplicationsController {
isSystemResources: undefined, isSystemResources: undefined,
}; };
this.deploymentOptions = await getDeploymentOptions();
this.user = this.Authentication.getUserDetails(); this.user = this.Authentication.getUserDetails();
this.state.namespaces = await this.KubernetesNamespaceService.get(); this.state.namespaces = await this.KubernetesNamespaceService.get();

View File

@ -99,6 +99,54 @@
</div> </div>
<!-- #endregion --> <!-- #endregion -->
<!-- #region STACK -->
<div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM">
<div class="col-sm-12 small text-muted vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon>
Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to use
the application name.
</div>
</div>
<div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality && ctrl.state.appType !== ctrl.KubernetesDeploymentTypes.APPLICATION_FORM">
<label for="stack_name" class="col-sm-3 col-lg-2 control-label text-left">
Stack
<portainer-tooltip
ng-if="!ctrl.isAdmin"
message="'The stack field below was previously labelled \'Name\' but, in
fact, it\'s always been the stack name (hence the relabelling).'"
class-name="'[&>span]:!text-left'"
set-html-message="true"
>
</portainer-tooltip>
<portainer-tooltip
ng-if="ctrl.isAdmin"
message="'The stack field below was previously labelled \'Name\' but, in
fact, it\'s always been the stack name (hence the relabelling).<br/>
Kubernetes Stacks functionality can be turned off entirely via
<a href=\'/#!/settings\' target=\'_blank\'>
Kubernetes Settings
</a>.'"
class-name="'[&>span]:!text-left'"
set-html-message="true"
>
</portainer-tooltip>
</label>
<div class="col-sm-8">
<input
type="text"
class="form-control"
placeholder="myStack"
ng-model="ctrl.formValues.StackName"
name="stack_name"
uib-typeahead="stack for stack in ctrl.stacks | filter:$viewValue | limitTo:7"
typeahead-min-length="0"
data-cy="k8sAppCreate-stackName"
/>
</div>
</div>
<!-- #endregion -->
<!-- #region Git repository --> <!-- #region Git repository -->
<kubernetes-redeploy-app-git-form <kubernetes-redeploy-app-git-form
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.GIT" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.GIT"
@ -218,7 +266,7 @@
<div ng-if="ctrl.formValues.ResourcePool"> <div ng-if="ctrl.formValues.ResourcePool">
<!-- #region STACK --> <!-- #region STACK -->
<div class="form-group"> <div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality">
<div class="col-sm-12 small text-muted vertical-center"> <div class="col-sm-12 small text-muted vertical-center">
<pr-icon icon="'info'" mode="'primary'"></pr-icon> <pr-icon icon="'info'" mode="'primary'"></pr-icon>
Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to Portainer can automatically bundle multiple applications inside a stack. Enter a name of a new stack or select an existing stack in the list. Leave empty to
@ -226,7 +274,7 @@
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group" ng-if="!ctrl.deploymentOptions.hideStacksFunctionality">
<label for="stack_name" class="col-sm-3 col-lg-2 control-label text-left">Stack</label> <label for="stack_name" class="col-sm-3 col-lg-2 control-label text-left">Stack</label>
<div class="col-sm-8"> <div class="col-sm-8">
<input <input
@ -1401,7 +1449,7 @@
class="btn btn-sm btn-primary" class="btn btn-sm btn-primary"
ng-click="ctrl.updateApplicationViaWebEditor()" ng-click="ctrl.updateApplicationViaWebEditor()"
ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT || ctrl.state.updateWebEditorInProgress" ng-if="ctrl.state.appType === ctrl.KubernetesDeploymentTypes.CONTENT || ctrl.state.updateWebEditorInProgress"
ng-disabled="!kubernetesApplicationCreationForm.$valid || !ctrl.state.isEditorDirty || ctrl.state.updateWebEditorInProgress" ng-disabled="kubernetesApplicationCreationForm.$valid && !ctrl.state.isEditorDirty && ctrl.savedFormValues.StackName === ctrl.formValues.StackName || ctrl.state.updateWebEditorInProgress"
style="margin-top: 7px; margin-left: 0" style="margin-top: 7px; margin-left: 0"
button-spinner="ctrl.state.updateWebEditorInProgress" button-spinner="ctrl.state.updateWebEditorInProgress"
> >

View File

@ -5,6 +5,7 @@ import * as JsonPatch from 'fast-json-patch';
import { RegistryTypes } from '@/portainer/models/registryTypes'; import { RegistryTypes } from '@/portainer/models/registryTypes';
import { getServices } from '@/react/kubernetes/networks/services/service'; import { getServices } from '@/react/kubernetes/networks/services/service';
import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models'; import { KubernetesConfigurationKinds } from 'Kubernetes/models/configuration/models';
import { getGlobalDeploymentOptions } from '@/react/portainer/settings/settings.service';
import { import {
KubernetesApplicationDataAccessPolicies, KubernetesApplicationDataAccessPolicies,
@ -196,7 +197,10 @@ class KubernetesCreateApplicationController {
} }
this.state.updateWebEditorInProgress = true; this.state.updateWebEditorInProgress = true;
await this.StackService.updateKubeStack({ EndpointId: this.endpoint.Id, Id: this.application.StackId }, { stackFile: this.stackFileContent }); await this.StackService.updateKubeStack(
{ EndpointId: this.endpoint.Id, Id: this.application.StackId },
{ stackFile: this.stackFileContent, stackName: this.formValues.StackName }
);
this.state.isEditorDirty = false; this.state.isEditorDirty = false;
await this.$state.reload(this.$state.current); await this.$state.reload(this.$state.current);
} catch (err) { } catch (err) {
@ -932,7 +936,7 @@ class KubernetesCreateApplicationController {
this.formValues.ApplicationOwner = this.Authentication.getUserDetails().username; this.formValues.ApplicationOwner = this.Authentication.getUserDetails().username;
// combine the secrets and configmap form values when submitting the form // combine the secrets and configmap form values when submitting the form
_.remove(this.formValues.Configurations, (item) => item.SelectedConfiguration === undefined); _.remove(this.formValues.Configurations, (item) => item.SelectedConfiguration === undefined);
await this.KubernetesApplicationService.create(this.formValues, this.originalServicePorts); await this.KubernetesApplicationService.create(this.formValues, this.originalServicePorts, this.deploymentOptions.hideStacksFunctionality);
this.Notifications.success('Request to deploy application successfully submitted', this.formValues.Name); this.Notifications.success('Request to deploy application successfully submitted', this.formValues.Name);
this.$state.go('kubernetes.applications'); this.$state.go('kubernetes.applications');
} catch (err) { } catch (err) {
@ -1092,6 +1096,8 @@ class KubernetesCreateApplicationController {
this.state.useLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer; this.state.useLoadBalancer = this.endpoint.Kubernetes.Configuration.UseLoadBalancer;
this.state.useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics; this.state.useServerMetrics = this.endpoint.Kubernetes.Configuration.UseServerMetrics;
this.deploymentOptions = await getGlobalDeploymentOptions();
const [resourcePools, nodes, nodesLimits] = await Promise.all([ const [resourcePools, nodes, nodesLimits] = await Promise.all([
this.KubernetesResourcePoolService.get(), this.KubernetesResourcePoolService.get(),
this.KubernetesNodeService.get(), this.KubernetesNodeService.get(),
@ -1140,6 +1146,8 @@ class KubernetesCreateApplicationController {
this.nodesLabels, this.nodesLabels,
this.ingresses this.ingresses
); );
this.formValues.Services = this.formValues.Services || [];
this.originalServicePorts = structuredClone(this.formValues.Services.flatMap((service) => service.Ports)); this.originalServicePorts = structuredClone(this.formValues.Services.flatMap((service) => service.Ports));
this.originalIngressPaths = structuredClone(this.originalServicePorts.flatMap((port) => port.ingressPaths).filter((ingressPath) => ingressPath.Host)); this.originalIngressPaths = structuredClone(this.originalServicePorts.flatMap((port) => port.ingressPaths).filter((ingressPath) => ingressPath.Host));

View File

@ -32,11 +32,13 @@
<label for="target_node" class="col-lg-2 col-sm-3 control-label text-left">Namespace</label> <label for="target_node" class="col-lg-2 col-sm-3 control-label text-left">Namespace</label>
<div class="col-sm-8"> <div class="col-sm-8">
<select <select
ng-if="!ctrl.formValues.namespace_toggle"
ng-disabled="ctrl.formValues.namespace_toggle" ng-disabled="ctrl.formValues.namespace_toggle"
class="form-control" class="form-control"
ng-model="ctrl.formValues.Namespace" ng-model="ctrl.formValues.Namespace"
ng-options="namespace.Name as namespace.Name for namespace in ctrl.namespaces" ng-options="namespace.Name as namespace.Name for namespace in ctrl.namespaces"
></select> ></select>
<span ng-if="ctrl.formValues.namespace_toggle">Namespaces specified in the manifest will be used</span>
</div> </div>
</div> </div>
@ -48,12 +50,17 @@
</div> </div>
<div class="form-group"> <div class="form-group">
<label for="stack_name" class="col-lg-2 col-sm-3 control-label required text-left">Name</label> <label for="stack_name" class="col-lg-2 col-sm-3 control-label text-left">Name</label>
<div class="col-sm-8"> <div class="col-sm-8"> Resource names specified in the manifest will be used </div>
<input type="text" class="form-control" ng-model="ctrl.formValues.StackName" id="stack_name" placeholder="my-app" auto-focus />
</div>
</div> </div>
<kube-stack-name
ng-if="!ctrl.deploymentOptions.hideStacksFunctionality"
stack-name="ctrl.formValues.StackName"
set-stack-name="(ctrl.setStackName)"
is-admin="ctrl.currentUser.isAdmin"
></kube-stack-name>
<div class="col-sm-12 form-section-title"> Deploy from </div> <div class="col-sm-12 form-section-title"> Deploy from </div>
<box-selector <box-selector
slim="true" slim="true"

View File

@ -5,6 +5,7 @@ import stripAnsi from 'strip-ansi';
import PortainerError from '@/portainer/error'; import PortainerError from '@/portainer/error';
import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy'; import { KubernetesDeployManifestTypes, KubernetesDeployBuildMethods, KubernetesDeployRequestMethods, RepositoryMechanismTypes } from 'Kubernetes/models/deploy';
import { renderTemplate } from '@/react/portainer/custom-templates/components/utils'; import { renderTemplate } from '@/react/portainer/custom-templates/components/utils';
import { getDeploymentOptions } from '@/react/portainer/environments/environment.service';
import { isBE } from '@/react/portainer/feature-flags/feature-flags.service'; import { isBE } from '@/react/portainer/feature-flags/feature-flags.service';
import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods'; import { kubernetes } from '@@/BoxSelector/common-options/deployment-methods';
import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods'; import { editor, git, customTemplate, url, helm } from '@@/BoxSelector/common-options/build-methods';
@ -89,6 +90,7 @@ class KubernetesDeployController {
this.onChangeMethod = this.onChangeMethod.bind(this); this.onChangeMethod = this.onChangeMethod.bind(this);
this.onChangeDeployType = this.onChangeDeployType.bind(this); this.onChangeDeployType = this.onChangeDeployType.bind(this);
this.onChangeTemplateVariables = this.onChangeTemplateVariables.bind(this); this.onChangeTemplateVariables = this.onChangeTemplateVariables.bind(this);
this.setStackName = this.setStackName.bind(this);
} }
onSelectHelmChart(chart) { onSelectHelmChart(chart) {
@ -101,6 +103,10 @@ class KubernetesDeployController {
this.renderTemplate(); this.renderTemplate();
} }
setStackName(name) {
this.formValues.StackName = name;
}
renderTemplate() { renderTemplate() {
if (!this.isTemplateVariablesEnabled) { if (!this.isTemplateVariablesEnabled) {
return; return;
@ -180,7 +186,7 @@ class KubernetesDeployController {
const isURLFormInvalid = this.state.BuildMethod == KubernetesDeployBuildMethods.WEB_EDITOR.URL && _.isEmpty(this.formValues.ManifestURL); const isURLFormInvalid = this.state.BuildMethod == KubernetesDeployBuildMethods.WEB_EDITOR.URL && _.isEmpty(this.formValues.ManifestURL);
const isNamespaceInvalid = _.isEmpty(this.formValues.Namespace); const isNamespaceInvalid = _.isEmpty(this.formValues.Namespace);
return !this.formValues.StackName || isWebEditorInvalid || isURLFormInvalid || this.state.actionInProgress || isNamespaceInvalid; return isWebEditorInvalid || isURLFormInvalid || this.state.actionInProgress || isNamespaceInvalid;
} }
onChangeFormValues(newValues) { onChangeFormValues(newValues) {
@ -360,6 +366,8 @@ class KubernetesDeployController {
this.formValues.namespace_toggle = false; this.formValues.namespace_toggle = false;
await this.getNamespaces(); await this.getNamespaces();
this.deploymentOptions = await getDeploymentOptions(this.endpoint.Id);
if (this.$state.params.templateId) { if (this.$state.params.templateId) {
const templateId = parseInt(this.$state.params.templateId, 10); const templateId = parseInt(this.$state.params.templateId, 10);
if (templateId && !Number.isNaN(templateId)) { if (templateId && !Number.isNaN(templateId)) {

View File

@ -262,12 +262,13 @@ angular.module('portainer.app').factory('StackService', [
).$promise; ).$promise;
}; };
service.updateKubeStack = function (stack, { stackFile, gitConfig, webhookId }) { service.updateKubeStack = function (stack, { stackFile, gitConfig, webhookId, stackName }) {
let payload = {}; let payload = {};
if (stackFile) { if (stackFile) {
payload = { payload = {
StackFileContent: stackFile, StackFileContent: stackFile,
StackName: stackName,
}; };
} else { } else {
payload = { payload = {

View File

@ -0,0 +1,84 @@
import { InsightsBox } from '@@/InsightsBox';
import { Link } from '@@/Link';
import { TextTip } from '@@/Tip/TextTip';
import { Tooltip } from '@@/Tip/Tooltip';
type Props = {
stackName: string;
setStackName: (name: string) => void;
isAdmin?: boolean;
};
export function StackName({ stackName, setStackName, isAdmin = false }: Props) {
const tooltip = (
<>
You may specify a stack name to label resources that you want to group.
This includes Deployments, DaemonSets, StatefulSets and Pods.
{isAdmin && (
<>
<br />
You can leave the stack name empty, or even turn off Kubernetes Stacks
functionality entirely via{' '}
<Link to="portainer.settings" target="_blank">
Kubernetes Settings
</Link>
.
</>
)}
</>
);
const insightsBoxContent = (
<>
The stack field below was previously labelled &apos;Name&apos; but, in
fact, it&apos;s always been the stack name (hence the relabelling).
{isAdmin && (
<>
<br />
Kubernetes Stacks functionality can be turned off entirely via{' '}
<Link to="portainer.settings" target="_blank">
Kubernetes Settings
</Link>
.
</>
)}
</>
);
return (
<>
<div className="w-fit mb-4">
<InsightsBox
type="slim"
header="Stack"
content={insightsBoxContent}
insightCloseId="k8s-stacks-name"
/>
</div>
<TextTip className="mb-4" color="blue">
Enter or select a &apos;stack&apos; name to group multiple deployments
together, or else leave empty to ignore.
</TextTip>
<div className="form-group">
<label
htmlFor="stack_name"
className="col-lg-2 col-sm-3 control-label text-left"
>
Stack
<Tooltip message={tooltip} setHtmlMessage />
</label>
<div className="col-sm-8">
<input
type="text"
className="form-control"
defaultValue={stackName}
onChange={(e) => setStackName(e.target.value)}
id="stack_name"
placeholder="myStack"
/>
</div>
</div>
</>
);
}

View File

@ -6,6 +6,8 @@ import { useCurrentStateAndParams } from '@uirouter/react';
import { Authorized } from '@/react/hooks/useUser'; import { Authorized } from '@/react/hooks/useUser';
import { notifyError, notifySuccess } from '@/portainer/services/notifications'; import { notifyError, notifySuccess } from '@/portainer/services/notifications';
import { usePublicSettings } from '@/react/portainer/settings/queries';
import { GlobalDeploymentOptions } from '@/react/portainer/settings/types';
import { DetailsTable } from '@@/DetailsTable'; import { DetailsTable } from '@@/DetailsTable';
import { Badge } from '@@/Badge'; import { Badge } from '@@/Badge';
@ -69,6 +71,11 @@ export function ApplicationSummaryWidget() {
setApplicationNoteFormValues(applicationNote || ''); setApplicationNoteFormValues(applicationNote || '');
}, [applicationNote]); }, [applicationNote]);
const globalDeploymentOptionsQuery =
usePublicSettings<GlobalDeploymentOptions>({
select: (settings) => settings.GlobalDeploymentOptions,
});
const failedCreateCondition = application?.status?.conditions?.find( const failedCreateCondition = application?.status?.conditions?.find(
(condition) => condition.reason === 'FailedCreate' (condition) => condition.reason === 'FailedCreate'
); );
@ -117,6 +124,9 @@ export function ApplicationSummaryWidget() {
</div> </div>
</td> </td>
</tr> </tr>
{globalDeploymentOptionsQuery.data &&
!globalDeploymentOptionsQuery.data
.hideStacksFunctionality && (
<tr> <tr>
<td>Stack</td> <td>Stack</td>
<td data-cy="k8sAppDetail-stackName"> <td data-cy="k8sAppDetail-stackName">
@ -124,6 +134,7 @@ export function ApplicationSummaryWidget() {
'-'} '-'}
</td> </td>
</tr> </tr>
)}
<tr> <tr>
<td>Namespace</td> <td>Namespace</td>
<td> <td>

View File

@ -8,6 +8,7 @@ import {
StatusType as EdgeStackStatusType, StatusType as EdgeStackStatusType,
} from '@/react/edge/edge-stacks/types'; } from '@/react/edge/edge-stacks/types';
import { getPublicSettings } from '../../settings/settings.service';
import type { import type {
Environment, Environment,
EnvironmentId, EnvironmentId,
@ -131,6 +132,20 @@ export async function snapshotEndpoints() {
} }
} }
export async function getDeploymentOptions(environmentId: EnvironmentId) {
const publicSettings = await getPublicSettings();
const endpoint = await getEndpoint(environmentId);
if (
publicSettings.GlobalDeploymentOptions.perEnvOverride &&
endpoint.DeploymentOptions?.overrideGlobalOptions
) {
return endpoint.DeploymentOptions;
}
return publicSettings.GlobalDeploymentOptions;
}
export async function snapshotEndpoint(id: EnvironmentId) { export async function snapshotEndpoint(id: EnvironmentId) {
try { try {
await axios.post<void>(buildUrl(id, 'snapshot')); await axios.post<void>(buildUrl(id, 'snapshot'));

View File

@ -14,6 +14,7 @@ export function DeploymentOptionsSection() {
values: { globalDeploymentOptions: values }, values: { globalDeploymentOptions: values },
setFieldValue, setFieldValue,
} = useFormikContext<FormValues>(); } = useFormikContext<FormValues>();
const limitedFeature = isLimitedToBE(FeatureId.ENFORCE_DEPLOYMENT_OPTIONS); const limitedFeature = isLimitedToBE(FeatureId.ENFORCE_DEPLOYMENT_OPTIONS);
return ( return (
<FormSection title="Deployment Options"> <FormSection title="Deployment Options">
@ -74,6 +75,24 @@ export function DeploymentOptionsSection() {
)} )}
<KubeNoteMinimumCharacters /> <KubeNoteMinimumCharacters />
<div className="form-group">
<div className="col-sm-12">
<SwitchField
label="Allow stacks functionality with Kubernetes environments"
checked={!values.hideStacksFunctionality}
onChange={(value) =>
setFieldValue(
'globalDeploymentOptions.hideStacksFunctionality',
!value
)
}
name="toggle_stacksFunctionality"
labelClass="col-sm-3 col-lg-2"
tooltip="This allows you to group your applications/workloads into a single stack, and then view or delete an entire stack. If disabled, stacks functionality will not show in the UI."
/>
</div>
</div>
</FormSection> </FormSection>
); );

View File

@ -29,13 +29,17 @@ export function KubeSettingsPanel() {
const initialValues: FormValues = { const initialValues: FormValues = {
helmRepositoryUrl: settingsQuery.data.HelmRepositoryURL || '', helmRepositoryUrl: settingsQuery.data.HelmRepositoryURL || '',
kubeconfigExpiry: settingsQuery.data.KubeconfigExpiry || '0', kubeconfigExpiry: settingsQuery.data.KubeconfigExpiry || '0',
globalDeploymentOptions: settingsQuery.data.GlobalDeploymentOptions || { globalDeploymentOptions: {
...{
requireNoteOnApplications: false, requireNoteOnApplications: false,
minApplicationNoteLength: 0, minApplicationNoteLength: 0,
hideAddWithForm: false, hideAddWithForm: false,
hideFileUpload: false, hideFileUpload: false,
hideWebEditor: false, hideWebEditor: false,
perEnvOverride: false, perEnvOverride: false,
hideStacksFunctionality: false,
},
...settingsQuery.data.GlobalDeploymentOptions,
}, },
}; };

View File

@ -8,5 +8,6 @@ export interface FormValues {
hideFileUpload: boolean; hideFileUpload: boolean;
requireNoteOnApplications: boolean; requireNoteOnApplications: boolean;
minApplicationNoteLength: number; minApplicationNoteLength: number;
hideStacksFunctionality: boolean;
}; };
} }

View File

@ -16,6 +16,7 @@ export function validation(): SchemaOf<FormValues> {
hideWebEditor: boolean().required(), hideWebEditor: boolean().required(),
hideFileUpload: boolean().required(), hideFileUpload: boolean().required(),
requireNoteOnApplications: boolean().required(), requireNoteOnApplications: boolean().required(),
hideStacksFunctionality: boolean().required(),
minApplicationNoteLength: number() minApplicationNoteLength: number()
.typeError('Must be a number') .typeError('Must be a number')
.default(0) .default(0)

View File

@ -145,7 +145,7 @@ export interface Settings {
}; };
} }
interface GlobalDeploymentOptions { export interface GlobalDeploymentOptions {
/** Hide manual deploy forms in portainer */ /** Hide manual deploy forms in portainer */
hideAddWithForm: boolean; hideAddWithForm: boolean;
/** Configure this per environment or globally */ /** Configure this per environment or globally */
@ -157,6 +157,8 @@ interface GlobalDeploymentOptions {
/** Make note on application add/edit screen required */ /** Make note on application add/edit screen required */
requireNoteOnApplications: boolean; requireNoteOnApplications: boolean;
minApplicationNoteLength: number; minApplicationNoteLength: number;
hideStacksFunctionality: boolean;
} }
export interface PublicSettingsResponse { export interface PublicSettingsResponse {