From 315a5d0002b55aa514d02f13ab4880b63c2d73bf Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 23 Jul 2026 08:43:26 -0500 Subject: [PATCH 1/2] feat: add topologySpreadConstraints to Connect, Workbench, PM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a user-configurable topologySpreadConstraints field to Connect, Workbench, and Package Manager, passed straight through to the server Deployment's PodSpec. Enables spreading a product's replicas across topology domains (e.g. kubernetes.io/arch), which a hard maxSkew:1/DoNotSchedule constraint can use to force one replica per CPU architecture in a multi-arch cluster. The field is independent of the operator's existing hardcoded hostname-keyed soft anti-affinity (shared.go's ComponentSpecPodAntiAffinity) and composes with it without conflict, since Affinity and TopologySpreadConstraints are separate PodSpec fields. Scoped to the server Deployment only; Workbench session pods are unaffected. No Affinity override or podLabels field is added — out of scope for the immediate use case. Mirrors the pattern used for the Resources field (#146): spec field on each product type + the Site's nested Internal*Spec, controller wiring, Site-to-product propagation, and regenerated CRDs/deepcopy/client-go via `just mgenerate` + `just helm-generate`. --- api/core/v1beta1/connect_types.go | 5 + api/core/v1beta1/packagemanager_types.go | 5 + api/core/v1beta1/site_types.go | 15 + api/core/v1beta1/workbench_types.go | 5 + api/core/v1beta1/zz_generated.deepcopy.go | 42 ++ .../core/v1beta1/connectspec.go | 11 + .../core/v1beta1/internalconnectspec.go | 61 +- .../v1beta1/internalpackagemanagerspec.go | 49 +- .../core/v1beta1/internalworkbenchspec.go | 11 + .../core/v1beta1/packagemanagerspec.go | 11 + .../core/v1beta1/workbenchspec.go | 11 + .../crd/bases/core.posit.team_connects.yaml | 177 ++++++ .../core.posit.team_packagemanagers.yaml | 177 ++++++ config/crd/bases/core.posit.team_sites.yaml | 531 ++++++++++++++++++ .../bases/core.posit.team_workbenches.yaml | 177 ++++++ .../crd/core.posit.team_connects.yaml | 177 ++++++ .../crd/core.posit.team_packagemanagers.yaml | 177 ++++++ .../templates/crd/core.posit.team_sites.yaml | 531 ++++++++++++++++++ .../crd/core.posit.team_workbenches.yaml | 177 ++++++ internal/controller/core/connect.go | 9 +- internal/controller/core/connect_test.go | 33 ++ internal/controller/core/package_manager.go | 9 +- .../core/package_manager_controller_test.go | 57 ++ .../core/site_controller_connect.go | 1 + .../core/site_controller_package_manager.go | 1 + .../core/site_controller_workbench.go | 1 + internal/controller/core/workbench.go | 1 + internal/controller/core/workbench_test.go | 33 ++ .../bases/core.posit.team_connects.yaml | 177 ++++++ .../core.posit.team_packagemanagers.yaml | 177 ++++++ .../crdapply/bases/core.posit.team_sites.yaml | 531 ++++++++++++++++++ .../bases/core.posit.team_workbenches.yaml | 177 ++++++ 32 files changed, 3505 insertions(+), 52 deletions(-) diff --git a/api/core/v1beta1/connect_types.go b/api/core/v1beta1/connect_types.go index 5c26b9e..c530a66 100644 --- a/api/core/v1beta1/connect_types.go +++ b/api/core/v1beta1/connect_types.go @@ -83,6 +83,11 @@ type ConnectSpec struct { NodeSelector map[string]string `json:"nodeSelector,omitempty"` + // TopologySpreadConstraints controls how server pod replicas are spread across topology + // domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + // hostname-keyed soft anti-affinity. + TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + // AddEnv adds arbitrary environment variables to the container env // // Deprecated: use envVars instead. If the same variable name is set in both diff --git a/api/core/v1beta1/packagemanager_types.go b/api/core/v1beta1/packagemanager_types.go index 2eae440..9ff9065 100644 --- a/api/core/v1beta1/packagemanager_types.go +++ b/api/core/v1beta1/packagemanager_types.go @@ -46,6 +46,11 @@ type PackageManagerSpec struct { // If unset, product defaults are applied. Setting this replaces the defaults entirely. Resources *v1.ResourceRequirements `json:"resources,omitempty"` + // TopologySpreadConstraints controls how server pod replicas are spread across topology + // domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + // hostname-keyed soft anti-affinity. + TopologySpreadConstraints []v1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + // AddEnv adds arbitrary environment variables to the container env // // Deprecated: use envVars instead. If the same variable name is set in both diff --git a/api/core/v1beta1/site_types.go b/api/core/v1beta1/site_types.go index 897de7c..42e36ea 100644 --- a/api/core/v1beta1/site_types.go +++ b/api/core/v1beta1/site_types.go @@ -217,6 +217,11 @@ type InternalPackageManagerSpec struct { // If unset, product defaults are applied. Setting this replaces the defaults entirely. Resources *corev1.ResourceRequirements `json:"resources,omitempty"` + // TopologySpreadConstraints controls how server pod replicas are spread across topology + // domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + // hostname-keyed soft anti-affinity. + TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + // Deprecated: use envVars instead. If the same variable name is set in both // addEnv and envVars, envVars takes precedence: it is rendered after addEnv, // and Kubernetes resolves a duplicate env var name to the last occurrence. @@ -288,6 +293,11 @@ type InternalConnectSpec struct { // If unset, product defaults are applied. Setting this replaces the defaults entirely. Resources *corev1.ResourceRequirements `json:"resources,omitempty"` + // TopologySpreadConstraints controls how server pod replicas are spread across topology + // domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + // hostname-keyed soft anti-affinity. + TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + Auth AuthSpec `json:"auth,omitempty"` // RegisterOnFirstLogin controls whether new users are automatically registered @@ -418,6 +428,11 @@ type InternalWorkbenchSpec struct { // If unset, product defaults are applied. Setting this replaces the defaults entirely. Resources *corev1.ResourceRequirements `json:"resources,omitempty"` + // TopologySpreadConstraints controls how server pod replicas are spread across topology + // domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + // hostname-keyed soft anti-affinity. Applies only to the server Deployment, not sessions. + TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + // Tolerations that are applied universally to server and sessions Tolerations []corev1.Toleration `json:"tolerations,omitempty"` diff --git a/api/core/v1beta1/workbench_types.go b/api/core/v1beta1/workbench_types.go index 51cb0b0..10a03af 100644 --- a/api/core/v1beta1/workbench_types.go +++ b/api/core/v1beta1/workbench_types.go @@ -62,6 +62,11 @@ type WorkbenchSpec struct { // If unset, product defaults are applied. Setting this replaces the defaults entirely. Resources *corev1.ResourceRequirements `json:"resources,omitempty"` + // TopologySpreadConstraints controls how server pod replicas are spread across topology + // domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + // hostname-keyed soft anti-affinity. Applies only to the server Deployment, not sessions. + TopologySpreadConstraints []corev1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + Tolerations []corev1.Toleration `json:"tolerations,omitempty"` // AddEnv adds arbitrary environment variables to the container env diff --git a/api/core/v1beta1/zz_generated.deepcopy.go b/api/core/v1beta1/zz_generated.deepcopy.go index efe6fca..902d31c 100644 --- a/api/core/v1beta1/zz_generated.deepcopy.go +++ b/api/core/v1beta1/zz_generated.deepcopy.go @@ -889,6 +889,13 @@ func (in *ConnectSpec) DeepCopyInto(out *ConnectSpec) { (*out)[key] = val } } + if in.TopologySpreadConstraints != nil { + in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints + *out = make([]v1.TopologySpreadConstraint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.AddEnv != nil { in, out := &in.AddEnv, &out.AddEnv *out = make(map[string]string, len(*in)) @@ -1232,6 +1239,13 @@ func (in *InternalConnectSpec) DeepCopyInto(out *InternalConnectSpec) { *out = new(v1.ResourceRequirements) (*in).DeepCopyInto(*out) } + if in.TopologySpreadConstraints != nil { + in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints + *out = make([]v1.TopologySpreadConstraint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } in.Auth.DeepCopyInto(&out.Auth) if in.RegisterOnFirstLogin != nil { in, out := &in.RegisterOnFirstLogin, &out.RegisterOnFirstLogin @@ -1363,6 +1377,13 @@ func (in *InternalPackageManagerSpec) DeepCopyInto(out *InternalPackageManagerSp *out = new(v1.ResourceRequirements) (*in).DeepCopyInto(*out) } + if in.TopologySpreadConstraints != nil { + in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints + *out = make([]v1.TopologySpreadConstraint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.AddEnv != nil { in, out := &in.AddEnv, &out.AddEnv *out = make(map[string]string, len(*in)) @@ -1505,6 +1526,13 @@ func (in *InternalWorkbenchSpec) DeepCopyInto(out *InternalWorkbenchSpec) { *out = new(v1.ResourceRequirements) (*in).DeepCopyInto(*out) } + if in.TopologySpreadConstraints != nil { + in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints + *out = make([]v1.TopologySpreadConstraint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Tolerations != nil { in, out := &in.Tolerations, &out.Tolerations *out = make([]v1.Toleration, len(*in)) @@ -2012,6 +2040,13 @@ func (in *PackageManagerSpec) DeepCopyInto(out *PackageManagerSpec) { *out = new(v1.ResourceRequirements) (*in).DeepCopyInto(*out) } + if in.TopologySpreadConstraints != nil { + in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints + *out = make([]v1.TopologySpreadConstraint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.AddEnv != nil { in, out := &in.AddEnv, &out.AddEnv *out = make(map[string]string, len(*in)) @@ -3415,6 +3450,13 @@ func (in *WorkbenchSpec) DeepCopyInto(out *WorkbenchSpec) { *out = new(v1.ResourceRequirements) (*in).DeepCopyInto(*out) } + if in.TopologySpreadConstraints != nil { + in, out := &in.TopologySpreadConstraints, &out.TopologySpreadConstraints + *out = make([]v1.TopologySpreadConstraint, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.Tolerations != nil { in, out := &in.Tolerations, &out.Tolerations *out = make([]v1.Toleration, len(*in)) diff --git a/client-go/applyconfiguration/core/v1beta1/connectspec.go b/client-go/applyconfiguration/core/v1beta1/connectspec.go index 4502f7b..2bb1b42 100644 --- a/client-go/applyconfiguration/core/v1beta1/connectspec.go +++ b/client-go/applyconfiguration/core/v1beta1/connectspec.go @@ -25,6 +25,7 @@ type ConnectSpecApplyConfiguration struct { IngressAnnotations map[string]string `json:"ingressAnnotations,omitempty"` ImagePullSecrets []string `json:"imagePullSecrets,omitempty"` NodeSelector map[string]string `json:"nodeSelector,omitempty"` + TopologySpreadConstraints []v1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` AddEnv map[string]string `json:"addEnv,omitempty"` EnvVars []v1.EnvVar `json:"envVars,omitempty"` OffHostExecution *bool `json:"offHostExecution,omitempty"` @@ -166,6 +167,16 @@ func (b *ConnectSpecApplyConfiguration) WithNodeSelector(entries map[string]stri return b } +// WithTopologySpreadConstraints adds the given value to the TopologySpreadConstraints field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the TopologySpreadConstraints field. +func (b *ConnectSpecApplyConfiguration) WithTopologySpreadConstraints(values ...v1.TopologySpreadConstraint) *ConnectSpecApplyConfiguration { + for i := range values { + b.TopologySpreadConstraints = append(b.TopologySpreadConstraints, values[i]) + } + return b +} + // WithAddEnv puts the entries into the AddEnv field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, the entries provided by each call will be put on the AddEnv field, diff --git a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go index a24302e..0e42d28 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalconnectspec.go @@ -13,31 +13,32 @@ import ( // InternalConnectSpecApplyConfiguration represents a declarative configuration of the InternalConnectSpec type for use // with apply. type InternalConnectSpecApplyConfiguration struct { - Enabled *bool `json:"enabled,omitempty"` - Teardown *bool `json:"teardown,omitempty"` - License *product.LicenseSpec `json:"license,omitempty"` - Volume *product.VolumeSpec `json:"volume,omitempty"` - NodeSelector map[string]string `json:"nodeSelector,omitempty"` - Resources *v1.ResourceRequirements `json:"resources,omitempty"` - Auth *AuthSpecApplyConfiguration `json:"auth,omitempty"` - RegisterOnFirstLogin *bool `json:"registerOnFirstLogin,omitempty"` - AddEnv map[string]string `json:"addEnv,omitempty"` - EnvVars []v1.EnvVar `json:"envVars,omitempty"` - Image *string `json:"image,omitempty"` - SessionImage *string `json:"sessionImage,omitempty"` - ImagePullPolicy *v1.PullPolicy `json:"imagePullPolicy,omitempty"` - Databricks *DatabricksConfigApplyConfiguration `json:"databricks,omitempty"` - LoggedInWarning *string `json:"loggedInWarning,omitempty"` - PublicWarning *string `json:"publicWarning,omitempty"` - Replicas *int `json:"replicas,omitempty"` - ExperimentalFeatures *InternalConnectExperimentalFeaturesApplyConfiguration `json:"experimentalFeatures,omitempty"` - DomainPrefix *string `json:"domainPrefix,omitempty"` - BaseDomain *string `json:"baseDomain,omitempty"` - GPUSettings *GPUSettingsApplyConfiguration `json:"gpuSettings,omitempty"` - DatabaseSettings *DatabaseSettingsApplyConfiguration `json:"databaseSettings,omitempty"` - ScheduleConcurrency *int `json:"scheduleConcurrency,omitempty"` - AdditionalRuntimeImages []ConnectRuntimeImageSpecApplyConfiguration `json:"additionalRuntimeImages,omitempty"` - AdditionalConfig *string `json:"additionalConfig,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Teardown *bool `json:"teardown,omitempty"` + License *product.LicenseSpec `json:"license,omitempty"` + Volume *product.VolumeSpec `json:"volume,omitempty"` + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + Resources *v1.ResourceRequirements `json:"resources,omitempty"` + TopologySpreadConstraints []v1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + Auth *AuthSpecApplyConfiguration `json:"auth,omitempty"` + RegisterOnFirstLogin *bool `json:"registerOnFirstLogin,omitempty"` + AddEnv map[string]string `json:"addEnv,omitempty"` + EnvVars []v1.EnvVar `json:"envVars,omitempty"` + Image *string `json:"image,omitempty"` + SessionImage *string `json:"sessionImage,omitempty"` + ImagePullPolicy *v1.PullPolicy `json:"imagePullPolicy,omitempty"` + Databricks *DatabricksConfigApplyConfiguration `json:"databricks,omitempty"` + LoggedInWarning *string `json:"loggedInWarning,omitempty"` + PublicWarning *string `json:"publicWarning,omitempty"` + Replicas *int `json:"replicas,omitempty"` + ExperimentalFeatures *InternalConnectExperimentalFeaturesApplyConfiguration `json:"experimentalFeatures,omitempty"` + DomainPrefix *string `json:"domainPrefix,omitempty"` + BaseDomain *string `json:"baseDomain,omitempty"` + GPUSettings *GPUSettingsApplyConfiguration `json:"gpuSettings,omitempty"` + DatabaseSettings *DatabaseSettingsApplyConfiguration `json:"databaseSettings,omitempty"` + ScheduleConcurrency *int `json:"scheduleConcurrency,omitempty"` + AdditionalRuntimeImages []ConnectRuntimeImageSpecApplyConfiguration `json:"additionalRuntimeImages,omitempty"` + AdditionalConfig *string `json:"additionalConfig,omitempty"` } // InternalConnectSpecApplyConfiguration constructs a declarative configuration of the InternalConnectSpec type for use with @@ -100,6 +101,16 @@ func (b *InternalConnectSpecApplyConfiguration) WithResources(value v1.ResourceR return b } +// WithTopologySpreadConstraints adds the given value to the TopologySpreadConstraints field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the TopologySpreadConstraints field. +func (b *InternalConnectSpecApplyConfiguration) WithTopologySpreadConstraints(values ...v1.TopologySpreadConstraint) *InternalConnectSpecApplyConfiguration { + for i := range values { + b.TopologySpreadConstraints = append(b.TopologySpreadConstraints, values[i]) + } + return b +} + // WithAuth sets the Auth field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Auth field is set to the value of the last call. diff --git a/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go b/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go index 4d72141..a2c6ea8 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalpackagemanagerspec.go @@ -13,25 +13,26 @@ import ( // InternalPackageManagerSpecApplyConfiguration represents a declarative configuration of the InternalPackageManagerSpec type for use // with apply. type InternalPackageManagerSpecApplyConfiguration struct { - Enabled *bool `json:"enabled,omitempty"` - Teardown *bool `json:"teardown,omitempty"` - License *product.LicenseSpec `json:"license,omitempty"` - Volume *product.VolumeSpec `json:"volume,omitempty"` - NodeSelector map[string]string `json:"nodeSelector,omitempty"` - Resources *v1.ResourceRequirements `json:"resources,omitempty"` - AddEnv map[string]string `json:"addEnv,omitempty"` - EnvVars []v1.EnvVar `json:"envVars,omitempty"` - Image *string `json:"image,omitempty"` - ImagePullPolicy *v1.PullPolicy `json:"imagePullPolicy,omitempty"` - S3Bucket *string `json:"s3Bucket,omitempty"` - Replicas *int `json:"replicas,omitempty"` - DomainPrefix *string `json:"domainPrefix,omitempty"` - BaseDomain *string `json:"baseDomain,omitempty"` - GitSSHKeys []SSHKeyConfigApplyConfiguration `json:"gitSSHKeys,omitempty"` - AzureFiles *AzureFilesConfigApplyConfiguration `json:"azureFiles,omitempty"` - AdditionalConfig *string `json:"additionalConfig,omitempty"` - Auth *AuthSpecApplyConfiguration `json:"auth,omitempty"` - OIDCClientSecretKey *string `json:"oidcClientSecretKey,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + Teardown *bool `json:"teardown,omitempty"` + License *product.LicenseSpec `json:"license,omitempty"` + Volume *product.VolumeSpec `json:"volume,omitempty"` + NodeSelector map[string]string `json:"nodeSelector,omitempty"` + Resources *v1.ResourceRequirements `json:"resources,omitempty"` + TopologySpreadConstraints []v1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` + AddEnv map[string]string `json:"addEnv,omitempty"` + EnvVars []v1.EnvVar `json:"envVars,omitempty"` + Image *string `json:"image,omitempty"` + ImagePullPolicy *v1.PullPolicy `json:"imagePullPolicy,omitempty"` + S3Bucket *string `json:"s3Bucket,omitempty"` + Replicas *int `json:"replicas,omitempty"` + DomainPrefix *string `json:"domainPrefix,omitempty"` + BaseDomain *string `json:"baseDomain,omitempty"` + GitSSHKeys []SSHKeyConfigApplyConfiguration `json:"gitSSHKeys,omitempty"` + AzureFiles *AzureFilesConfigApplyConfiguration `json:"azureFiles,omitempty"` + AdditionalConfig *string `json:"additionalConfig,omitempty"` + Auth *AuthSpecApplyConfiguration `json:"auth,omitempty"` + OIDCClientSecretKey *string `json:"oidcClientSecretKey,omitempty"` } // InternalPackageManagerSpecApplyConfiguration constructs a declarative configuration of the InternalPackageManagerSpec type for use with @@ -94,6 +95,16 @@ func (b *InternalPackageManagerSpecApplyConfiguration) WithResources(value v1.Re return b } +// WithTopologySpreadConstraints adds the given value to the TopologySpreadConstraints field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the TopologySpreadConstraints field. +func (b *InternalPackageManagerSpecApplyConfiguration) WithTopologySpreadConstraints(values ...v1.TopologySpreadConstraint) *InternalPackageManagerSpecApplyConfiguration { + for i := range values { + b.TopologySpreadConstraints = append(b.TopologySpreadConstraints, values[i]) + } + return b +} + // WithAddEnv puts the entries into the AddEnv field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, the entries provided by each call will be put on the AddEnv field, diff --git a/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go b/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go index dab2eae..bc1677f 100644 --- a/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go +++ b/client-go/applyconfiguration/core/v1beta1/internalworkbenchspec.go @@ -23,6 +23,7 @@ type InternalWorkbenchSpecApplyConfiguration struct { AdditionalVolumes []product.VolumeSpec `json:"additionalVolumes,omitempty"` NodeSelector map[string]string `json:"nodeSelector,omitempty"` Resources *v1.ResourceRequirements `json:"resources,omitempty"` + TopologySpreadConstraints []v1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` Tolerations []v1.Toleration `json:"tolerations,omitempty"` SessionTolerations []v1.Toleration `json:"sessionTolerations,omitempty"` CreateUsersAutomatically *bool `json:"createUsersAutomatically,omitempty"` @@ -147,6 +148,16 @@ func (b *InternalWorkbenchSpecApplyConfiguration) WithResources(value v1.Resourc return b } +// WithTopologySpreadConstraints adds the given value to the TopologySpreadConstraints field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the TopologySpreadConstraints field. +func (b *InternalWorkbenchSpecApplyConfiguration) WithTopologySpreadConstraints(values ...v1.TopologySpreadConstraint) *InternalWorkbenchSpecApplyConfiguration { + for i := range values { + b.TopologySpreadConstraints = append(b.TopologySpreadConstraints, values[i]) + } + return b +} + // WithTolerations adds the given value to the Tolerations field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the Tolerations field. diff --git a/client-go/applyconfiguration/core/v1beta1/packagemanagerspec.go b/client-go/applyconfiguration/core/v1beta1/packagemanagerspec.go index a881eea..321229b 100644 --- a/client-go/applyconfiguration/core/v1beta1/packagemanagerspec.go +++ b/client-go/applyconfiguration/core/v1beta1/packagemanagerspec.go @@ -25,6 +25,7 @@ type PackageManagerSpecApplyConfiguration struct { ImagePullSecrets []string `json:"imagePullSecrets,omitempty"` NodeSelector map[string]string `json:"nodeSelector,omitempty"` Resources *v1.ResourceRequirements `json:"resources,omitempty"` + TopologySpreadConstraints []v1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` AddEnv map[string]string `json:"addEnv,omitempty"` EnvVars []v1.EnvVar `json:"envVars,omitempty"` Image *string `json:"image,omitempty"` @@ -159,6 +160,16 @@ func (b *PackageManagerSpecApplyConfiguration) WithResources(value v1.ResourceRe return b } +// WithTopologySpreadConstraints adds the given value to the TopologySpreadConstraints field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the TopologySpreadConstraints field. +func (b *PackageManagerSpecApplyConfiguration) WithTopologySpreadConstraints(values ...v1.TopologySpreadConstraint) *PackageManagerSpecApplyConfiguration { + for i := range values { + b.TopologySpreadConstraints = append(b.TopologySpreadConstraints, values[i]) + } + return b +} + // WithAddEnv puts the entries into the AddEnv field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, the entries provided by each call will be put on the AddEnv field, diff --git a/client-go/applyconfiguration/core/v1beta1/workbenchspec.go b/client-go/applyconfiguration/core/v1beta1/workbenchspec.go index 82c9072..1ae73b0 100644 --- a/client-go/applyconfiguration/core/v1beta1/workbenchspec.go +++ b/client-go/applyconfiguration/core/v1beta1/workbenchspec.go @@ -30,6 +30,7 @@ type WorkbenchSpecApplyConfiguration struct { ImagePullSecrets []string `json:"imagePullSecrets,omitempty"` NodeSelector map[string]string `json:"nodeSelector,omitempty"` Resources *v1.ResourceRequirements `json:"resources,omitempty"` + TopologySpreadConstraints []v1.TopologySpreadConstraint `json:"topologySpreadConstraints,omitempty"` Tolerations []v1.Toleration `json:"tolerations,omitempty"` AddEnv map[string]string `json:"addEnv,omitempty"` EnvVars []v1.EnvVar `json:"envVars,omitempty"` @@ -211,6 +212,16 @@ func (b *WorkbenchSpecApplyConfiguration) WithResources(value v1.ResourceRequire return b } +// WithTopologySpreadConstraints adds the given value to the TopologySpreadConstraints field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the TopologySpreadConstraints field. +func (b *WorkbenchSpecApplyConfiguration) WithTopologySpreadConstraints(values ...v1.TopologySpreadConstraint) *WorkbenchSpecApplyConfiguration { + for i := range values { + b.TopologySpreadConstraints = append(b.TopologySpreadConstraints, values[i]) + } + return b +} + // WithTolerations adds the given value to the Tolerations field in the declarative configuration // and returns the receiver, so that objects can be build by chaining "With" function invocations. // If called multiple times, values provided by each call will be appended to the Tolerations field. diff --git a/config/crd/bases/core.posit.team_connects.yaml b/config/crd/bases/core.posit.team_connects.yaml index 514eb56..d6540d3 100644 --- a/config/crd/bases/core.posit.team_connects.yaml +++ b/config/crd/bases/core.posit.team_connects.yaml @@ -7576,6 +7576,183 @@ spec: Suspended indicates Connect should not run serving resources (Deployment, Service, Ingress) but should preserve data resources (PVC, database, secrets). Set by the Site controller. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread matching + pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array url: type: string volume: diff --git a/config/crd/bases/core.posit.team_packagemanagers.yaml b/config/crd/bases/core.posit.team_packagemanagers.yaml index 539e433..80cd7d6 100644 --- a/config/crd/bases/core.posit.team_packagemanagers.yaml +++ b/config/crd/bases/core.posit.team_packagemanagers.yaml @@ -620,6 +620,183 @@ spec: Suspended indicates Package Manager should not run serving resources (Deployment, Service, Ingress) but should preserve data resources (PVC, database, secrets). Set by the Site controller. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread matching + pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array url: type: string volume: diff --git a/config/crd/bases/core.posit.team_sites.yaml b/config/crd/bases/core.posit.team_sites.yaml index f1e124d..78e7992 100644 --- a/config/crd/bases/core.posit.team_sites.yaml +++ b/config/crd/bases/core.posit.team_sites.yaml @@ -695,6 +695,183 @@ spec: secrets, and persistent volume claim. Only takes effect when Enabled is false. Re-enabling after teardown starts fresh with a new empty database. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) @@ -1303,6 +1480,183 @@ spec: secrets, and persistent volume claim. Only takes effect when Enabled is false. Re-enabling after teardown starts fresh with a new empty database. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) @@ -2419,6 +2773,183 @@ spec: type: string type: object type: array + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. Applies only to the server Deployment, not sessions. + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) diff --git a/config/crd/bases/core.posit.team_workbenches.yaml b/config/crd/bases/core.posit.team_workbenches.yaml index 79839b1..c6d3f21 100644 --- a/config/crd/bases/core.posit.team_workbenches.yaml +++ b/config/crd/bases/core.posit.team_workbenches.yaml @@ -7935,6 +7935,183 @@ spec: type: string type: object type: array + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. Applies only to the server Deployment, not sessions. + items: + description: TopologySpreadConstraint specifies how to spread matching + pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array url: type: string volume: diff --git a/dist/chart/templates/crd/core.posit.team_connects.yaml b/dist/chart/templates/crd/core.posit.team_connects.yaml index e8f3f75..e900ee4 100755 --- a/dist/chart/templates/crd/core.posit.team_connects.yaml +++ b/dist/chart/templates/crd/core.posit.team_connects.yaml @@ -7597,6 +7597,183 @@ spec: Suspended indicates Connect should not run serving resources (Deployment, Service, Ingress) but should preserve data resources (PVC, database, secrets). Set by the Site controller. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread matching + pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array url: type: string volume: diff --git a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml index 87d1f9f..271f8d9 100755 --- a/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml +++ b/dist/chart/templates/crd/core.posit.team_packagemanagers.yaml @@ -641,6 +641,183 @@ spec: Suspended indicates Package Manager should not run serving resources (Deployment, Service, Ingress) but should preserve data resources (PVC, database, secrets). Set by the Site controller. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread matching + pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array url: type: string volume: diff --git a/dist/chart/templates/crd/core.posit.team_sites.yaml b/dist/chart/templates/crd/core.posit.team_sites.yaml index a1201d7..507e75f 100755 --- a/dist/chart/templates/crd/core.posit.team_sites.yaml +++ b/dist/chart/templates/crd/core.posit.team_sites.yaml @@ -716,6 +716,183 @@ spec: secrets, and persistent volume claim. Only takes effect when Enabled is false. Re-enabling after teardown starts fresh with a new empty database. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) @@ -1324,6 +1501,183 @@ spec: secrets, and persistent volume claim. Only takes effect when Enabled is false. Re-enabling after teardown starts fresh with a new empty database. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) @@ -2440,6 +2794,183 @@ spec: type: string type: object type: array + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. Applies only to the server Deployment, not sessions. + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) diff --git a/dist/chart/templates/crd/core.posit.team_workbenches.yaml b/dist/chart/templates/crd/core.posit.team_workbenches.yaml index 1fd5626..c1a20d9 100755 --- a/dist/chart/templates/crd/core.posit.team_workbenches.yaml +++ b/dist/chart/templates/crd/core.posit.team_workbenches.yaml @@ -7956,6 +7956,183 @@ spec: type: string type: object type: array + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. Applies only to the server Deployment, not sessions. + items: + description: TopologySpreadConstraint specifies how to spread matching + pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array url: type: string volume: diff --git a/internal/controller/core/connect.go b/internal/controller/core/connect.go index fce4cfa..da5d3da 100644 --- a/internal/controller/core/connect.go +++ b/internal/controller/core/connect.go @@ -674,10 +674,11 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. }, }, Spec: corev1.PodSpec{ - EnableServiceLinks: ptr.To(false), - NodeSelector: c.Spec.NodeSelector, - ImagePullSecrets: pullSecrets, - ServiceAccountName: maybeServiceAccountName, + EnableServiceLinks: ptr.To(false), + NodeSelector: c.Spec.NodeSelector, + TopologySpreadConstraints: c.Spec.TopologySpreadConstraints, + ImagePullSecrets: pullSecrets, + ServiceAccountName: maybeServiceAccountName, // TODO: go back to automounting service token... AutomountServiceAccountToken: ptr.To(false), Containers: product.ConcatLists([]corev1.Container{ diff --git a/internal/controller/core/connect_test.go b/internal/controller/core/connect_test.go index 3badb26..7e6ba4d 100644 --- a/internal/controller/core/connect_test.go +++ b/internal/controller/core/connect_test.go @@ -664,6 +664,39 @@ func TestConnectReconciler_ResourcesOverride(t *testing.T) { assertQuantityEqual(t, "8Gi", resources.Limits[corev1.ResourceMemory]) } +// TestConnectReconciler_TopologySpreadConstraints verifies an explicit +// Spec.TopologySpreadConstraints passes through unchanged onto the server Deployment. +func TestConnectReconciler_TopologySpreadConstraints(t *testing.T) { + ctx := context.Background() + ns := "posit-team" + name := "connect-topology-spread" + + ctx, r, req, cli := initConnectReconciler(t, ctx, ns, name) + + constraints := []corev1.TopologySpreadConstraint{ + { + MaxSkew: 1, + TopologyKey: "kubernetes.io/arch", + WhenUnsatisfiable: corev1.DoNotSchedule, + }, + } + + c := defineDefaultConnect(t, ns, name) + c.Spec.TopologySpreadConstraints = constraints + + err := internal.BasicCreateOrUpdate(ctx, r, r.GetLogger(ctx), req.NamespacedName, &positcov1beta1.Connect{}, c) + require.NoError(t, err) + + c = getConnect(t, cli, ns, name) + + res, err := r.ReconcileConnect(ctx, req, c) + require.NoError(t, err) + require.True(t, res.IsZero()) + + deployment := getDeployment(t, cli, ns, c.ComponentName()) + assert.Equal(t, constraints, deployment.Spec.Template.Spec.TopologySpreadConstraints) +} + // TestConnectReconciler_Suspended verifies that when Connect has Suspended=true, // ReconcileConnect does not create serving resources (Deployment, Service, Ingress). func TestConnectReconciler_Suspended(t *testing.T) { diff --git a/internal/controller/core/package_manager.go b/internal/controller/core/package_manager.go index 23168d5..9e1de26 100644 --- a/internal/controller/core/package_manager.go +++ b/internal/controller/core/package_manager.go @@ -520,10 +520,11 @@ func (r *PackageManagerReconciler) ensureDeployedService(ctx context.Context, re }, }, Spec: corev1.PodSpec{ - EnableServiceLinks: ptr.To(false), - NodeSelector: pm.Spec.NodeSelector, - ImagePullSecrets: pullSecrets, - ServiceAccountName: pm.ComponentName(), + EnableServiceLinks: ptr.To(false), + NodeSelector: pm.Spec.NodeSelector, + TopologySpreadConstraints: pm.Spec.TopologySpreadConstraints, + ImagePullSecrets: pullSecrets, + ServiceAccountName: pm.ComponentName(), Containers: product.ConcatLists([]corev1.Container{ { Name: "rspm", diff --git a/internal/controller/core/package_manager_controller_test.go b/internal/controller/core/package_manager_controller_test.go index b1cd44c..d36ee45 100644 --- a/internal/controller/core/package_manager_controller_test.go +++ b/internal/controller/core/package_manager_controller_test.go @@ -270,6 +270,63 @@ func TestPackageManagerReconciler_ResourcesOverride(t *testing.T) { assertQuantityEqual(t, "16Gi", resources.Limits[corev1.ResourceMemory]) } +// TestPackageManagerReconciler_TopologySpreadConstraints verifies an explicit +// Spec.TopologySpreadConstraints passes through unchanged onto the server Deployment. +func TestPackageManagerReconciler_TopologySpreadConstraints(t *testing.T) { + ctx := context.Background() + ns := "posit-team" + name := "pm-topology-spread" + + fakeEnv := localtest.FakeTestEnv{} + cli, scheme, log := fakeEnv.Start(loadSchemes) + + r := &PackageManagerReconciler{ + Client: cli, + Scheme: scheme, + Log: log, + } + + ctx = logr.NewContext(ctx, log) + req := ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: ns, Name: name}, + } + + constraints := []corev1.TopologySpreadConstraint{ + { + MaxSkew: 1, + TopologyKey: "kubernetes.io/arch", + WhenUnsatisfiable: corev1.DoNotSchedule, + }, + } + + pm := &positcov1beta1.PackageManager{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageManager", + APIVersion: "core.posit.team/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name, UID: "pm-topology-spread-uid"}, + Spec: positcov1beta1.PackageManagerSpec{ + Image: "ghcr.io/rstudio/rstudio-pm:test", + Secret: positcov1beta1.SecretConfig{ + Type: product.SiteSecretKubernetes, + }, + Config: &positcov1beta1.PackageManagerConfig{}, + TopologySpreadConstraints: constraints, + }, + } + + require.NoError(t, cli.Create(ctx, pm)) + + _, err := r.ensureDeployedService(ctx, req, pm) + require.NoError(t, err) + + dep := &appsv1.Deployment{} + err = cli.Get(ctx, client.ObjectKey{Name: pm.ComponentName(), Namespace: ns}, dep) + require.NoError(t, err) + + assert.Equal(t, constraints, dep.Spec.Template.Spec.TopologySpreadConstraints) +} + // TestPackageManagerReconciler_EnvVars verifies that the envVars field, including a // valueFrom.secretKeyRef entry, flows through to the rendered Package Manager // container's Env. diff --git a/internal/controller/core/site_controller_connect.go b/internal/controller/core/site_controller_connect.go index 22786c7..5a6a872 100644 --- a/internal/controller/core/site_controller_connect.go +++ b/internal/controller/core/site_controller_connect.go @@ -145,6 +145,7 @@ func (r *SiteReconciler) reconcileConnect( AdditionalVolumes: additionalVolumes, NodeSelector: site.Spec.Connect.NodeSelector, Resources: site.Spec.Connect.Resources, + TopologySpreadConstraints: site.Spec.Connect.TopologySpreadConstraints, AddEnv: site.Spec.Connect.AddEnv, EnvVars: site.Spec.Connect.EnvVars, // default to true... diff --git a/internal/controller/core/site_controller_package_manager.go b/internal/controller/core/site_controller_package_manager.go index 3b7a049..280e467 100644 --- a/internal/controller/core/site_controller_package_manager.go +++ b/internal/controller/core/site_controller_package_manager.go @@ -99,6 +99,7 @@ func (r *SiteReconciler) reconcilePackageManager( ChronicleAgentImage: site.Spec.Chronicle.AgentImage, NodeSelector: site.Spec.PackageManager.NodeSelector, Resources: site.Spec.PackageManager.Resources, + TopologySpreadConstraints: site.Spec.PackageManager.TopologySpreadConstraints, AddEnv: site.Spec.PackageManager.AddEnv, EnvVars: site.Spec.PackageManager.EnvVars, Secret: site.Spec.Secret, diff --git a/internal/controller/core/site_controller_workbench.go b/internal/controller/core/site_controller_workbench.go index 892ee13..fd9f54e 100644 --- a/internal/controller/core/site_controller_workbench.go +++ b/internal/controller/core/site_controller_workbench.go @@ -264,6 +264,7 @@ func (r *SiteReconciler) reconcileWorkbench( ImagePullSecrets: site.Spec.ImagePullSecrets, NodeSelector: site.Spec.Workbench.NodeSelector, Resources: site.Spec.Workbench.Resources, + TopologySpreadConstraints: site.Spec.Workbench.TopologySpreadConstraints, Tolerations: site.Spec.Workbench.Tolerations, AddEnv: site.Spec.Workbench.AddEnv, EnvVars: site.Spec.Workbench.EnvVars, diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index b0bca23..7a91145 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -881,6 +881,7 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr Spec: corev1.PodSpec{ EnableServiceLinks: ptr.To(false), NodeSelector: w.Spec.NodeSelector, + TopologySpreadConstraints: w.Spec.TopologySpreadConstraints, ImagePullSecrets: pullSecrets, ServiceAccountName: maybeServiceAccountName, AutomountServiceAccountToken: ptr.To(true), diff --git a/internal/controller/core/workbench_test.go b/internal/controller/core/workbench_test.go index 02dbffb..11e7108 100644 --- a/internal/controller/core/workbench_test.go +++ b/internal/controller/core/workbench_test.go @@ -554,6 +554,39 @@ func TestWorkbenchReconciler_ResourcesOverride(t *testing.T) { assertQuantityEqual(t, "8Gi", resources.Limits[corev1.ResourceMemory]) } +// TestWorkbenchReconciler_TopologySpreadConstraints verifies an explicit +// Spec.TopologySpreadConstraints passes through unchanged onto the server Deployment. +func TestWorkbenchReconciler_TopologySpreadConstraints(t *testing.T) { + ctx := context.Background() + ns := "posit-team" + name := "workbench-topology-spread" + + ctx, r, req, cli := initWorkbenchReconciler(t, ctx, ns, name) + + constraints := []corev1.TopologySpreadConstraint{ + { + MaxSkew: 1, + TopologyKey: "kubernetes.io/arch", + WhenUnsatisfiable: corev1.DoNotSchedule, + }, + } + + wb := defineDefaultWorkbench(t, ns, name) + wb.Spec.TopologySpreadConstraints = constraints + + err := internal.BasicCreateOrUpdate(ctx, r, r.GetLogger(ctx), req.NamespacedName, &positcov1beta1.Workbench{}, wb) + require.NoError(t, err) + + wb = getWorkbench(t, cli, ns, name) + + res, err := r.ReconcileWorkbench(ctx, req, wb) + require.NoError(t, err) + require.True(t, res.IsZero()) + + deployment := getDeployment(t, cli, ns, wb.ComponentName()) + assert.Equal(t, constraints, deployment.Spec.Template.Spec.TopologySpreadConstraints) +} + // TestWorkbenchReconciler_Suspended verifies that when Workbench has Suspended=true, // ReconcileWorkbench does not create serving resources (Deployment, Service, Ingress). func TestWorkbenchReconciler_Suspended(t *testing.T) { diff --git a/internal/crdapply/bases/core.posit.team_connects.yaml b/internal/crdapply/bases/core.posit.team_connects.yaml index 514eb56..d6540d3 100644 --- a/internal/crdapply/bases/core.posit.team_connects.yaml +++ b/internal/crdapply/bases/core.posit.team_connects.yaml @@ -7576,6 +7576,183 @@ spec: Suspended indicates Connect should not run serving resources (Deployment, Service, Ingress) but should preserve data resources (PVC, database, secrets). Set by the Site controller. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread matching + pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array url: type: string volume: diff --git a/internal/crdapply/bases/core.posit.team_packagemanagers.yaml b/internal/crdapply/bases/core.posit.team_packagemanagers.yaml index 539e433..80cd7d6 100644 --- a/internal/crdapply/bases/core.posit.team_packagemanagers.yaml +++ b/internal/crdapply/bases/core.posit.team_packagemanagers.yaml @@ -620,6 +620,183 @@ spec: Suspended indicates Package Manager should not run serving resources (Deployment, Service, Ingress) but should preserve data resources (PVC, database, secrets). Set by the Site controller. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread matching + pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array url: type: string volume: diff --git a/internal/crdapply/bases/core.posit.team_sites.yaml b/internal/crdapply/bases/core.posit.team_sites.yaml index f1e124d..78e7992 100644 --- a/internal/crdapply/bases/core.posit.team_sites.yaml +++ b/internal/crdapply/bases/core.posit.team_sites.yaml @@ -695,6 +695,183 @@ spec: secrets, and persistent volume claim. Only takes effect when Enabled is false. Re-enabling after teardown starts fresh with a new empty database. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) @@ -1303,6 +1480,183 @@ spec: secrets, and persistent volume claim. Only takes effect when Enabled is false. Re-enabling after teardown starts fresh with a new empty database. type: boolean + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) @@ -2419,6 +2773,183 @@ spec: type: string type: object type: array + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. Applies only to the server Deployment, not sessions. + items: + description: TopologySpreadConstraint specifies how to spread + matching pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array volume: description: VolumeSpec is a specification for a PersistentVolumeClaim to be created (and/or mounted) diff --git a/internal/crdapply/bases/core.posit.team_workbenches.yaml b/internal/crdapply/bases/core.posit.team_workbenches.yaml index 79839b1..c6d3f21 100644 --- a/internal/crdapply/bases/core.posit.team_workbenches.yaml +++ b/internal/crdapply/bases/core.posit.team_workbenches.yaml @@ -7935,6 +7935,183 @@ spec: type: string type: object type: array + topologySpreadConstraints: + description: |- + TopologySpreadConstraints controls how server pod replicas are spread across topology + domains (e.g. node architecture, zone, hostname). Composes with the operator's built-in + hostname-keyed soft anti-affinity. Applies only to the server Deployment, not sessions. + items: + description: TopologySpreadConstraint specifies how to spread matching + pods among the given topology. + properties: + labelSelector: + description: |- + LabelSelector is used to find matching pods. + Pods that match this label selector are counted to determine the number of pods + in their corresponding topology domain. + properties: + matchExpressions: + description: matchExpressions is a list of label selector + requirements. The requirements are ANDed. + items: + description: |- + A label selector requirement is a selector that contains values, a key, and an operator that + relates the key and values. + properties: + key: + description: key is the label key that the selector + applies to. + type: string + operator: + description: |- + operator represents a key's relationship to a set of values. + Valid operators are In, NotIn, Exists and DoesNotExist. + type: string + values: + description: |- + values is an array of string values. If the operator is In or NotIn, + the values array must be non-empty. If the operator is Exists or DoesNotExist, + the values array must be empty. This array is replaced during a strategic + merge patch. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - key + - operator + type: object + type: array + x-kubernetes-list-type: atomic + matchLabels: + additionalProperties: + type: string + description: |- + matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels + map is equivalent to an element of matchExpressions, whose key field is "key", the + operator is "In", and the values array contains only "value". The requirements are ANDed. + type: object + type: object + x-kubernetes-map-type: atomic + matchLabelKeys: + description: |- + MatchLabelKeys is a set of pod label keys to select the pods over which + spreading will be calculated. The keys are used to lookup values from the + incoming pod labels, those key-value labels are ANDed with labelSelector + to select the group of existing pods over which spreading will be calculated + for the incoming pod. The same key is forbidden to exist in both MatchLabelKeys and LabelSelector. + MatchLabelKeys cannot be set when LabelSelector isn't set. + Keys that don't exist in the incoming pod labels will + be ignored. A null or empty list means only match against labelSelector. + + This is a beta field and requires the MatchLabelKeysInPodTopologySpread feature gate to be enabled (enabled by default). + items: + type: string + type: array + x-kubernetes-list-type: atomic + maxSkew: + description: |- + MaxSkew describes the degree to which pods may be unevenly distributed. + When `whenUnsatisfiable=DoNotSchedule`, it is the maximum permitted difference + between the number of matching pods in the target topology and the global minimum. + The global minimum is the minimum number of matching pods in an eligible domain + or zero if the number of eligible domains is less than MinDomains. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 2/2/1: + In this case, the global minimum is 1. + | zone1 | zone2 | zone3 | + | P P | P P | P | + - if MaxSkew is 1, incoming pod can only be scheduled to zone3 to become 2/2/2; + scheduling it onto zone1(zone2) would make the ActualSkew(3-1) on zone1(zone2) + violate MaxSkew(1). + - if MaxSkew is 2, incoming pod can be scheduled onto any zone. + When `whenUnsatisfiable=ScheduleAnyway`, it is used to give higher precedence + to topologies that satisfy it. + It's a required field. Default value is 1 and 0 is not allowed. + format: int32 + type: integer + minDomains: + description: |- + MinDomains indicates a minimum number of eligible domains. + When the number of eligible domains with matching topology keys is less than minDomains, + Pod Topology Spread treats "global minimum" as 0, and then the calculation of Skew is performed. + And when the number of eligible domains with matching topology keys equals or greater than minDomains, + this value has no effect on scheduling. + As a result, when the number of eligible domains is less than minDomains, + scheduler won't schedule more than maxSkew Pods to those domains. + If value is nil, the constraint behaves as if MinDomains is equal to 1. + Valid values are integers greater than 0. + When value is not nil, WhenUnsatisfiable must be DoNotSchedule. + + For example, in a 3-zone cluster, MaxSkew is set to 2, MinDomains is set to 5 and pods with the same + labelSelector spread as 2/2/2: + | zone1 | zone2 | zone3 | + | P P | P P | P P | + The number of domains is less than 5(MinDomains), so "global minimum" is treated as 0. + In this situation, new pod with the same labelSelector cannot be scheduled, + because computed skew will be 3(3 - 0) if new Pod is scheduled to any of the three zones, + it will violate MaxSkew. + format: int32 + type: integer + nodeAffinityPolicy: + description: |- + NodeAffinityPolicy indicates how we will treat Pod's nodeAffinity/nodeSelector + when calculating pod topology spread skew. Options are: + - Honor: only nodes matching nodeAffinity/nodeSelector are included in the calculations. + - Ignore: nodeAffinity/nodeSelector are ignored. All nodes are included in the calculations. + + If this value is nil, the behavior is equivalent to the Honor policy. + type: string + nodeTaintsPolicy: + description: |- + NodeTaintsPolicy indicates how we will treat node taints when calculating + pod topology spread skew. Options are: + - Honor: nodes without taints, along with tainted nodes for which the incoming pod + has a toleration, are included. + - Ignore: node taints are ignored. All nodes are included. + + If this value is nil, the behavior is equivalent to the Ignore policy. + type: string + topologyKey: + description: |- + TopologyKey is the key of node labels. Nodes that have a label with this key + and identical values are considered to be in the same topology. + We consider each as a "bucket", and try to put balanced number + of pods into each bucket. + We define a domain as a particular instance of a topology. + Also, we define an eligible domain as a domain whose nodes meet the requirements of + nodeAffinityPolicy and nodeTaintsPolicy. + e.g. If TopologyKey is "kubernetes.io/hostname", each Node is a domain of that topology. + And, if TopologyKey is "topology.kubernetes.io/zone", each zone is a domain of that topology. + It's a required field. + type: string + whenUnsatisfiable: + description: |- + WhenUnsatisfiable indicates how to deal with a pod if it doesn't satisfy + the spread constraint. + - DoNotSchedule (default) tells the scheduler not to schedule it. + - ScheduleAnyway tells the scheduler to schedule the pod in any location, + but giving higher precedence to topologies that would help reduce the + skew. + A constraint is considered "Unsatisfiable" for an incoming pod + if and only if every possible node assignment for that pod would violate + "MaxSkew" on some topology. + For example, in a 3-zone cluster, MaxSkew is set to 1, and pods with the same + labelSelector spread as 3/1/1: + | zone1 | zone2 | zone3 | + | P P P | P | P | + If WhenUnsatisfiable is set to DoNotSchedule, incoming pod can only be scheduled + to zone2(zone3) to become 3/2/1(3/1/2) as ActualSkew(2-1) on zone2(zone3) satisfies + MaxSkew(1). In other words, the cluster can still be imbalanced, but scheduler + won't make it *more* imbalanced. + It's a required field. + type: string + required: + - maxSkew + - topologyKey + - whenUnsatisfiable + type: object + type: array url: type: string volume: From 50efae97891a696e3f9614c34b40d1b3638a0e10 Mon Sep 17 00:00:00 2001 From: "Benjamin R. J. Schwedler" Date: Thu, 23 Jul 2026 10:46:40 -0500 Subject: [PATCH 2/2] fix: default topologySpreadConstraints labelSelector A TopologySpreadConstraint with no labelSelector matches zero pods under Kubernetes semantics (metav1.LabelSelectorAsSelector(nil) returns labels.Nothing()), which silently turns maxSkew and whenUnsatisfiable into a no-op. That's the exact shape used in the motivating multi-arch example, so shipping the raw pass-through from the prior commit would let the constraint compile, deploy, and pass review while never actually forcing anything. Neither Kubernetes nor the wider operator ecosystem solves this well: upstream closed a request to validate it as "not planned" (kubernetes/kubernetes#135797), and prometheus-operator's own StatefulSet builder does the same raw pass-through. Extracts the label-identity selector already used by ComponentSpecPodAntiAffinity into componentSelector, and adds ComponentSpecTopologySpreadConstraints, which fills in that selector on any constraint that omits one before it reaches the Deployment. An explicit caller-supplied selector is always left untouched, so the default only fires for the common case this field exists to serve: spreading a component's own replicas. --- api/core/v1beta1/shared.go | 66 +++++++++---- internal/controller/core/connect.go | 2 +- internal/controller/core/connect_test.go | 57 +++++++++++- internal/controller/core/package_manager.go | 2 +- .../core/package_manager_controller_test.go | 93 +++++++++++++++++-- internal/controller/core/workbench.go | 2 +- internal/controller/core/workbench_test.go | 57 +++++++++++- 7 files changed, 238 insertions(+), 41 deletions(-) diff --git a/api/core/v1beta1/shared.go b/api/core/v1beta1/shared.go index edba128..5590f63 100644 --- a/api/core/v1beta1/shared.go +++ b/api/core/v1beta1/shared.go @@ -68,6 +68,29 @@ type SecretConfig struct { Type product.SiteSecretType `json:"type,omitempty"` } +// componentSelector is the label-identity selector for a component's own pods — +// shared by anti-affinity and topology spread so both target the same pod set. +func componentSelector(p product.KubernetesLabelser) *metav1.LabelSelector { + return &metav1.LabelSelector{ + MatchExpressions: []metav1.LabelSelectorRequirement{ + { + Key: KubernetesInstanceLabelKey, + Operator: metav1.LabelSelectorOpIn, + Values: []string{ + p.KubernetesLabels()[KubernetesInstanceLabelKey], + }, + }, + { + Key: SiteLabelKey, + Operator: metav1.LabelSelectorOpIn, + Values: []string{ + p.KubernetesLabels()[SiteLabelKey], + }, + }, + }, + } +} + // ComponentSpecPodAntiAffinity generates a *corev1.PodAntiAffinity suitable for use in a // given component's deployment template spec to inform kubernetes to place pod replicas // on separate nodes when possible. @@ -77,28 +100,31 @@ func ComponentSpecPodAntiAffinity(p product.KubernetesLabelser, namespace string { Weight: 1, PodAffinityTerm: corev1.PodAffinityTerm{ - TopologyKey: "kubernetes.io/hostname", - Namespaces: []string{namespace}, - LabelSelector: &metav1.LabelSelector{ - MatchExpressions: []metav1.LabelSelectorRequirement{ - { - Key: KubernetesInstanceLabelKey, - Operator: metav1.LabelSelectorOpIn, - Values: []string{ - p.KubernetesLabels()[KubernetesInstanceLabelKey], - }, - }, - { - Key: SiteLabelKey, - Operator: metav1.LabelSelectorOpIn, - Values: []string{ - p.KubernetesLabels()[SiteLabelKey], - }, - }, - }, - }, + TopologyKey: "kubernetes.io/hostname", + Namespaces: []string{namespace}, + LabelSelector: componentSelector(p), }, }, }, } } + +// ComponentSpecTopologySpreadConstraints defaults any constraint's LabelSelector to +// componentSelector when the caller left it nil. A nil LabelSelector matches zero pods +// (Kubernetes semantics: metav1.LabelSelectorAsSelector(nil) returns labels.Nothing()), +// silently turning maxSkew/whenUnsatisfiable into a no-op. This exists so the common case +// (spread this component's own replicas) works without the caller re-deriving labels the +// operator already owns; an explicit caller-supplied LabelSelector is always left untouched. +func ComponentSpecTopologySpreadConstraints( + constraints []corev1.TopologySpreadConstraint, + p product.KubernetesLabelser, +) []corev1.TopologySpreadConstraint { + out := make([]corev1.TopologySpreadConstraint, len(constraints)) + for i, c := range constraints { + if c.LabelSelector == nil { + c.LabelSelector = componentSelector(p) + } + out[i] = c + } + return out +} diff --git a/internal/controller/core/connect.go b/internal/controller/core/connect.go index da5d3da..cae7c51 100644 --- a/internal/controller/core/connect.go +++ b/internal/controller/core/connect.go @@ -676,7 +676,7 @@ func (r *ConnectReconciler) ensureDeployedService(ctx context.Context, req ctrl. Spec: corev1.PodSpec{ EnableServiceLinks: ptr.To(false), NodeSelector: c.Spec.NodeSelector, - TopologySpreadConstraints: c.Spec.TopologySpreadConstraints, + TopologySpreadConstraints: positcov1beta1.ComponentSpecTopologySpreadConstraints(c.Spec.TopologySpreadConstraints, c), ImagePullSecrets: pullSecrets, ServiceAccountName: maybeServiceAccountName, // TODO: go back to automounting service token... diff --git a/internal/controller/core/connect_test.go b/internal/controller/core/connect_test.go index 7e6ba4d..c731b92 100644 --- a/internal/controller/core/connect_test.go +++ b/internal/controller/core/connect_test.go @@ -18,6 +18,7 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -665,7 +666,10 @@ func TestConnectReconciler_ResourcesOverride(t *testing.T) { } // TestConnectReconciler_TopologySpreadConstraints verifies an explicit -// Spec.TopologySpreadConstraints passes through unchanged onto the server Deployment. +// Spec.TopologySpreadConstraints passes through onto the server Deployment, with an +// omitted LabelSelector auto-filled to match this component's own pods. Without this, +// the constraint would silently match zero pods and never do anything (Kubernetes +// semantics: a nil LabelSelector matches nothing, not everything). func TestConnectReconciler_TopologySpreadConstraints(t *testing.T) { ctx := context.Background() ns := "posit-team" @@ -673,7 +677,8 @@ func TestConnectReconciler_TopologySpreadConstraints(t *testing.T) { ctx, r, req, cli := initConnectReconciler(t, ctx, ns, name) - constraints := []corev1.TopologySpreadConstraint{ + c := defineDefaultConnect(t, ns, name) + c.Spec.TopologySpreadConstraints = []corev1.TopologySpreadConstraint{ { MaxSkew: 1, TopologyKey: "kubernetes.io/arch", @@ -681,8 +686,50 @@ func TestConnectReconciler_TopologySpreadConstraints(t *testing.T) { }, } + err := internal.BasicCreateOrUpdate(ctx, r, r.GetLogger(ctx), req.NamespacedName, &positcov1beta1.Connect{}, c) + require.NoError(t, err) + + c = getConnect(t, cli, ns, name) + + res, err := r.ReconcileConnect(ctx, req, c) + require.NoError(t, err) + require.True(t, res.IsZero()) + + deployment := getDeployment(t, cli, ns, c.ComponentName()) + got := deployment.Spec.Template.Spec.TopologySpreadConstraints + require.Len(t, got, 1) + assert.Equal(t, int32(1), got[0].MaxSkew) + assert.Equal(t, "kubernetes.io/arch", got[0].TopologyKey) + assert.Equal(t, corev1.DoNotSchedule, got[0].WhenUnsatisfiable) + + require.NotNil(t, got[0].LabelSelector, "an omitted LabelSelector must be auto-filled, or the constraint silently matches zero pods") + selector, err := metav1.LabelSelectorAsSelector(got[0].LabelSelector) + require.NoError(t, err) + assert.True(t, selector.Matches(labels.Set(c.KubernetesLabels())), + "auto-filled LabelSelector must match this component's own pod labels, or the constraint counts nothing") +} + +// TestConnectReconciler_TopologySpreadConstraintsExplicitSelector verifies that an +// explicit LabelSelector on a constraint is left untouched, not overridden by the +// auto-fill default. +func TestConnectReconciler_TopologySpreadConstraintsExplicitSelector(t *testing.T) { + ctx := context.Background() + ns := "posit-team" + name := "connect-topology-spread-explicit" + + ctx, r, req, cli := initConnectReconciler(t, ctx, ns, name) + + explicitSelector := &metav1.LabelSelector{MatchLabels: map[string]string{"custom": "selector"}} + c := defineDefaultConnect(t, ns, name) - c.Spec.TopologySpreadConstraints = constraints + c.Spec.TopologySpreadConstraints = []corev1.TopologySpreadConstraint{ + { + MaxSkew: 1, + TopologyKey: "kubernetes.io/arch", + WhenUnsatisfiable: corev1.DoNotSchedule, + LabelSelector: explicitSelector, + }, + } err := internal.BasicCreateOrUpdate(ctx, r, r.GetLogger(ctx), req.NamespacedName, &positcov1beta1.Connect{}, c) require.NoError(t, err) @@ -694,7 +741,9 @@ func TestConnectReconciler_TopologySpreadConstraints(t *testing.T) { require.True(t, res.IsZero()) deployment := getDeployment(t, cli, ns, c.ComponentName()) - assert.Equal(t, constraints, deployment.Spec.Template.Spec.TopologySpreadConstraints) + got := deployment.Spec.Template.Spec.TopologySpreadConstraints + require.Len(t, got, 1) + assert.Equal(t, explicitSelector, got[0].LabelSelector, "an explicit LabelSelector must not be overridden by the auto-fill default") } // TestConnectReconciler_Suspended verifies that when Connect has Suspended=true, diff --git a/internal/controller/core/package_manager.go b/internal/controller/core/package_manager.go index 9e1de26..f734f81 100644 --- a/internal/controller/core/package_manager.go +++ b/internal/controller/core/package_manager.go @@ -522,7 +522,7 @@ func (r *PackageManagerReconciler) ensureDeployedService(ctx context.Context, re Spec: corev1.PodSpec{ EnableServiceLinks: ptr.To(false), NodeSelector: pm.Spec.NodeSelector, - TopologySpreadConstraints: pm.Spec.TopologySpreadConstraints, + TopologySpreadConstraints: positcov1beta1.ComponentSpecTopologySpreadConstraints(pm.Spec.TopologySpreadConstraints, pm), ImagePullSecrets: pullSecrets, ServiceAccountName: pm.ComponentName(), Containers: product.ConcatLists([]corev1.Container{ diff --git a/internal/controller/core/package_manager_controller_test.go b/internal/controller/core/package_manager_controller_test.go index d36ee45..ed29af2 100644 --- a/internal/controller/core/package_manager_controller_test.go +++ b/internal/controller/core/package_manager_controller_test.go @@ -20,6 +20,7 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" "k8s.io/apimachinery/pkg/util/intstr" ctrl "sigs.k8s.io/controller-runtime" @@ -271,7 +272,10 @@ func TestPackageManagerReconciler_ResourcesOverride(t *testing.T) { } // TestPackageManagerReconciler_TopologySpreadConstraints verifies an explicit -// Spec.TopologySpreadConstraints passes through unchanged onto the server Deployment. +// Spec.TopologySpreadConstraints passes through onto the server Deployment, with an +// omitted LabelSelector auto-filled to match this component's own pods. Without this, +// the constraint would silently match zero pods and never do anything (Kubernetes +// semantics: a nil LabelSelector matches nothing, not everything). func TestPackageManagerReconciler_TopologySpreadConstraints(t *testing.T) { ctx := context.Background() ns := "posit-team" @@ -291,27 +295,94 @@ func TestPackageManagerReconciler_TopologySpreadConstraints(t *testing.T) { NamespacedName: types.NamespacedName{Namespace: ns, Name: name}, } - constraints := []corev1.TopologySpreadConstraint{ - { - MaxSkew: 1, - TopologyKey: "kubernetes.io/arch", - WhenUnsatisfiable: corev1.DoNotSchedule, + pm := &positcov1beta1.PackageManager{ + TypeMeta: metav1.TypeMeta{ + Kind: "PackageManager", + APIVersion: "core.posit.team/v1beta1", + }, + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name, UID: "pm-topology-spread-uid"}, + Spec: positcov1beta1.PackageManagerSpec{ + Image: "ghcr.io/rstudio/rstudio-pm:test", + Secret: positcov1beta1.SecretConfig{ + Type: product.SiteSecretKubernetes, + }, + Config: &positcov1beta1.PackageManagerConfig{}, + TopologySpreadConstraints: []corev1.TopologySpreadConstraint{ + { + MaxSkew: 1, + TopologyKey: "kubernetes.io/arch", + WhenUnsatisfiable: corev1.DoNotSchedule, + }, + }, }, } + require.NoError(t, cli.Create(ctx, pm)) + + _, err := r.ensureDeployedService(ctx, req, pm) + require.NoError(t, err) + + dep := &appsv1.Deployment{} + err = cli.Get(ctx, client.ObjectKey{Name: pm.ComponentName(), Namespace: ns}, dep) + require.NoError(t, err) + + got := dep.Spec.Template.Spec.TopologySpreadConstraints + require.Len(t, got, 1) + assert.Equal(t, int32(1), got[0].MaxSkew) + assert.Equal(t, "kubernetes.io/arch", got[0].TopologyKey) + assert.Equal(t, corev1.DoNotSchedule, got[0].WhenUnsatisfiable) + + require.NotNil(t, got[0].LabelSelector, "an omitted LabelSelector must be auto-filled, or the constraint silently matches zero pods") + selector, err := metav1.LabelSelectorAsSelector(got[0].LabelSelector) + require.NoError(t, err) + assert.True(t, selector.Matches(labels.Set(pm.KubernetesLabels())), + "auto-filled LabelSelector must match this component's own pod labels, or the constraint counts nothing") +} + +// TestPackageManagerReconciler_TopologySpreadConstraintsExplicitSelector verifies that +// an explicit LabelSelector on a constraint is left untouched, not overridden by the +// auto-fill default. +func TestPackageManagerReconciler_TopologySpreadConstraintsExplicitSelector(t *testing.T) { + ctx := context.Background() + ns := "posit-team" + name := "pm-topology-spread-explicit" + + fakeEnv := localtest.FakeTestEnv{} + cli, scheme, log := fakeEnv.Start(loadSchemes) + + r := &PackageManagerReconciler{ + Client: cli, + Scheme: scheme, + Log: log, + } + + ctx = logr.NewContext(ctx, log) + req := ctrl.Request{ + NamespacedName: types.NamespacedName{Namespace: ns, Name: name}, + } + + explicitSelector := &metav1.LabelSelector{MatchLabels: map[string]string{"custom": "selector"}} + pm := &positcov1beta1.PackageManager{ TypeMeta: metav1.TypeMeta{ Kind: "PackageManager", APIVersion: "core.posit.team/v1beta1", }, - ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name, UID: "pm-topology-spread-uid"}, + ObjectMeta: metav1.ObjectMeta{Namespace: ns, Name: name, UID: "pm-topology-spread-explicit-uid"}, Spec: positcov1beta1.PackageManagerSpec{ Image: "ghcr.io/rstudio/rstudio-pm:test", Secret: positcov1beta1.SecretConfig{ Type: product.SiteSecretKubernetes, }, - Config: &positcov1beta1.PackageManagerConfig{}, - TopologySpreadConstraints: constraints, + Config: &positcov1beta1.PackageManagerConfig{}, + TopologySpreadConstraints: []corev1.TopologySpreadConstraint{ + { + MaxSkew: 1, + TopologyKey: "kubernetes.io/arch", + WhenUnsatisfiable: corev1.DoNotSchedule, + LabelSelector: explicitSelector, + }, + }, }, } @@ -324,7 +395,9 @@ func TestPackageManagerReconciler_TopologySpreadConstraints(t *testing.T) { err = cli.Get(ctx, client.ObjectKey{Name: pm.ComponentName(), Namespace: ns}, dep) require.NoError(t, err) - assert.Equal(t, constraints, dep.Spec.Template.Spec.TopologySpreadConstraints) + got := dep.Spec.Template.Spec.TopologySpreadConstraints + require.Len(t, got, 1) + assert.Equal(t, explicitSelector, got[0].LabelSelector, "an explicit LabelSelector must not be overridden by the auto-fill default") } // TestPackageManagerReconciler_EnvVars verifies that the envVars field, including a diff --git a/internal/controller/core/workbench.go b/internal/controller/core/workbench.go index 7a91145..4cf893d 100644 --- a/internal/controller/core/workbench.go +++ b/internal/controller/core/workbench.go @@ -881,7 +881,7 @@ func (r *WorkbenchReconciler) ensureDeployedService(ctx context.Context, req ctr Spec: corev1.PodSpec{ EnableServiceLinks: ptr.To(false), NodeSelector: w.Spec.NodeSelector, - TopologySpreadConstraints: w.Spec.TopologySpreadConstraints, + TopologySpreadConstraints: positcov1beta1.ComponentSpecTopologySpreadConstraints(w.Spec.TopologySpreadConstraints, w), ImagePullSecrets: pullSecrets, ServiceAccountName: maybeServiceAccountName, AutomountServiceAccountToken: ptr.To(true), diff --git a/internal/controller/core/workbench_test.go b/internal/controller/core/workbench_test.go index 11e7108..65b7d49 100644 --- a/internal/controller/core/workbench_test.go +++ b/internal/controller/core/workbench_test.go @@ -20,6 +20,7 @@ import ( apimeta "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/types" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" @@ -555,7 +556,10 @@ func TestWorkbenchReconciler_ResourcesOverride(t *testing.T) { } // TestWorkbenchReconciler_TopologySpreadConstraints verifies an explicit -// Spec.TopologySpreadConstraints passes through unchanged onto the server Deployment. +// Spec.TopologySpreadConstraints passes through onto the server Deployment, with an +// omitted LabelSelector auto-filled to match this component's own pods. Without this, +// the constraint would silently match zero pods and never do anything (Kubernetes +// semantics: a nil LabelSelector matches nothing, not everything). func TestWorkbenchReconciler_TopologySpreadConstraints(t *testing.T) { ctx := context.Background() ns := "posit-team" @@ -563,7 +567,8 @@ func TestWorkbenchReconciler_TopologySpreadConstraints(t *testing.T) { ctx, r, req, cli := initWorkbenchReconciler(t, ctx, ns, name) - constraints := []corev1.TopologySpreadConstraint{ + wb := defineDefaultWorkbench(t, ns, name) + wb.Spec.TopologySpreadConstraints = []corev1.TopologySpreadConstraint{ { MaxSkew: 1, TopologyKey: "kubernetes.io/arch", @@ -571,8 +576,50 @@ func TestWorkbenchReconciler_TopologySpreadConstraints(t *testing.T) { }, } + err := internal.BasicCreateOrUpdate(ctx, r, r.GetLogger(ctx), req.NamespacedName, &positcov1beta1.Workbench{}, wb) + require.NoError(t, err) + + wb = getWorkbench(t, cli, ns, name) + + res, err := r.ReconcileWorkbench(ctx, req, wb) + require.NoError(t, err) + require.True(t, res.IsZero()) + + deployment := getDeployment(t, cli, ns, wb.ComponentName()) + got := deployment.Spec.Template.Spec.TopologySpreadConstraints + require.Len(t, got, 1) + assert.Equal(t, int32(1), got[0].MaxSkew) + assert.Equal(t, "kubernetes.io/arch", got[0].TopologyKey) + assert.Equal(t, corev1.DoNotSchedule, got[0].WhenUnsatisfiable) + + require.NotNil(t, got[0].LabelSelector, "an omitted LabelSelector must be auto-filled, or the constraint silently matches zero pods") + selector, err := metav1.LabelSelectorAsSelector(got[0].LabelSelector) + require.NoError(t, err) + assert.True(t, selector.Matches(labels.Set(wb.KubernetesLabels())), + "auto-filled LabelSelector must match this component's own pod labels, or the constraint counts nothing") +} + +// TestWorkbenchReconciler_TopologySpreadConstraintsExplicitSelector verifies that an +// explicit LabelSelector on a constraint is left untouched, not overridden by the +// auto-fill default. +func TestWorkbenchReconciler_TopologySpreadConstraintsExplicitSelector(t *testing.T) { + ctx := context.Background() + ns := "posit-team" + name := "workbench-topology-spread-explicit" + + ctx, r, req, cli := initWorkbenchReconciler(t, ctx, ns, name) + + explicitSelector := &metav1.LabelSelector{MatchLabels: map[string]string{"custom": "selector"}} + wb := defineDefaultWorkbench(t, ns, name) - wb.Spec.TopologySpreadConstraints = constraints + wb.Spec.TopologySpreadConstraints = []corev1.TopologySpreadConstraint{ + { + MaxSkew: 1, + TopologyKey: "kubernetes.io/arch", + WhenUnsatisfiable: corev1.DoNotSchedule, + LabelSelector: explicitSelector, + }, + } err := internal.BasicCreateOrUpdate(ctx, r, r.GetLogger(ctx), req.NamespacedName, &positcov1beta1.Workbench{}, wb) require.NoError(t, err) @@ -584,7 +631,9 @@ func TestWorkbenchReconciler_TopologySpreadConstraints(t *testing.T) { require.True(t, res.IsZero()) deployment := getDeployment(t, cli, ns, wb.ComponentName()) - assert.Equal(t, constraints, deployment.Spec.Template.Spec.TopologySpreadConstraints) + got := deployment.Spec.Template.Spec.TopologySpreadConstraints + require.Len(t, got, 1) + assert.Equal(t, explicitSelector, got[0].LabelSelector, "an explicit LabelSelector must not be overridden by the auto-fill default") } // TestWorkbenchReconciler_Suspended verifies that when Workbench has Suspended=true,