Skip to content

Commit 273e164

Browse files
feat: support externally managed secret (#168)
1 parent f8f622b commit 273e164

13 files changed

Lines changed: 426 additions & 38 deletions

File tree

api/v1alpha1/clickhousecluster_types.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ type ClickHouseClusterSpec struct {
8080
// VersionProbeTemplate overrides for the version detection Job.
8181
// +optional
8282
VersionProbeTemplate *VersionProbeTemplate `json:"versionProbeTemplate,omitempty"`
83+
84+
// ExternalSecret is an optional reference to an externally-managed Secret containing cluster secrets.
85+
// The secret must reside in the same namespace as the cluster.
86+
// +optional
87+
ExternalSecret *ExternalSecret `json:"externalSecret,omitempty"`
8388
}
8489

8590
// WithDefaults sets default values for ClickHouseClusterSpec fields.
@@ -344,8 +349,13 @@ func (v *ClickHouseCluster) PodDisruptionBudgetNameByShard(shard int32) string {
344349
return fmt.Sprintf("%s-%d", v.SpecificName(), shard)
345350
}
346351

347-
// SecretName returns name of the Secret with operator generated values.
352+
// SecretName returns name of the Secret with cluster secret values.
353+
// When ExternalSecret is configured, returns the external secret name instead of the operator-generated one.
348354
func (v *ClickHouseCluster) SecretName() string {
355+
if v.Spec.ExternalSecret != nil {
356+
return v.Spec.ExternalSecret.Name
357+
}
358+
349359
return v.SpecificName()
350360
}
351361

api/v1alpha1/common.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -351,6 +351,34 @@ func (s *ClusterTLSSpec) Validate() error {
351351
return nil
352352
}
353353

354+
// ExternalSecretPolicy controls how the operator treats the external secret's content.
355+
// +kubebuilder:validation:Enum=Observe;Manage
356+
type ExternalSecretPolicy string
357+
358+
const (
359+
// ExternalSecretPolicyObserve is the default policy: the operator reads and validates the secret;
360+
// reconciliation is blocked if any required key is absent.
361+
// Missing required keys and their expected formats are reported via the ExternalSecretValid status condition at runtime.
362+
ExternalSecretPolicyObserve ExternalSecretPolicy = "Observe"
363+
// ExternalSecretPolicyManage is the policy where the operator fills in any missing required keys by generating
364+
// values for them. The secret is updated but never owned or deleted by the operator.
365+
ExternalSecretPolicyManage ExternalSecretPolicy = "Manage"
366+
)
367+
368+
// ExternalSecret is a reference to a Secret in the same namespace.
369+
type ExternalSecret struct {
370+
// Name of the Secret.
371+
// +kubebuilder:validation:Required
372+
Name string `json:"name"`
373+
374+
// Policy controls how the operator treats the secret's content.
375+
// Observe (default): blocks reconciliation if any required key is missing.
376+
// Manage: generates missing required keys into the existing secret.
377+
// +optional
378+
// +kubebuilder:default:=Observe
379+
Policy ExternalSecretPolicy `json:"policy,omitempty"`
380+
}
381+
354382
// SecretKeySelector selects a key of a Secret.
355383
type SecretKeySelector struct {
356384
// The name of the secret in the cluster's namespace to select from.

api/v1alpha1/conditions.go

Lines changed: 7 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,13 @@ const (
7575
ClickHouseConditionReplicasInSync ConditionReason = "ReplicasInSync"
7676
ClickHouseConditionDatabasesNotCreated ConditionReason = "DatabasesNotCreated"
7777
ClickHouseConditionReplicasNotCleanedUp ConditionReason = "ReplicasNotCleanedUp"
78+
79+
// ClickHouseConditionTypeExternalSecretValid indicates whether the externally managed Secret contains all required keys.
80+
// This condition is present only when spec.externalSecret is configured; it is removed when externalSecret is unset.
81+
ClickHouseConditionTypeExternalSecretValid ConditionType = "ExternalSecretValid"
82+
ClickHouseConditionReasonExternalSecretValid ConditionReason = "ExternalSecretValid"
83+
ClickHouseConditionReasonExternalSecretNotFound ConditionReason = "ExternalSecretNotFound"
84+
ClickHouseConditionReasonExternalSecretInvalid ConditionReason = "ExternalSecretInvalid"
7885
)
7986

8087
// KeeperCluster specific condition types and reasons.
@@ -88,33 +95,3 @@ const (
8895
KeeperConditionReasonWaitingFollowers ConditionReason = "WaitingFollowers"
8996
KeeperConditionReasonReadyToScale ConditionReason = "ReadyToScale"
9097
)
91-
92-
var (
93-
// AllClickHouseConditionTypes lists all ClickHouseCluster condition types.
94-
AllClickHouseConditionTypes = []ConditionType{
95-
ConditionTypeSpecValid,
96-
ConditionTypeReconcileSucceeded,
97-
ConditionTypeReplicaStartupSucceeded,
98-
ConditionTypeHealthy,
99-
ConditionTypeClusterSizeAligned,
100-
ConditionTypeConfigurationInSync,
101-
ConditionTypeVersionInSync,
102-
ConditionTypeVersionUpgraded,
103-
ConditionTypeReady,
104-
ClickHouseConditionTypeSchemaInSync,
105-
}
106-
107-
// AllKeeperConditionTypes lists all KeeperCluster condition types.
108-
AllKeeperConditionTypes = []ConditionType{
109-
ConditionTypeSpecValid,
110-
ConditionTypeReconcileSucceeded,
111-
ConditionTypeReplicaStartupSucceeded,
112-
ConditionTypeHealthy,
113-
ConditionTypeClusterSizeAligned,
114-
ConditionTypeConfigurationInSync,
115-
ConditionTypeVersionInSync,
116-
ConditionTypeVersionUpgraded,
117-
ConditionTypeReady,
118-
KeeperConditionTypeScaleAllowed,
119-
}
120-
)

api/v1alpha1/events.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ const (
2525
EventReasonClusterNotReady EventReason = "ClusterNotReady"
2626
)
2727

28+
// Event reasons for external secret issues.
29+
const (
30+
EventReasonExternalSecretInvalid EventReason = "ExternalSecretInvalid"
31+
EventReasonExternalSecretNotFound EventReason = "ExternalSecretNotFound"
32+
)
33+
2834
// Event reasons for version checks.
2935
const (
3036
EventReasonVersionDiverge EventReason = "VersionDiverge"

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

config/crd/bases/clickhouse.com_clickhouseclusters.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,27 @@ spec:
10911091
backing this claim.
10921092
type: string
10931093
type: object
1094+
externalSecret:
1095+
description: |-
1096+
ExternalSecret is an optional reference to an externally-managed Secret containing cluster secrets.
1097+
The secret must reside in the same namespace as the cluster.
1098+
properties:
1099+
name:
1100+
description: Name of the Secret.
1101+
type: string
1102+
policy:
1103+
default: Observe
1104+
description: |-
1105+
Policy controls how the operator treats the secret's content.
1106+
Observe (default): blocks reconciliation if any required key is missing.
1107+
Manage: generates missing required keys into the existing secret.
1108+
enum:
1109+
- Observe
1110+
- Manage
1111+
type: string
1112+
required:
1113+
- name
1114+
type: object
10941115
keeperClusterRef:
10951116
description: Reference to the KeeperCluster that is used for ClickHouse
10961117
coordination.

dist/chart/templates/crd/clickhouseclusters.clickhouse.com.yaml

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1094,6 +1094,27 @@ spec:
10941094
backing this claim.
10951095
type: string
10961096
type: object
1097+
externalSecret:
1098+
description: |-
1099+
ExternalSecret is an optional reference to an externally-managed Secret containing cluster secrets.
1100+
The secret must reside in the same namespace as the cluster.
1101+
properties:
1102+
name:
1103+
description: Name of the Secret.
1104+
type: string
1105+
policy:
1106+
default: Observe
1107+
description: |-
1108+
Policy controls how the operator treats the secret's content.
1109+
Observe (default): blocks reconciliation if any required key is missing.
1110+
Manage: generates missing required keys into the existing secret.
1111+
enum:
1112+
- Observe
1113+
- Manage
1114+
type: string
1115+
required:
1116+
- name
1117+
type: object
10971118
keeperClusterRef:
10981119
description: Reference to the KeeperCluster that is used for ClickHouse
10991120
coordination.

docs/api_reference.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ ClickHouseClusterSpec defines the desired state of ClickHouseCluster.
5858
| `clusterDomain` | string | ClusterDomain is the Kubernetes cluster domain suffix used for DNS resolution. | false | cluster.local |
5959
| `upgradeChannel` | string | UpgradeChannel specifies the release channel for major version upgrade checks.<br />When empty, only minor updates will be proposed. Allowed values are: stable, lts or specific major.minor version (e.g. 25.8). | false | |
6060
| `versionProbeTemplate` | [VersionProbeTemplate](#versionprobetemplate) | VersionProbeTemplate overrides for the version detection Job. | false | |
61+
| `externalSecret` | [ExternalSecret](#externalsecret) | ExternalSecret is an optional reference to an externally-managed Secret containing cluster secrets.<br />The secret must reside in the same namespace as the cluster. | false | |
6162

6263
Appears in:
6364
- [ClickHouseCluster](#clickhousecluster)
@@ -181,6 +182,32 @@ Appears in:
181182

182183

183184

185+
## ExternalSecret
186+
187+
ExternalSecret is a reference to a Secret in the same namespace.
188+
189+
| Field | Type | Description | Required | Default |
190+
|-------|------|-------------|----------|---------|
191+
| `name` | string | Name of the Secret. | true | |
192+
| `policy` | [ExternalSecretPolicy](#externalsecretpolicy) | Policy controls how the operator treats the secret's content.<br />Observe (default): blocks reconciliation if any required key is missing.<br />Manage: generates missing required keys into the existing secret. | false | Observe |
193+
194+
Appears in:
195+
- [ClickHouseClusterSpec](#clickhouseclusterspec)
196+
197+
198+
## ExternalSecretPolicy
199+
200+
ExternalSecretPolicy controls how the operator treats the external secret's content.
201+
202+
| Field | Description |
203+
|-------|-------------|
204+
| `Observe` | ExternalSecretPolicyObserve is the default policy: the operator reads and validates the secret;<br />reconciliation is blocked if any required key is absent.<br />Missing required keys and their expected formats are reported via the ExternalSecretValid status condition at runtime.<br /> |
205+
| `Manage` | ExternalSecretPolicyManage is the policy where the operator fills in any missing required keys by generating<br />values for them. The secret is updated but never owned or deleted by the operator.<br /> |
206+
207+
Appears in:
208+
- [ExternalSecret](#externalsecret)
209+
210+
184211
## KeeperCluster
185212

186213
KeeperCluster is the Schema for the `keeperclusters` API.

internal/controller/clickhouse/constants.go

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,11 @@ const (
6969
)
7070

7171
type secretSpec struct {
72-
Key string
73-
Env string
74-
Format string
72+
Key string
73+
Env string
74+
Format string
75+
// Hint is a human-readable description of the expected value.
76+
Hint string
7577
Generate func() any
7678
Enabled func(cluster *v1.ClickHouseCluster) bool
7779
}
@@ -96,11 +98,12 @@ var (
9698
minVersionNamedCollections = upgrade.ClickHouseVersion{Major: 25, Minor: 12} //nolint:mnd
9799
breakingStatefulSetVersion, _ = semver.Parse("0.0.1")
98100
clusterSecrets = []secretSpec{
99-
{Key: SecretKeyInterserverPassword, Env: EnvInterserverPassword, Format: "%s"},
100-
{Key: SecretKeyManagementPassword, Format: "%s"},
101-
{Key: SecretKeyKeeperIdentity, Env: EnvKeeperIdentity, Format: "clickhouse:%s"},
102-
{Key: SecretKeyClusterSecret, Env: EnvClusterSecret, Format: "%s"},
101+
{Key: SecretKeyInterserverPassword, Env: EnvInterserverPassword, Format: "%s", Hint: "plaintext password"},
102+
{Key: SecretKeyManagementPassword, Format: "%s", Hint: "plaintext password"},
103+
{Key: SecretKeyKeeperIdentity, Env: EnvKeeperIdentity, Format: "clickhouse:%s", Hint: `"clickhouse:<password>"`},
104+
{Key: SecretKeyClusterSecret, Env: EnvClusterSecret, Format: "%s", Hint: "plaintext password"},
103105
{Key: SecretKeyNamedCollectionsKey, Env: EnvNamedCollectionsKey, Format: "%x",
106+
Hint: fmt.Sprintf("hex-encoded %d-byte AES key", NamedCollectionsKeyByteLen),
104107
Generate: func() any { return controllerutil.GenerateRandomBytes(NamedCollectionsKeyByteLen) },
105108
Enabled: func(cluster *v1.ClickHouseCluster) bool {
106109
return upgrade.VersionAtLeast(cluster.Status.Version, minVersionNamedCollections)

internal/controller/clickhouse/controller_test.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,4 +515,83 @@ var _ = When("reconciling ClickHouseCluster", Ordered, func() {
515515
}, &sts)).To(Succeed())
516516
Expect(sts.Annotations[controllerutil.AnnotationSpecHash]).ToNot(Equal(pvcCR.Status.StatefulSetRevision))
517517
})
518+
519+
It("should correctly work with ExternalSecret", func(ctx context.Context) {
520+
By("creating a new cluster with ExternalSecret")
521+
522+
secret := corev1.Secret{
523+
ObjectMeta: metav1.ObjectMeta{
524+
Namespace: "default",
525+
Name: "eso-managed-secret",
526+
},
527+
Data: map[string][]byte{
528+
SecretKeyInterserverPassword: []byte("interserver-pass"),
529+
},
530+
}
531+
532+
esoCR := &v1.ClickHouseCluster{
533+
ObjectMeta: metav1.ObjectMeta{
534+
Name: "ext-secret",
535+
Namespace: "default",
536+
},
537+
Spec: v1.ClickHouseClusterSpec{
538+
KeeperClusterRef: &corev1.LocalObjectReference{Name: keeperName},
539+
ExternalSecret: &v1.ExternalSecret{
540+
Name: secret.Name,
541+
},
542+
},
543+
}
544+
Expect(suite.Client.Create(ctx, esoCR)).To(Succeed())
545+
546+
By("reconciling without secret")
547+
548+
_, err := controller.Reconcile(ctx, ctrl.Request{NamespacedName: esoCR.NamespacedName()})
549+
Expect(err).NotTo(HaveOccurred())
550+
Expect(suite.Client.Get(ctx, esoCR.NamespacedName(), esoCR)).To(Succeed())
551+
552+
cond := meta.FindStatusCondition(esoCR.Status.Conditions, v1.ClickHouseConditionTypeExternalSecretValid)
553+
Expect(cond).ToNot(BeNil())
554+
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
555+
Expect(cond.Reason).To(BeEquivalentTo(v1.ClickHouseConditionReasonExternalSecretNotFound))
556+
557+
testutil.AssertEvents(recorder.Events, map[string]int{
558+
"ExternalSecretNotFound": 1,
559+
"ClusterNotReady": 1,
560+
})
561+
562+
testutil.CompleteVersionProbeJob(ctx, suite, esoCR.Namespace, esoCR.SpecificName(), "26.1.1.1")
563+
564+
By("creating partial secret and reconciling")
565+
Expect(suite.Client.Create(ctx, &secret)).To(Succeed())
566+
_, err = controller.Reconcile(ctx, ctrl.Request{NamespacedName: esoCR.NamespacedName()})
567+
Expect(err).NotTo(HaveOccurred())
568+
Expect(suite.Client.Get(ctx, esoCR.NamespacedName(), esoCR)).To(Succeed())
569+
570+
cond = meta.FindStatusCondition(esoCR.Status.Conditions, v1.ClickHouseConditionTypeExternalSecretValid)
571+
Expect(cond).ToNot(BeNil())
572+
Expect(cond.Status).To(Equal(metav1.ConditionFalse))
573+
Expect(cond.Reason).To(BeEquivalentTo(v1.ClickHouseConditionReasonExternalSecretInvalid))
574+
Expect(cond.Message).To(ContainSubstring("cluster-secret"))
575+
Expect(cond.Message).To(ContainSubstring("keeper-identity"))
576+
Expect(cond.Message).To(ContainSubstring("management-password"))
577+
Expect(cond.Message).To(ContainSubstring("plaintext password"))
578+
Expect(cond.Message).NotTo(ContainSubstring("interserver-password"))
579+
580+
By("reconciling with external secret manage policy")
581+
582+
esoCR.Spec.ExternalSecret.Policy = v1.ExternalSecretPolicyManage
583+
Expect(suite.Client.Update(ctx, esoCR)).To(Succeed())
584+
_, err = controller.Reconcile(ctx, ctrl.Request{NamespacedName: esoCR.NamespacedName()})
585+
Expect(err).NotTo(HaveOccurred())
586+
587+
Expect(suite.Client.Get(ctx, esoCR.NamespacedName(), esoCR)).To(Succeed())
588+
cond = meta.FindStatusCondition(esoCR.Status.Conditions, v1.ClickHouseConditionTypeExternalSecretValid)
589+
Expect(cond).ToNot(BeNil())
590+
Expect(cond.Status).To(Equal(metav1.ConditionTrue))
591+
Expect(cond.Reason).To(BeEquivalentTo(v1.ClickHouseConditionReasonExternalSecretValid))
592+
593+
Expect(suite.Client.Get(ctx, client.ObjectKeyFromObject(&secret), &secret)).To(Succeed())
594+
Expect(secret.Data).To(HaveKey(SecretKeyManagementPassword))
595+
Expect(secret.Data).To(HaveKey(SecretKeyClusterSecret))
596+
})
518597
})

0 commit comments

Comments
 (0)