From 795cbf25cf60fb3a9c4b1d785f798a379221deb2 Mon Sep 17 00:00:00 2001 From: keiailab Date: Wed, 10 Jun 2026 23:25:52 +0900 Subject: [PATCH] feat(modules): extend Redis Stack-compatible presets to clusters Signed-off-by: keiailab --- api/v1alpha1/valkey_types.go | 1 + api/v1alpha1/valkeycluster_types.go | 9 ++ api/v1alpha1/zz_generated.deepcopy.go | 7 ++ api/v1alpha2/valkey_types.go | 6 + api/v1alpha2/valkeycluster_types.go | 20 ++++ api/v1alpha2/zz_generated.deepcopy.go | 17 +++ charts/valkey-operator/Chart.yaml | 6 +- .../cache.keiailab.io_valkeyclusters.yaml | 89 ++++++++++++++ .../crds/cache.keiailab.io_valkeys.yaml | 12 ++ .../cache.keiailab.io_valkeyclusters.yaml | 89 ++++++++++++++ .../crd/bases/cache.keiailab.io_valkeys.yaml | 12 ++ .../samples/cache_v1alpha1_valkeycluster.yaml | 5 + docs/ROADMAP.md | 18 ++- internal/controller/capabilities.go | 9 +- internal/controller/capabilities_test.go | 4 + .../controller/capability_metrics_test.go | 7 +- .../valkeybackuptarget_retention.go | 2 +- .../controller/valkeycluster_controller.go | 1 + .../valkeycluster_controller_test.go | 44 +++++++ internal/resources/module_init.go | 4 +- .../v1alpha1/module_validation_test.go | 49 ++++++++ .../webhook/v1alpha1/valkeycluster_webhook.go | 3 + test/e2e/modules_test.go | 113 ++++++++++++++++++ 23 files changed, 512 insertions(+), 15 deletions(-) create mode 100644 test/e2e/modules_test.go diff --git a/api/v1alpha1/valkey_types.go b/api/v1alpha1/valkey_types.go index 1ce0933..d73e255 100644 --- a/api/v1alpha1/valkey_types.go +++ b/api/v1alpha1/valkey_types.go @@ -181,6 +181,7 @@ type ValkeyStatus struct { // "Monitoring" — Spec.Monitoring.Enabled // "ExternalReplica" — Spec.ExternalReplica.Enabled // "EphemeralStorage" — Spec.Storage.Ephemeral + // "Modules" — Spec.Modules non-empty (ADR-0032) // +optional Capabilities []string `json:"capabilities,omitempty"` } diff --git a/api/v1alpha1/valkeycluster_types.go b/api/v1alpha1/valkeycluster_types.go index 5ebf1cf..b65b273 100644 --- a/api/v1alpha1/valkeycluster_types.go +++ b/api/v1alpha1/valkeycluster_types.go @@ -86,6 +86,14 @@ type ValkeyClusterSpec struct { // +optional SlowLog *SlowLogSpec `json:"slowLog,omitempty"` + // Modules — Valkey 공식 BSD module(valkey-search/json/bloom) 또는 BYO module 로딩. + // 외부 Redis Stack(RediSearch/RedisJSON/RedisBloom/RedisTimeSeries/RedisGraph/ + // RedisGears, RSALv2/SSPL)은 라이선스 비호환으로 미지원(ADR-0032). + // Cluster mode 에서는 모든 shard pod 에 동일 module set 을 로딩한다. + // +kubebuilder:validation:MaxItems=16 + // +optional + Modules []ModuleSpec `json:"modules,omitempty"` + // RevisionHistoryLimit — StatefulSet rollout history 보존 개수. // +kubebuilder:validation:Minimum=0 // +optional @@ -123,6 +131,7 @@ type ValkeyClusterStatus struct { ClusterInitialized bool `json:"clusterInitialized,omitempty"` // Capabilities — 활성 optional features. Valkey CR Status.Capabilities 와 동일 패턴. + // 예: TLS, Auth, Monitoring, Modules. // `kubectl get vc -o wide` 의 priority=1 printcolumn 으로 노출. // +optional Capabilities []string `json:"capabilities,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 6f6dd81..09806d9 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1259,6 +1259,13 @@ func (in *ValkeyClusterSpec) DeepCopyInto(out *ValkeyClusterSpec) { *out = new(SlowLogSpec) **out = **in } + if in.Modules != nil { + in, out := &in.Modules, &out.Modules + *out = make([]ModuleSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.RevisionHistoryLimit != nil { in, out := &in.RevisionHistoryLimit, &out.RevisionHistoryLimit *out = new(int32) diff --git a/api/v1alpha2/valkey_types.go b/api/v1alpha2/valkey_types.go index 85b4449..3f27433 100644 --- a/api/v1alpha2/valkey_types.go +++ b/api/v1alpha2/valkey_types.go @@ -173,6 +173,11 @@ type ValkeyStatus struct { // baseline 기록 + ShouldRotate 비교 기준 (자체 시크릿 로테이션, AuthSpec.RotationInterval). // +optional LastPasswordRotation *metav1.Time `json:"lastPasswordRotation,omitempty"` + + // Capabilities — 본 CR 에서 활성된 optional features 의 ordered list. + // 예: TLS, Auth, Monitoring, Modules. + // +optional + Capabilities []string `json:"capabilities,omitempty"` } // +kubebuilder:object:root=true @@ -183,6 +188,7 @@ type ValkeyStatus struct { // +kubebuilder:printcolumn:name="Ready",type="integer",JSONPath=".status.readyReplicas" // +kubebuilder:printcolumn:name="Version",type="string",JSONPath=".spec.version.version" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:printcolumn:name="Capabilities",type="string",JSONPath=".status.capabilities",priority=1 // Valkey is the Schema for the valkeys API. type Valkey struct { diff --git a/api/v1alpha2/valkeycluster_types.go b/api/v1alpha2/valkeycluster_types.go index 8a68dc6..c62f125 100644 --- a/api/v1alpha2/valkeycluster_types.go +++ b/api/v1alpha2/valkeycluster_types.go @@ -83,6 +83,20 @@ type ValkeyClusterSpec struct { // +optional AdditionalConfig map[string]string `json:"additionalConfig,omitempty"` + // Modules — Valkey 공식 module 활성화 (ADR-0032). + // + // 지원 preset: + // - Name 만 지정: Valkey 공식 module preset (예: "valkey-search", + // "valkey-json", "valkey-bloom") 을 operator 가 valkey-bundle 에서 + // 추출해 loadmodule 로 적재. + // - 사용자 커스텀 module 은 ModuleSpec.Image 로 *bring-your-own* + // 이미지 지정 가능. 단 외부 Redis Stack module/image 는 라이선스 + // 비호환으로 admission 에서 거부. + // Cluster mode 에서는 모든 shard pod 에 동일 module set 을 로딩한다. + // +kubebuilder:validation:MaxItems=16 + // +optional + Modules []ModuleSpec `json:"modules,omitempty"` + // RevisionHistoryLimit — StatefulSet rollout history 보존 개수. // +kubebuilder:validation:Minimum=0 // +optional @@ -118,6 +132,11 @@ type ValkeyClusterStatus struct { PendingScale *PendingScale `json:"pendingScale,omitempty"` ClusterInitialized bool `json:"clusterInitialized,omitempty"` + + // Capabilities — 활성 optional features. Valkey CR Status.Capabilities 와 동일 패턴. + // 예: TLS, Auth, Monitoring, Modules. + // +optional + Capabilities []string `json:"capabilities,omitempty"` } // +kubebuilder:object:root=true @@ -129,6 +148,7 @@ type ValkeyClusterStatus struct { // +kubebuilder:printcolumn:name="Slots",type="integer",JSONPath=".status.assignedSlots" // +kubebuilder:printcolumn:name="Version",type="string",JSONPath=".spec.version.version" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:printcolumn:name="Capabilities",type="string",JSONPath=".status.capabilities",priority=1 // ValkeyCluster is the Schema for the valkeyclusters API. type ValkeyCluster struct { diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go index 833156a..f0a8f55 100644 --- a/api/v1alpha2/zz_generated.deepcopy.go +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -1162,6 +1162,13 @@ func (in *ValkeyClusterSpec) DeepCopyInto(out *ValkeyClusterSpec) { (*out)[key] = val } } + if in.Modules != nil { + in, out := &in.Modules, &out.Modules + *out = make([]ModuleSpec, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } if in.RevisionHistoryLimit != nil { in, out := &in.RevisionHistoryLimit, &out.RevisionHistoryLimit *out = new(int32) @@ -1206,6 +1213,11 @@ func (in *ValkeyClusterStatus) DeepCopyInto(out *ValkeyClusterStatus) { *out = new(PendingScale) **out = **in } + if in.Capabilities != nil { + in, out := &in.Capabilities, &out.Capabilities + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ValkeyClusterStatus. @@ -1473,6 +1485,11 @@ func (in *ValkeyStatus) DeepCopyInto(out *ValkeyStatus) { in, out := &in.LastPasswordRotation, &out.LastPasswordRotation *out = (*in).DeepCopy() } + if in.Capabilities != nil { + in, out := &in.Capabilities, &out.Capabilities + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ValkeyStatus. diff --git a/charts/valkey-operator/Chart.yaml b/charts/valkey-operator/Chart.yaml index 9cdcafe..8139075 100644 --- a/charts/valkey-operator/Chart.yaml +++ b/charts/valkey-operator/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: valkey-operator description: A Kubernetes Operator for managing Valkey instances and Clusters (Redis OSS fork, BSD-licensed) type: application -version: 1.3.0 -appVersion: "1.3.0" +version: 1.3.1 +appVersion: "1.3.1" kubeVersion: ">=1.26.0-0" keywords: @@ -116,7 +116,7 @@ annotations: artifacthub.io/containsSecurityUpdates: "true" artifacthub.io/images: | - name: valkey-operator - image: ghcr.io/keiailab/valkey-operator:1.3.0 + image: ghcr.io/keiailab/valkey-operator:1.3.1 - name: valkey image: docker.io/valkey/valkey:9.1.0-alpine3.23 artifacthub.io/prerelease: "false" diff --git a/charts/valkey-operator/crds/cache.keiailab.io_valkeyclusters.yaml b/charts/valkey-operator/crds/cache.keiailab.io_valkeyclusters.yaml index e7e0659..b246423 100644 --- a/charts/valkey-operator/crds/cache.keiailab.io_valkeyclusters.yaml +++ b/charts/valkey-operator/crds/cache.keiailab.io_valkeyclusters.yaml @@ -180,6 +180,37 @@ spec: 빈 값이면 상시 허용. 자정 넘김(예: "22:00-02:00") 지원. type: string type: object + modules: + description: |- + Modules — Valkey 공식 BSD module(valkey-search/json/bloom) 또는 BYO module 로딩. + 외부 Redis Stack(RediSearch/RedisJSON/RedisBloom/RedisTimeSeries/RedisGraph/ + RedisGears, RSALv2/SSPL)은 라이선스 비호환으로 미지원(ADR-0032). + Cluster mode 에서는 모든 shard pod 에 동일 module set 을 로딩한다. + items: + description: "ModuleSpec — Valkey module 정의 (ADR-0032). 컨트롤러 hub(v1alpha1) + 미러.\n\n\tName 만: 공식 BSD preset (allow-list 검증 + 공식 image 자동 resolve)\n\tImage: + \ bring-your-own custom module (init container 가 .so 를 emptyDir + mount)" + properties: + image: + description: Image — custom module image (optional). 미지정 시 공식 + preset 자동 resolve. + type: string + loadModuleArgs: + description: LoadModuleArgs — `loadmodule ` 의 args + (optional). + items: + type: string + type: array + name: + description: 'Name — module 식별자 (예: "valkey-search").' + pattern: ^[a-z][a-z0-9-]+$ + type: string + required: + - name + type: object + maxItems: 16 + type: array monitoring: description: MonitoringSpec — Prometheus exporter sidecar + ServiceMonitor. properties: @@ -2827,6 +2858,7 @@ spec: capabilities: description: |- Capabilities — 활성 optional features. Valkey CR Status.Capabilities 와 동일 패턴. + 예: TLS, Auth, Monitoring, Modules. `kubectl get vc -o wide` 의 priority=1 printcolumn 으로 노출. items: type: string @@ -2974,6 +3006,10 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + - jsonPath: .status.capabilities + name: Capabilities + priority: 1 + type: string name: v1alpha2 schema: openAPIV3Schema: @@ -3154,6 +3190,52 @@ spec: 빈 값이면 상시 허용. 자정 넘김(예: "22:00-02:00") 지원. type: string type: object + modules: + description: |- + Modules — Valkey 공식 module 활성화 (ADR-0032). + + 지원 preset: + - Name 만 지정: Valkey 공식 module preset (예: "valkey-search", + "valkey-json", "valkey-bloom") 을 operator 가 valkey-bundle 에서 + 추출해 loadmodule 로 적재. + - 사용자 커스텀 module 은 ModuleSpec.Image 로 *bring-your-own* + 이미지 지정 가능. 단 외부 Redis Stack module/image 는 라이선스 + 비호환으로 admission 에서 거부. + Cluster mode 에서는 모든 shard pod 에 동일 module set 을 로딩한다. + items: + description: |- + ModuleSpec — Valkey module 정의 (Plan §2 D9, ADR-0032). + + 두 모드: + - Name 만 지정: Valkey 공식 module preset (예: "valkey-search", + "valkey-json", "valkey-bloom"). operator 가 alllow-list 검증 + + 공식 image 자동 resolve. + - Image 명시: bring-your-own custom module. init container 가 + 해당 image 의 /modules/.so 를 emptyDir 로 mount, valkey + container 가 `--loadmodule /modules/.so ` 로 적재. + + 보안: PSS Restricted (ADR-0036) 와 정합 — module image 가 privileged + syscall 요구 시 webhook 거부. Sonatype Trust Score 검증 권장. + properties: + image: + description: Image — custom module image (optional). 미지정 시 공식 + preset 자동 resolve. + type: string + loadModuleArgs: + description: LoadModuleArgs — `loadmodule ` 의 args + (optional). + items: + type: string + type: array + name: + description: 'Name — module 식별자 (예: "valkey-search").' + pattern: ^[a-z][a-z0-9-]+$ + type: string + required: + - name + type: object + maxItems: 16 + type: array monitoring: description: MonitoringSpec — Prometheus exporter sidecar + ServiceMonitor. properties: @@ -5784,6 +5866,13 @@ spec: assignedSlots: format: int32 type: integer + capabilities: + description: |- + Capabilities — 활성 optional features. Valkey CR Status.Capabilities 와 동일 패턴. + 예: TLS, Auth, Monitoring, Modules. + items: + type: string + type: array clusterInitialized: type: boolean clusterState: diff --git a/charts/valkey-operator/crds/cache.keiailab.io_valkeys.yaml b/charts/valkey-operator/crds/cache.keiailab.io_valkeys.yaml index 15039a0..8891079 100644 --- a/charts/valkey-operator/crds/cache.keiailab.io_valkeys.yaml +++ b/charts/valkey-operator/crds/cache.keiailab.io_valkeys.yaml @@ -2939,6 +2939,7 @@ spec: "Monitoring" — Spec.Monitoring.Enabled "ExternalReplica" — Spec.ExternalReplica.Enabled "EphemeralStorage" — Spec.Storage.Ephemeral + "Modules" — Spec.Modules non-empty (ADR-0032) items: type: string type: array @@ -3063,6 +3064,10 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + - jsonPath: .status.capabilities + name: Capabilities + priority: 1 + type: string name: v1alpha2 schema: openAPIV3Schema: @@ -5983,6 +5988,13 @@ spec: status: description: ValkeyStatus — observed state. properties: + capabilities: + description: |- + Capabilities — 본 CR 에서 활성된 optional features 의 ordered list. + 예: TLS, Auth, Monitoring, Modules. + items: + type: string + type: array conditions: items: description: Condition contains details for one aspect of the current diff --git a/config/crd/bases/cache.keiailab.io_valkeyclusters.yaml b/config/crd/bases/cache.keiailab.io_valkeyclusters.yaml index e7e0659..b246423 100644 --- a/config/crd/bases/cache.keiailab.io_valkeyclusters.yaml +++ b/config/crd/bases/cache.keiailab.io_valkeyclusters.yaml @@ -180,6 +180,37 @@ spec: 빈 값이면 상시 허용. 자정 넘김(예: "22:00-02:00") 지원. type: string type: object + modules: + description: |- + Modules — Valkey 공식 BSD module(valkey-search/json/bloom) 또는 BYO module 로딩. + 외부 Redis Stack(RediSearch/RedisJSON/RedisBloom/RedisTimeSeries/RedisGraph/ + RedisGears, RSALv2/SSPL)은 라이선스 비호환으로 미지원(ADR-0032). + Cluster mode 에서는 모든 shard pod 에 동일 module set 을 로딩한다. + items: + description: "ModuleSpec — Valkey module 정의 (ADR-0032). 컨트롤러 hub(v1alpha1) + 미러.\n\n\tName 만: 공식 BSD preset (allow-list 검증 + 공식 image 자동 resolve)\n\tImage: + \ bring-your-own custom module (init container 가 .so 를 emptyDir + mount)" + properties: + image: + description: Image — custom module image (optional). 미지정 시 공식 + preset 자동 resolve. + type: string + loadModuleArgs: + description: LoadModuleArgs — `loadmodule ` 의 args + (optional). + items: + type: string + type: array + name: + description: 'Name — module 식별자 (예: "valkey-search").' + pattern: ^[a-z][a-z0-9-]+$ + type: string + required: + - name + type: object + maxItems: 16 + type: array monitoring: description: MonitoringSpec — Prometheus exporter sidecar + ServiceMonitor. properties: @@ -2827,6 +2858,7 @@ spec: capabilities: description: |- Capabilities — 활성 optional features. Valkey CR Status.Capabilities 와 동일 패턴. + 예: TLS, Auth, Monitoring, Modules. `kubectl get vc -o wide` 의 priority=1 printcolumn 으로 노출. items: type: string @@ -2974,6 +3006,10 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + - jsonPath: .status.capabilities + name: Capabilities + priority: 1 + type: string name: v1alpha2 schema: openAPIV3Schema: @@ -3154,6 +3190,52 @@ spec: 빈 값이면 상시 허용. 자정 넘김(예: "22:00-02:00") 지원. type: string type: object + modules: + description: |- + Modules — Valkey 공식 module 활성화 (ADR-0032). + + 지원 preset: + - Name 만 지정: Valkey 공식 module preset (예: "valkey-search", + "valkey-json", "valkey-bloom") 을 operator 가 valkey-bundle 에서 + 추출해 loadmodule 로 적재. + - 사용자 커스텀 module 은 ModuleSpec.Image 로 *bring-your-own* + 이미지 지정 가능. 단 외부 Redis Stack module/image 는 라이선스 + 비호환으로 admission 에서 거부. + Cluster mode 에서는 모든 shard pod 에 동일 module set 을 로딩한다. + items: + description: |- + ModuleSpec — Valkey module 정의 (Plan §2 D9, ADR-0032). + + 두 모드: + - Name 만 지정: Valkey 공식 module preset (예: "valkey-search", + "valkey-json", "valkey-bloom"). operator 가 alllow-list 검증 + + 공식 image 자동 resolve. + - Image 명시: bring-your-own custom module. init container 가 + 해당 image 의 /modules/.so 를 emptyDir 로 mount, valkey + container 가 `--loadmodule /modules/.so ` 로 적재. + + 보안: PSS Restricted (ADR-0036) 와 정합 — module image 가 privileged + syscall 요구 시 webhook 거부. Sonatype Trust Score 검증 권장. + properties: + image: + description: Image — custom module image (optional). 미지정 시 공식 + preset 자동 resolve. + type: string + loadModuleArgs: + description: LoadModuleArgs — `loadmodule ` 의 args + (optional). + items: + type: string + type: array + name: + description: 'Name — module 식별자 (예: "valkey-search").' + pattern: ^[a-z][a-z0-9-]+$ + type: string + required: + - name + type: object + maxItems: 16 + type: array monitoring: description: MonitoringSpec — Prometheus exporter sidecar + ServiceMonitor. properties: @@ -5784,6 +5866,13 @@ spec: assignedSlots: format: int32 type: integer + capabilities: + description: |- + Capabilities — 활성 optional features. Valkey CR Status.Capabilities 와 동일 패턴. + 예: TLS, Auth, Monitoring, Modules. + items: + type: string + type: array clusterInitialized: type: boolean clusterState: diff --git a/config/crd/bases/cache.keiailab.io_valkeys.yaml b/config/crd/bases/cache.keiailab.io_valkeys.yaml index 15039a0..8891079 100644 --- a/config/crd/bases/cache.keiailab.io_valkeys.yaml +++ b/config/crd/bases/cache.keiailab.io_valkeys.yaml @@ -2939,6 +2939,7 @@ spec: "Monitoring" — Spec.Monitoring.Enabled "ExternalReplica" — Spec.ExternalReplica.Enabled "EphemeralStorage" — Spec.Storage.Ephemeral + "Modules" — Spec.Modules non-empty (ADR-0032) items: type: string type: array @@ -3063,6 +3064,10 @@ spec: - jsonPath: .metadata.creationTimestamp name: Age type: date + - jsonPath: .status.capabilities + name: Capabilities + priority: 1 + type: string name: v1alpha2 schema: openAPIV3Schema: @@ -5983,6 +5988,13 @@ spec: status: description: ValkeyStatus — observed state. properties: + capabilities: + description: |- + Capabilities — 본 CR 에서 활성된 optional features 의 ordered list. + 예: TLS, Auth, Monitoring, Modules. + items: + type: string + type: array conditions: items: description: Condition contains details for one aspect of the current diff --git a/config/samples/cache_v1alpha1_valkeycluster.yaml b/config/samples/cache_v1alpha1_valkeycluster.yaml index d5e70cd..eecc039 100644 --- a/config/samples/cache_v1alpha1_valkeycluster.yaml +++ b/config/samples/cache_v1alpha1_valkeycluster.yaml @@ -58,3 +58,8 @@ spec: # slowLog: # PR #45 — slowlog-log-slower-than directive 주입 # thresholdMicros: 5000 # 5ms 보다 오래 걸린 명령 기록 # maxEntries: 256 + # modules: # ADR-0032 — 모든 shard pod 에 동일 module set 로딩 + # - name: valkey-search # FT.* (RediSearch 대체) + # - name: valkey-json # JSON.* (RedisJSON 대체) + # - name: valkey-bloom # BF.* (RedisBloom 대체) + # # TS.*/GRAPH.*/Gears 계열 외부 Redis Stack module/image 는 admission 거부 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 4c4cee5..5816353 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -162,21 +162,29 @@ file used to confirm the checkbox. - [~] **Valkey official module presets (Redis Stack equivalent)** — turnkey loading of the BSD-licensed `valkey-search` / `valkey-json` / - `valkey-bloom` modules via `ValkeySpec.Modules`. External Redis Stack - modules (RediSearch / RedisJSON, RSALv2 / SSPL) are a deliberate - non-goal — license-incompatible with Valkey's BSD-3 (ADR-0032) + `valkey-bloom` modules via `spec.modules`. Runtime compatibility is + explicit: `JSON.*`, `FT.*`, and `BF.*` are supported through Valkey + official modules; `TS.*`, `GRAPH.*`, and Gears remain unsupported until + Valkey-compatible official modules exist. External Redis Stack modules + (RediSearch / RedisJSON / RedisTimeSeries / RedisGraph / RedisGears, + RSALv2 / SSPL) are a deliberate non-goal — license-incompatible with + Valkey's BSD-3 (ADR-0032) - [x] `ModuleSpec` type + `ValkeySpec.Modules []ModuleSpec` field (PR-C6.1) — `api/v1alpha2/valkey_types.go` + - [x] `ValkeyClusterSpec.Modules []ModuleSpec` field — same `spec.modules` + API for Standalone, Replication, and Cluster topologies - [x] Controller wiring — init-container `.so` mount (emptyDir) + `--loadmodule` in the StatefulSet podSpec (PR-C6.2, live since 1.1.0) — `internal/resources/module_init.go` `BuildModuleInitContainers`, - `internal/controller/valkey_controller.go` `Modules: v.Spec.Modules` + `internal/controller/valkey_controller.go` `Modules: v.Spec.Modules`, + `internal/controller/valkeycluster_controller.go` `Modules: vc.Spec.Modules` - [x] Official-preset allow-list validation (외부 Redis Stack 거부, v1.2.0 unit test) — `internal/webhook/v1alpha1/valkey_webhook.go` `validateModules`. + Cluster webhook uses the same validator. ⚠️ 클러스터 admission 실작동은 webhook 활성화 필요 (현재 `ENABLE_WEBHOOKS=false` — chart hook 순서 chicken-egg, 별도 이슈) - [x] Chart module-list exposure (v1.2.0) — `charts/valkey-operator/values.yaml` - module preset 문서 + `config/samples/cache_v1alpha1_valkey.yaml` modules 예시 + module preset 문서 + Valkey/ValkeyCluster sample `modules` 예시 - [ ] e2e — `valkey-search` `FT.SEARCH` round-trip — `test/e2e` - Verify: apply a Valkey CR with a `valkey-search` preset under `modules`, then `valkey-cli MODULE LIST` shows the module loaded diff --git a/internal/controller/capabilities.go b/internal/controller/capabilities.go index 44810b8..61f2680 100644 --- a/internal/controller/capabilities.go +++ b/internal/controller/capabilities.go @@ -23,6 +23,7 @@ const ( CapabilityMonitoring = "Monitoring" CapabilityExternalReplica = "ExternalReplica" CapabilityEphemeralStorage = "EphemeralStorage" + CapabilityModules = "Modules" ) // AllCapabilities — Prometheus Metric 의 inactive=0 명시 set 위해 전체 리스트 @@ -31,7 +32,7 @@ var AllCapabilities = []string{ CapabilityTLS, CapabilityTLSAutoCA, CapabilityAuth, CapabilityAutoscaling, CapabilitySlowLog, CapabilityEncryptionAudit, CapabilityEncryptionEnforce, CapabilityNetworkPolicy, CapabilityMonitoring, CapabilityExternalReplica, - CapabilityEphemeralStorage, + CapabilityEphemeralStorage, CapabilityModules, } // computeValkeyCapabilities — Valkey CR 의 활성 optional features 슬라이스 산출. @@ -74,6 +75,9 @@ func computeValkeyCapabilities(v *cachev1alpha1.Valkey) []string { if v.Spec.Storage.Ephemeral { out = append(out, CapabilityEphemeralStorage) } + if len(v.Spec.Modules) > 0 { + out = append(out, CapabilityModules) + } return out } @@ -110,5 +114,8 @@ func computeClusterCapabilities(vc *cachev1alpha1.ValkeyCluster) []string { if vc.Spec.Storage.Ephemeral { out = append(out, CapabilityEphemeralStorage) } + if len(vc.Spec.Modules) > 0 { + out = append(out, CapabilityModules) + } return out } diff --git a/internal/controller/capabilities_test.go b/internal/controller/capabilities_test.go index efa72e7..4475c5c 100644 --- a/internal/controller/capabilities_test.go +++ b/internal/controller/capabilities_test.go @@ -60,6 +60,7 @@ func TestComputeValkeyCapabilities_full_features(t *testing.T) { } v.Spec.NetworkPolicy = &cachev1alpha1.NetworkPolicySpec{Enabled: true} v.Spec.Monitoring = &cachev1alpha1.MonitoringSpec{Enabled: true} + v.Spec.Modules = []cachev1alpha1.ModuleSpec{{Name: "valkey-search"}} got := computeValkeyCapabilities(v) want := []string{ @@ -68,6 +69,7 @@ func TestComputeValkeyCapabilities_full_features(t *testing.T) { CapabilitySlowLog, CapabilityEncryptionAudit, CapabilityEncryptionEnforce, CapabilityNetworkPolicy, CapabilityMonitoring, + CapabilityModules, } if !reflect.DeepEqual(got, want) { t.Errorf("full features:\n got: %v\nwant: %v", got, want) @@ -112,6 +114,7 @@ func TestComputeClusterCapabilities_full_features(t *testing.T) { vc.Spec.Storage = cachev1alpha1.StorageSpec{EncryptionRequired: true} vc.Spec.NetworkPolicy = &cachev1alpha1.NetworkPolicySpec{Enabled: true} vc.Spec.Monitoring = &cachev1alpha1.MonitoringSpec{Enabled: true} + vc.Spec.Modules = []cachev1alpha1.ModuleSpec{{Name: "valkey-search"}} got := computeClusterCapabilities(vc) want := []string{ @@ -119,6 +122,7 @@ func TestComputeClusterCapabilities_full_features(t *testing.T) { CapabilitySlowLog, CapabilityEncryptionAudit, CapabilityNetworkPolicy, CapabilityMonitoring, + CapabilityModules, } if !reflect.DeepEqual(got, want) { t.Errorf("got %v want %v", got, want) diff --git a/internal/controller/capability_metrics_test.go b/internal/controller/capability_metrics_test.go index a8b6fb7..0407d71 100644 --- a/internal/controller/capability_metrics_test.go +++ b/internal/controller/capability_metrics_test.go @@ -28,6 +28,7 @@ func TestSetCapabilityMetrics_active_set_to_1(t *testing.T) { CapabilityTLSAutoCA: 0, CapabilityExternalReplica: 0, CapabilityEphemeralStorage: 0, + CapabilityModules: 0, } for cap, want := range cases { got := testutil.ToFloat64(MetricCapabilityActive.WithLabelValues("ns-cap", "vk-cap", cap)) @@ -66,8 +67,8 @@ func TestDeleteMetricsFor_clears_capabilities(t *testing.T) { } func TestAllCapabilities_count(t *testing.T) { - // 토큰 11 개 (ADR-0043 추가 2개 포함). 신규 추가 시 본 테스트도 함께 갱신. - if len(AllCapabilities) != 11 { - t.Errorf("AllCapabilities length: got %d want 11 (ADR-0043 spec)", len(AllCapabilities)) + // 토큰 12 개 (ADR-0043 + ADR-0032 module capability 포함). 신규 추가 시 본 테스트도 함께 갱신. + if len(AllCapabilities) != 12 { + t.Errorf("AllCapabilities length: got %d want 12 (ADR-0043 + ADR-0032 spec)", len(AllCapabilities)) } } diff --git a/internal/controller/valkeybackuptarget_retention.go b/internal/controller/valkeybackuptarget_retention.go index 028985c..9ed947b 100644 --- a/internal/controller/valkeybackuptarget_retention.go +++ b/internal/controller/valkeybackuptarget_retention.go @@ -47,7 +47,7 @@ func selectExpiredBackupsForTarget( } infos = append(infos, backuplifecycle.BackupInfo{ Name: b.Name, - CreatedAt: b.Status.CompletedAt.Time.Unix(), + CreatedAt: b.Status.CompletedAt.Unix(), }) } maxAgeSec := int64(retention.MaxAgeDays) * secondsPerDay diff --git a/internal/controller/valkeycluster_controller.go b/internal/controller/valkeycluster_controller.go index b9b006f..b3459ad 100644 --- a/internal/controller/valkeycluster_controller.go +++ b/internal/controller/valkeycluster_controller.go @@ -214,6 +214,7 @@ func (r *ValkeyClusterReconciler) Reconcile(ctx context.Context, req ctrl.Reques PasswordRef: secretRef, ClusterMode: true, Pod: vc.Spec.Pod, + Modules: vc.Spec.Modules, AuthSecretHash: hashAuthSecret(password), RevisionHistoryLimit: vc.Spec.RevisionHistoryLimit, } diff --git a/internal/controller/valkeycluster_controller_test.go b/internal/controller/valkeycluster_controller_test.go index 672eb94..987d62a 100644 --- a/internal/controller/valkeycluster_controller_test.go +++ b/internal/controller/valkeycluster_controller_test.go @@ -127,4 +127,48 @@ var _ = Describe("ValkeyCluster Controller", func() { Expect(sts.Spec.Template.Spec.Containers[0].Image).To(Equal(cachev1alpha1.DefaultValkeyImage + ":9.0.4")) }) }) + + Context("When reconciling Cluster modules", func() { + const resourceName = "test-valkeycluster-modules-20260610" + + ctx := context.Background() + key := types.NamespacedName{Name: resourceName, Namespace: "default"} + + AfterEach(func() { + resource := &cachev1alpha1.ValkeyCluster{} + if err := k8sClient.Get(ctx, key, resource); err == nil { + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + reconciler := &ValkeyClusterReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + _, _ = reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: key}) + } + }) + + It("spec.modules를 StatefulSet init-container와 loadmodule args로 전달한다", func() { + reconciler := &ValkeyClusterReconciler{Client: k8sClient, Scheme: k8sClient.Scheme()} + + resource := &cachev1alpha1.ValkeyCluster{ + ObjectMeta: metav1.ObjectMeta{Name: resourceName, Namespace: "default"}, + Spec: cachev1alpha1.ValkeyClusterSpec{ + Shards: 3, + ReplicasPerShard: 1, + Version: cachev1alpha1.ValkeyVersion{Version: "9.0.4"}, + Modules: []cachev1alpha1.ModuleSpec{ + {Name: "valkey-search"}, + {Name: "valkey-json"}, + {Name: "valkey-bloom"}, + }, + }, + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + _, err := reconciler.Reconcile(ctx, reconcile.Request{NamespacedName: key}) + Expect(err).NotTo(HaveOccurred()) + + stsKey := types.NamespacedName{Name: resources.StatefulSetName(resourceName), Namespace: "default"} + sts := &appsv1.StatefulSet{} + Expect(k8sClient.Get(ctx, stsKey, sts)).To(Succeed()) + Expect(sts.Spec.Template.Spec.InitContainers).To(HaveLen(3)) + Expect(sts.Spec.Template.Spec.Containers[0].Args).To(ContainElement("--loadmodule")) + Expect(sts.Spec.Template.Spec.Volumes).To(ContainElement(HaveField("Name", resources.ModuleVolumeName))) + }) + }) }) diff --git a/internal/resources/module_init.go b/internal/resources/module_init.go index 845d67e..d0097dc 100644 --- a/internal/resources/module_init.go +++ b/internal/resources/module_init.go @@ -38,8 +38,8 @@ func BuildModuleInitContainers(mods []cachev1alpha1.ModuleSpec) ([]corev1.Contai Name: ModuleVolumeName, VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, } - var initContainers []corev1.Container - var loadArgs []string + initContainers := make([]corev1.Container, 0, len(mods)) + loadArgs := make([]string, 0, len(mods)) for _, m := range mods { var image, soPath string diff --git a/internal/webhook/v1alpha1/module_validation_test.go b/internal/webhook/v1alpha1/module_validation_test.go index cd523b1..fe676de 100644 --- a/internal/webhook/v1alpha1/module_validation_test.go +++ b/internal/webhook/v1alpha1/module_validation_test.go @@ -23,6 +23,15 @@ func standaloneValkeyWithModules(mods ...cachev1alpha1.ModuleSpec) *cachev1alpha return v } +func clusterValkeyWithModules(mods ...cachev1alpha1.ModuleSpec) *cachev1alpha1.ValkeyCluster { + vc := &cachev1alpha1.ValkeyCluster{} + vc.Spec.Shards = 3 + vc.Spec.ReplicasPerShard = 1 + vc.Spec.Version = cachev1alpha1.ValkeyVersion{Version: cachev1alpha1.DefaultValkeyVersion} + vc.Spec.Modules = mods + return vc +} + func TestValidateModules(t *testing.T) { t.Parallel() @@ -83,3 +92,43 @@ func TestValidateModules(t *testing.T) { } }) } + +func TestValidateClusterModules(t *testing.T) { + t.Parallel() + + t.Run("Cluster official Redis Stack-compatible presets → ok", func(t *testing.T) { + t.Parallel() + vc := clusterValkeyWithModules( + cachev1alpha1.ModuleSpec{Name: "valkey-search"}, + cachev1alpha1.ModuleSpec{Name: "valkey-json"}, + cachev1alpha1.ModuleSpec{Name: "valkey-bloom"}, + ) + if errs := validateClusterSpec(vc); len(errs) > 0 { + t.Errorf("Cluster 공식 module preset → expected no error, got %v", errs) + } + }) + + t.Run("Cluster unsupported Redis Stack families → reject", func(t *testing.T) { + t.Parallel() + for _, name := range []string{"redistimeseries", "redisgraph", "redisgears"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + vc := clusterValkeyWithModules(cachev1alpha1.ModuleSpec{Name: name}) + if errs := validateClusterSpec(vc); len(errs) == 0 { + t.Errorf("%s 는 외부 Redis Stack module 이므로 Cluster 에서도 reject 기대", name) + } + }) + } + }) + + t.Run("Cluster BYO Redis Stack image → reject", func(t *testing.T) { + t.Parallel() + vc := clusterValkeyWithModules(cachev1alpha1.ModuleSpec{ + Name: "custom-mod", + Image: "redis/redis-stack-server:7.2.0", + }) + if errs := validateClusterSpec(vc); len(errs) == 0 { + t.Error("Cluster BYO redis-stack image → reject 기대") + } + }) +} diff --git a/internal/webhook/v1alpha1/valkeycluster_webhook.go b/internal/webhook/v1alpha1/valkeycluster_webhook.go index bf7a614..573f0b3 100644 --- a/internal/webhook/v1alpha1/valkeycluster_webhook.go +++ b/internal/webhook/v1alpha1/valkeycluster_webhook.go @@ -219,6 +219,9 @@ func validateClusterSpec(vc *cachev1alpha1.ValkeyCluster) field.ErrorList { // auth.users[].passwordSecretRef cross-cut (Valkey single-CR webhook 와 동일). errs = append(errs, validateUsersSecretRefs(specPath.Child("auth", "users"), vc.Spec.Auth.Users)...) + // modules allow-list 검증 (ADR-0032) — Valkey single-CR 와 동일하게 외부 Redis Stack 거부. + errs = append(errs, validateModules(specPath.Child("modules"), vc.Spec.Modules)...) + // pod.topologySpreadConstraints 일관성 검증 (ROADMAP topology spread). if vc.Spec.Pod != nil { errs = append(errs, validateTopologySpread( diff --git a/test/e2e/modules_test.go b/test/e2e/modules_test.go new file mode 100644 index 0000000..f27a1c2 --- /dev/null +++ b/test/e2e/modules_test.go @@ -0,0 +1,113 @@ +//go:build e2e +// +build e2e + +/* +Copyright 2026 Keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package e2e + +import ( + "fmt" + "os/exec" + "strings" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/keiailab/valkey-operator/test/utils" +) + +var _ = Describe("Valkey Redis Stack-compatible modules", Ordered, func() { + const ( + modulesNamespace = "test-valkey-modules-20260610" + modulesName = "vk-modules" + ) + + BeforeAll(func() { + _, _ = utils.Run(exec.Command("kubectl", "delete", "ns", modulesNamespace, "--ignore-not-found")) + _, err := utils.Run(exec.Command("kubectl", "create", "ns", modulesNamespace)) + Expect(err).NotTo(HaveOccurred()) + }) + + AfterAll(func() { + _, _ = utils.Run(exec.Command("kubectl", "delete", "valkey", modulesName, + "-n", modulesNamespace, "--ignore-not-found")) + _, _ = utils.Run(exec.Command("kubectl", "delete", "ns", modulesNamespace, "--ignore-not-found")) + }) + + It("loads Valkey official modules and serves JSON, Search, and Bloom commands", func() { + manifest := fmt.Sprintf(` +apiVersion: cache.keiailab.io/v1alpha1 +kind: Valkey +metadata: + name: %s + namespace: %s +spec: + mode: Standalone + replicas: 1 + version: + version: "9.0.4" + storage: + ephemeral: true + modules: + - name: valkey-json + - name: valkey-search + - name: valkey-bloom +`, modulesName, modulesNamespace) + + cmd := exec.Command("kubectl", "apply", "-f", "-") + cmd.Stdin = strings.NewReader(manifest) + _, err := utils.Run(cmd) + Expect(err).NotTo(HaveOccurred()) + + Eventually(func() string { + out, _ := utils.Run(exec.Command("kubectl", "get", "valkey", modulesName, + "-n", modulesNamespace, "-o", "jsonpath={.status.phase}")) + return strings.TrimSpace(out) + }, 5*time.Minute, 2*time.Second).Should(Equal("Running")) + + pwdCmd := fmt.Sprintf(`kubectl get secret %s-auth -n %s -o jsonpath='{.data.password}' | base64 -d`, + modulesName, modulesNamespace) + pwd, err := utils.Run(exec.Command("sh", "-c", pwdCmd)) + Expect(err).NotTo(HaveOccurred()) + pwd = strings.TrimSpace(pwd) + Expect(pwd).NotTo(BeEmpty()) + + runCLI := func(args ...string) (string, error) { + base := []string{"exec", "-n", modulesNamespace, modulesName + "-0", "--", + "valkey-cli", "--no-auth-warning", "-a", pwd} + base = append(base, args...) + return utils.Run(exec.Command("kubectl", base...)) + } + + Eventually(func(g Gomega) { + out, err := runCLI("MODULE", "LIST") + g.Expect(err).NotTo(HaveOccurred()) + g.Expect(out).To(ContainSubstring("json")) + g.Expect(out).To(ContainSubstring("search")) + g.Expect(out).To(ContainSubstring("bloom")) + }, 2*time.Minute, 2*time.Second).Should(Succeed()) + + _, err = runCLI("JSON.SET", "doc:1", "$", `{"title":"hello modules"}`) + Expect(err).NotTo(HaveOccurred()) + jsonOut, err := runCLI("JSON.GET", "doc:1", "$.title") + Expect(err).NotTo(HaveOccurred()) + Expect(jsonOut).To(ContainSubstring("hello modules")) + + _, err = runCLI("FT.CREATE", "idx:docs", "ON", "HASH", "PREFIX", "1", "doc:", "SCHEMA", "title", "TEXT") + Expect(err).NotTo(HaveOccurred()) + _, err = runCLI("HSET", "doc:2", "title", "searchable modules") + Expect(err).NotTo(HaveOccurred()) + searchOut, err := runCLI("FT.SEARCH", "idx:docs", "searchable") + Expect(err).NotTo(HaveOccurred()) + Expect(searchOut).To(ContainSubstring("doc:2")) + + bloomOut, err := runCLI("BF.ADD", "bf:docs", "module-item") + Expect(err).NotTo(HaveOccurred()) + Expect(strings.TrimSpace(bloomOut)).To(Or(Equal("1"), ContainSubstring("1"))) + }) +})