feat(pooler): make connection pooler security contexts configurable
Allow operators to override the connection pooler pod- and container-level securityContext via the OperatorConfiguration CRD (connection_pooler_pod_security_context and connection_pooler_security_context). When unset, the operator preserves its historical defaults (pod RunAsUser/RunAsGroup 100/101 and spilo FSGroup; container AllowPrivilegeEscalation=false), so existing deployments are unaffected. Useful for hardened images whose pgbouncer UID/GID differs from the default and for satisfying restricted Pod Security Standards.
This commit is contained in:
parent
40b6c68443
commit
8ee421f9ff
|
|
@ -851,6 +851,12 @@ spec:
|
||||||
connection_pooler_default_memory_request:
|
connection_pooler_default_memory_request:
|
||||||
type: string
|
type: string
|
||||||
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
pattern: '^(\d+(e\d+)?|\d+(\.\d+)?(e\d+)?[EPTGMK]i?)$'
|
||||||
|
connection_pooler_pod_security_context:
|
||||||
|
type: object
|
||||||
|
x-kubernetes-preserve-unknown-fields: true
|
||||||
|
connection_pooler_security_context:
|
||||||
|
type: object
|
||||||
|
x-kubernetes-preserve-unknown-fields: true
|
||||||
patroni:
|
patroni:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|
|
||||||
|
|
@ -471,6 +471,24 @@ configConnectionPooler:
|
||||||
connection_pooler_default_memory_request: 100Mi
|
connection_pooler_default_memory_request: 100Mi
|
||||||
connection_pooler_default_cpu_limit: "1"
|
connection_pooler_default_cpu_limit: "1"
|
||||||
connection_pooler_default_memory_limit: 100Mi
|
connection_pooler_default_memory_limit: 100Mi
|
||||||
|
# Override the pooler pod- and container-level securityContext. Useful for hardened
|
||||||
|
# images whose pgbouncer user/UID differs from the operator default (100/101) and which
|
||||||
|
# own /etc/pgbouncer, or to satisfy restricted Pod Security Standards. When unset the
|
||||||
|
# operator keeps its defaults (pod RunAsUser/RunAsGroup 100/101, container
|
||||||
|
# allowPrivilegeEscalation=false).
|
||||||
|
# connection_pooler_pod_security_context:
|
||||||
|
# runAsUser: 100
|
||||||
|
# runAsGroup: 101
|
||||||
|
# runAsNonRoot: true
|
||||||
|
# fsGroup: 101
|
||||||
|
# seccompProfile:
|
||||||
|
# type: RuntimeDefault
|
||||||
|
# connection_pooler_security_context:
|
||||||
|
# allowPrivilegeEscalation: false
|
||||||
|
# readOnlyRootFilesystem: true
|
||||||
|
# capabilities:
|
||||||
|
# drop:
|
||||||
|
# - ALL
|
||||||
|
|
||||||
configPatroni:
|
configPatroni:
|
||||||
# enable Patroni DCS failsafe_mode feature
|
# enable Patroni DCS failsafe_mode feature
|
||||||
|
|
|
||||||
|
|
@ -1052,6 +1052,14 @@ var OperatorConfigCRDResourceValidation = apiextv1.CustomResourceValidation{
|
||||||
"connection_pooler_user": {
|
"connection_pooler_user": {
|
||||||
Type: "string",
|
Type: "string",
|
||||||
},
|
},
|
||||||
|
"connection_pooler_pod_security_context": {
|
||||||
|
Type: "object",
|
||||||
|
XPreserveUnknownFields: util.True(),
|
||||||
|
},
|
||||||
|
"connection_pooler_security_context": {
|
||||||
|
Type: "object",
|
||||||
|
XPreserveUnknownFields: util.True(),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,11 @@ type ConnectionPoolerConfiguration struct {
|
||||||
DefaultMemoryRequest string `json:"connection_pooler_default_memory_request,omitempty"`
|
DefaultMemoryRequest string `json:"connection_pooler_default_memory_request,omitempty"`
|
||||||
DefaultCPULimit string `json:"connection_pooler_default_cpu_limit,omitempty"`
|
DefaultCPULimit string `json:"connection_pooler_default_cpu_limit,omitempty"`
|
||||||
DefaultMemoryLimit string `json:"connection_pooler_default_memory_limit,omitempty"`
|
DefaultMemoryLimit string `json:"connection_pooler_default_memory_limit,omitempty"`
|
||||||
|
// PodSecurityContext and SecurityContext allow overriding the pooler pod- and
|
||||||
|
// container-level security contexts (e.g. to match a hardened image's UID/GID or to
|
||||||
|
// satisfy restricted Pod Security Standards). When unset the operator keeps its defaults.
|
||||||
|
PodSecurityContext *v1.PodSecurityContext `json:"connection_pooler_pod_security_context,omitempty"`
|
||||||
|
SecurityContext *v1.SecurityContext `json:"connection_pooler_security_context,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OperatorLogicalBackupConfiguration defines configuration for logical backup
|
// OperatorLogicalBackupConfiguration defines configuration for logical backup
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,16 @@ func (in *ConnectionPoolerConfiguration) DeepCopyInto(out *ConnectionPoolerConfi
|
||||||
*out = new(int32)
|
*out = new(int32)
|
||||||
**out = **in
|
**out = **in
|
||||||
}
|
}
|
||||||
|
if in.PodSecurityContext != nil {
|
||||||
|
in, out := &in.PodSecurityContext, &out.PodSecurityContext
|
||||||
|
*out = new(corev1.PodSecurityContext)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
|
if in.SecurityContext != nil {
|
||||||
|
in, out := &in.SecurityContext, &out.SecurityContext
|
||||||
|
*out = new(corev1.SecurityContext)
|
||||||
|
(*in).DeepCopyInto(*out)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,51 @@ import (
|
||||||
var poolerRunAsUser = int64(100)
|
var poolerRunAsUser = int64(100)
|
||||||
var poolerRunAsGroup = int64(101)
|
var poolerRunAsGroup = int64(101)
|
||||||
|
|
||||||
|
// generateConnectionPoolerPodSecurityContext returns the pod-level securityContext for the
|
||||||
|
// connection pooler. When the operator configuration provides one it is used as the base
|
||||||
|
// (deep-copied); otherwise an empty context is used. The historical defaults (RunAsUser/RunAsGroup
|
||||||
|
// 100/101) and the spilo FSGroup are applied only for fields the configuration leaves unset, so
|
||||||
|
// behavior is unchanged when no override is configured.
|
||||||
|
func (c *Cluster) generateConnectionPoolerPodSecurityContext(spec *acidv1.PostgresSpec) *v1.PodSecurityContext {
|
||||||
|
var securityContext v1.PodSecurityContext
|
||||||
|
if c.OpConfig.ConnectionPooler.PodSecurityContext != nil {
|
||||||
|
securityContext = *c.OpConfig.ConnectionPooler.PodSecurityContext.DeepCopy()
|
||||||
|
}
|
||||||
|
|
||||||
|
if securityContext.RunAsUser == nil {
|
||||||
|
securityContext.RunAsUser = &poolerRunAsUser
|
||||||
|
}
|
||||||
|
if securityContext.RunAsGroup == nil {
|
||||||
|
securityContext.RunAsGroup = &poolerRunAsGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
if securityContext.FSGroup == nil {
|
||||||
|
effectiveFSGroup := c.OpConfig.Resources.SpiloFSGroup
|
||||||
|
if spec.SpiloFSGroup != nil {
|
||||||
|
effectiveFSGroup = spec.SpiloFSGroup
|
||||||
|
}
|
||||||
|
if effectiveFSGroup != nil {
|
||||||
|
securityContext.FSGroup = effectiveFSGroup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &securityContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateConnectionPoolerContainerSecurityContext returns the container-level securityContext for
|
||||||
|
// the pooler. A configured context is used as the base (deep-copied); AllowPrivilegeEscalation
|
||||||
|
// defaults to false when left unset, preserving prior behavior.
|
||||||
|
func (c *Cluster) generateConnectionPoolerContainerSecurityContext() *v1.SecurityContext {
|
||||||
|
var securityContext v1.SecurityContext
|
||||||
|
if c.OpConfig.ConnectionPooler.SecurityContext != nil {
|
||||||
|
securityContext = *c.OpConfig.ConnectionPooler.SecurityContext.DeepCopy()
|
||||||
|
}
|
||||||
|
if securityContext.AllowPrivilegeEscalation == nil {
|
||||||
|
securityContext.AllowPrivilegeEscalation = util.False()
|
||||||
|
}
|
||||||
|
return &securityContext
|
||||||
|
}
|
||||||
|
|
||||||
// ConnectionPoolerObjects K8s objects that are belong to connection pooler
|
// ConnectionPoolerObjects K8s objects that are belong to connection pooler
|
||||||
type ConnectionPoolerObjects struct {
|
type ConnectionPoolerObjects struct {
|
||||||
AuthSecret *v1.Secret
|
AuthSecret *v1.Secret
|
||||||
|
|
@ -383,9 +428,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) (
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
SecurityContext: &v1.SecurityContext{
|
SecurityContext: c.generateConnectionPoolerContainerSecurityContext(),
|
||||||
AllowPrivilegeEscalation: util.False(),
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var poolerVolumes []v1.Volume
|
var poolerVolumes []v1.Volume
|
||||||
|
|
@ -444,19 +487,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) (
|
||||||
poolerContainer.Env = envVars
|
poolerContainer.Env = envVars
|
||||||
poolerContainer.VolumeMounts = volumeMounts
|
poolerContainer.VolumeMounts = volumeMounts
|
||||||
tolerationsSpec := tolerations(&spec.Tolerations, c.OpConfig.PodToleration)
|
tolerationsSpec := tolerations(&spec.Tolerations, c.OpConfig.PodToleration)
|
||||||
securityContext := v1.PodSecurityContext{}
|
securityContext := c.generateConnectionPoolerPodSecurityContext(spec)
|
||||||
|
|
||||||
// determine the User, Group and FSGroup for the pooler pod
|
|
||||||
securityContext.RunAsUser = &poolerRunAsUser
|
|
||||||
securityContext.RunAsGroup = &poolerRunAsGroup
|
|
||||||
|
|
||||||
effectiveFSGroup := c.OpConfig.Resources.SpiloFSGroup
|
|
||||||
if spec.SpiloFSGroup != nil {
|
|
||||||
effectiveFSGroup = spec.SpiloFSGroup
|
|
||||||
}
|
|
||||||
if effectiveFSGroup != nil {
|
|
||||||
securityContext.FSGroup = effectiveFSGroup
|
|
||||||
}
|
|
||||||
|
|
||||||
podTemplate := &v1.PodTemplateSpec{
|
podTemplate := &v1.PodTemplateSpec{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
|
@ -469,7 +500,7 @@ func (c *Cluster) generateConnectionPoolerPodTemplate(role PostgresRole) (
|
||||||
Containers: []v1.Container{poolerContainer},
|
Containers: []v1.Container{poolerContainer},
|
||||||
Tolerations: tolerationsSpec,
|
Tolerations: tolerationsSpec,
|
||||||
Volumes: poolerVolumes,
|
Volumes: poolerVolumes,
|
||||||
SecurityContext: &securityContext,
|
SecurityContext: securityContext,
|
||||||
ServiceAccountName: c.OpConfig.PodServiceAccountName,
|
ServiceAccountName: c.OpConfig.PodServiceAccountName,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1064,6 +1064,137 @@ func TestPoolerTLS(t *testing.T) {
|
||||||
assert.Contains(t, poolerContainer.Env, v1.EnvVar{Name: "CONNECTION_POOLER_CLIENT_CA_FILE", Value: "/tls/ca.crt"})
|
assert.Contains(t, poolerContainer.Env, v1.EnvVar{Name: "CONNECTION_POOLER_CLIENT_CA_FILE", Value: "/tls/ca.crt"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// poolerSecurityContextOpConfig builds a minimal OpConfig usable for exercising the
|
||||||
|
// connection pooler securityContext behavior. Callers tweak the returned config's
|
||||||
|
// ConnectionPooler / Resources before passing it to syncedPoolerDeployment.
|
||||||
|
func poolerSecurityContextOpConfig() config.Config {
|
||||||
|
return config.Config{
|
||||||
|
PodManagementPolicy: "ordered_ready",
|
||||||
|
ProtectedRoles: []string{"admin"},
|
||||||
|
Auth: config.Auth{
|
||||||
|
SuperUsername: superUserName,
|
||||||
|
ReplicationUsername: replicationUserName,
|
||||||
|
},
|
||||||
|
Resources: config.Resources{
|
||||||
|
ClusterLabels: map[string]string{"application": "spilo"},
|
||||||
|
ClusterNameLabel: "cluster-name",
|
||||||
|
DefaultCPURequest: "300m",
|
||||||
|
DefaultCPULimit: "300m",
|
||||||
|
DefaultMemoryRequest: "300Mi",
|
||||||
|
DefaultMemoryLimit: "300Mi",
|
||||||
|
PodRoleLabel: "spilo-role",
|
||||||
|
},
|
||||||
|
ConnectionPooler: config.ConnectionPooler{
|
||||||
|
ConnectionPoolerDefaultCPURequest: "100m",
|
||||||
|
ConnectionPoolerDefaultCPULimit: "100m",
|
||||||
|
ConnectionPoolerDefaultMemoryRequest: "100Mi",
|
||||||
|
ConnectionPoolerDefaultMemoryLimit: "100Mi",
|
||||||
|
},
|
||||||
|
PodServiceAccountName: "postgres-pod",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncedPoolerDeployment provisions a pooler with the given OpConfig and returns the
|
||||||
|
// resulting master pooler Deployment.
|
||||||
|
func syncedPoolerDeployment(t *testing.T, opConfig config.Config) *appsv1.Deployment {
|
||||||
|
t.Helper()
|
||||||
|
client, _ := newFakeK8sPoolerTestClient()
|
||||||
|
clusterName := "acid-test-cluster"
|
||||||
|
namespace := "default"
|
||||||
|
|
||||||
|
pg := acidv1.Postgresql{
|
||||||
|
ObjectMeta: metav1.ObjectMeta{Name: clusterName, Namespace: namespace},
|
||||||
|
Spec: acidv1.PostgresSpec{
|
||||||
|
TeamID: "myapp", NumberOfInstances: 1,
|
||||||
|
EnableConnectionPooler: util.True(),
|
||||||
|
Resources: &acidv1.Resources{
|
||||||
|
ResourceRequests: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")},
|
||||||
|
ResourceLimits: acidv1.ResourceDescription{CPU: k8sutil.StringToPointer("1"), Memory: k8sutil.StringToPointer("10")},
|
||||||
|
},
|
||||||
|
Volume: acidv1.Volume{Size: "1G"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cluster := New(Config{OpConfig: opConfig}, client, pg, logger, eventRecorder)
|
||||||
|
|
||||||
|
_, err := cluster.createStatefulSet()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
cluster.ConnectionPooler = map[PostgresRole]*ConnectionPoolerObjects{
|
||||||
|
Master: {
|
||||||
|
Name: cluster.connectionPoolerName(Master),
|
||||||
|
ClusterName: clusterName,
|
||||||
|
Namespace: namespace,
|
||||||
|
Role: Master,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = cluster.syncConnectionPoolerWorker(nil, &pg, Master)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
deploy, err := client.Deployments(namespace).Get(context.TODO(), cluster.connectionPoolerName(Master), metav1.GetOptions{})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return deploy
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectionPoolerDefaultSecurityContext(t *testing.T) {
|
||||||
|
deploy := syncedPoolerDeployment(t, poolerSecurityContextOpConfig())
|
||||||
|
|
||||||
|
podSc := deploy.Spec.Template.Spec.SecurityContext
|
||||||
|
assert.NotNil(t, podSc, "pod securityContext should be set")
|
||||||
|
assert.Equal(t, int64(100), *podSc.RunAsUser, "defaults to RunAsUser 100 for backward compatibility")
|
||||||
|
assert.Equal(t, int64(101), *podSc.RunAsGroup, "defaults to RunAsGroup 101 for backward compatibility")
|
||||||
|
|
||||||
|
containerSc := deploy.Spec.Template.Spec.Containers[constants.ConnectionPoolerContainer].SecurityContext
|
||||||
|
assert.NotNil(t, containerSc, "container securityContext should be set")
|
||||||
|
assert.Equal(t, false, *containerSc.AllowPrivilegeEscalation, "defaults AllowPrivilegeEscalation to false")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectionPoolerPodSecurityContextOverride(t *testing.T) {
|
||||||
|
runAsUser := int64(1000001)
|
||||||
|
runAsGroup := int64(1000001)
|
||||||
|
spiloFSGroup := int64(103)
|
||||||
|
|
||||||
|
opConfig := poolerSecurityContextOpConfig()
|
||||||
|
// FSGroup comes from the spilo config and must still apply when the override leaves it nil.
|
||||||
|
opConfig.Resources.SpiloFSGroup = &spiloFSGroup
|
||||||
|
opConfig.ConnectionPooler.PodSecurityContext = &v1.PodSecurityContext{
|
||||||
|
RunAsUser: &runAsUser,
|
||||||
|
RunAsGroup: &runAsGroup,
|
||||||
|
RunAsNonRoot: util.True(),
|
||||||
|
SeccompProfile: &v1.SeccompProfile{
|
||||||
|
Type: v1.SeccompProfileTypeRuntimeDefault,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy := syncedPoolerDeployment(t, opConfig)
|
||||||
|
podSc := deploy.Spec.Template.Spec.SecurityContext
|
||||||
|
|
||||||
|
assert.Equal(t, runAsUser, *podSc.RunAsUser, "honors configured RunAsUser")
|
||||||
|
assert.Equal(t, runAsGroup, *podSc.RunAsGroup, "honors configured RunAsGroup")
|
||||||
|
assert.Equal(t, true, *podSc.RunAsNonRoot, "honors configured RunAsNonRoot")
|
||||||
|
assert.Equal(t, v1.SeccompProfileTypeRuntimeDefault, podSc.SeccompProfile.Type, "honors configured SeccompProfile")
|
||||||
|
assert.Equal(t, spiloFSGroup, *podSc.FSGroup, "still applies spilo FSGroup when override leaves it nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnectionPoolerContainerSecurityContextOverride(t *testing.T) {
|
||||||
|
opConfig := poolerSecurityContextOpConfig()
|
||||||
|
opConfig.ConnectionPooler.SecurityContext = &v1.SecurityContext{
|
||||||
|
AllowPrivilegeEscalation: util.False(),
|
||||||
|
ReadOnlyRootFilesystem: util.True(),
|
||||||
|
Capabilities: &v1.Capabilities{
|
||||||
|
Drop: []v1.Capability{"ALL"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy := syncedPoolerDeployment(t, opConfig)
|
||||||
|
containerSc := deploy.Spec.Template.Spec.Containers[constants.ConnectionPoolerContainer].SecurityContext
|
||||||
|
|
||||||
|
assert.Equal(t, true, *containerSc.ReadOnlyRootFilesystem, "honors configured ReadOnlyRootFilesystem")
|
||||||
|
assert.Equal(t, false, *containerSc.AllowPrivilegeEscalation, "keeps AllowPrivilegeEscalation false")
|
||||||
|
assert.Equal(t, []v1.Capability{"ALL"}, containerSc.Capabilities.Drop, "honors dropped capabilities")
|
||||||
|
}
|
||||||
|
|
||||||
func TestConnectionPoolerServiceSpec(t *testing.T) {
|
func TestConnectionPoolerServiceSpec(t *testing.T) {
|
||||||
testName := "Test connection pooler service spec generation"
|
testName := "Test connection pooler service spec generation"
|
||||||
var cluster = New(
|
var cluster = New(
|
||||||
|
|
|
||||||
|
|
@ -291,5 +291,10 @@ func (c *Controller) importConfigurationFromCRD(fromCRD *acidv1.OperatorConfigur
|
||||||
fromCRD.ConnectionPooler.MaxDBConnections,
|
fromCRD.ConnectionPooler.MaxDBConnections,
|
||||||
k8sutil.Int32ToPointer(constants.ConnectionPoolerMaxDBConnections))
|
k8sutil.Int32ToPointer(constants.ConnectionPoolerMaxDBConnections))
|
||||||
|
|
||||||
|
// Security contexts are nil unless explicitly configured; the cluster package falls back
|
||||||
|
// to the historical pooler defaults when they are not set.
|
||||||
|
result.ConnectionPooler.PodSecurityContext = fromCRD.ConnectionPooler.PodSecurityContext
|
||||||
|
result.ConnectionPooler.SecurityContext = fromCRD.ConnectionPooler.SecurityContext
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -163,6 +163,13 @@ type ConnectionPooler struct {
|
||||||
ConnectionPoolerDefaultMemoryRequest string `name:"connection_pooler_default_memory_request"`
|
ConnectionPoolerDefaultMemoryRequest string `name:"connection_pooler_default_memory_request"`
|
||||||
ConnectionPoolerDefaultCPULimit string `name:"connection_pooler_default_cpu_limit"`
|
ConnectionPoolerDefaultCPULimit string `name:"connection_pooler_default_cpu_limit"`
|
||||||
ConnectionPoolerDefaultMemoryLimit string `name:"connection_pooler_default_memory_limit"`
|
ConnectionPoolerDefaultMemoryLimit string `name:"connection_pooler_default_memory_limit"`
|
||||||
|
// PodSecurityContext and SecurityContext let operators override the pooler
|
||||||
|
// pod- and container-level security contexts. They are populated only from the
|
||||||
|
// OperatorConfiguration CRD (name:"-" excludes them from the ConfigMap decoder,
|
||||||
|
// mirroring LivenessProbe). When nil, the operator falls back to the historical
|
||||||
|
// defaults (pod RunAsUser/RunAsGroup 100/101, container AllowPrivilegeEscalation=false).
|
||||||
|
PodSecurityContext *v1.PodSecurityContext `name:"-"`
|
||||||
|
SecurityContext *v1.SecurityContext `name:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config describes operator config
|
// Config describes operator config
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue