diff --git a/go.mod b/go.mod index 1bed94f..d643369 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( cloud.google.com/go/storage v1.62.2 github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.1 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.7.0 - github.com/keiailab/keiailab-commons v0.12.0 + github.com/keiailab/keiailab-commons v0.13.0 github.com/minio/minio-go/v7 v7.2.0 github.com/onsi/ginkgo/v2 v2.29.0 github.com/onsi/gomega v1.41.0 diff --git a/go.sum b/go.sum index 3843f8e..a571c2b 100644 --- a/go.sum +++ b/go.sum @@ -148,8 +148,12 @@ github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/keiailab/keiailab-commons v0.12.0 h1:mcFaekA1lTbtCi8LPsyALP+/ObNmYFGO9bKGzcI+/ZQ= -github.com/keiailab/keiailab-commons v0.12.0/go.mod h1:Lfj6BDoP6OaBxSDAtBuG0iHp9ADo5T8b/Bl54w8n74o= +github.com/keiailab/keiailab-commons v0.12.1-0.20260630004504-13e0976fc111 h1:9eZ33N5WdGza4aoJm13PVFC3UAsJ3EyCh9k8/W+5DJs= +github.com/keiailab/keiailab-commons v0.12.1-0.20260630004504-13e0976fc111/go.mod h1:Lfj6BDoP6OaBxSDAtBuG0iHp9ADo5T8b/Bl54w8n74o= +github.com/keiailab/keiailab-commons v0.12.1-0.20260630011743-a54c4a826028 h1:lFNY/8inscTA9bzp3MRjmZNKeA9LDGCChu6UE4vGAo0= +github.com/keiailab/keiailab-commons v0.12.1-0.20260630011743-a54c4a826028/go.mod h1:Lfj6BDoP6OaBxSDAtBuG0iHp9ADo5T8b/Bl54w8n74o= +github.com/keiailab/keiailab-commons v0.13.0 h1:GfJEIs/HeOu5CGMUJQkTHoeyA3BOX3tHMqqACldo6DI= +github.com/keiailab/keiailab-commons v0.13.0/go.mod h1:Lfj6BDoP6OaBxSDAtBuG0iHp9ADo5T8b/Bl54w8n74o= github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= diff --git a/internal/controller/tls_cert_hash.go b/internal/controller/tls_cert_hash.go index ddd1767..c4bbfbf 100644 --- a/internal/controller/tls_cert_hash.go +++ b/internal/controller/tls_cert_hash.go @@ -7,13 +7,13 @@ package controller import ( "context" - "crypto/sha256" - "encoding/hex" corev1 "k8s.io/api/core/v1" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/types" "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/keiailab/keiailab-commons/pkg/secrethash" ) // hashTLSSecret — TLS Secret data (tls.crt + tls.key + ca.crt) 의 SHA256 (hex) @@ -43,12 +43,8 @@ func hashTLSSecret(ctx context.Context, c client.Client, namespace, secretName s } return "", err } - h := sha256.New() - // 결정적 순서로 누적 (map 순회 비결정성 회피). - for _, key := range []string{"tls.crt", "tls.key", "ca.crt"} { - // key 자체도 누적해 빈 값/누락을 구분 (예: ca.crt 만 변경되어도 hash 변경). - h.Write([]byte(key)) - h.Write(s.Data[key]) - } - return hex.EncodeToString(h.Sum(nil)), nil + // 결정적 키 순서 누적은 keiailab-commons/pkg/secrethash.Hash 에 위임. key 자체도 + // 누적되어 빈 값/누락을 구분한다 (예: ca.crt 만 변경되어도 hash 변경). 기존 거동과 + // byte-identical (동일 키 순서 + full hex). + return secrethash.Hash(s.Data, "tls.crt", "tls.key", "ca.crt"), nil } diff --git a/internal/resources/backup_job.go b/internal/resources/backup_job.go index de3bf46..f573eed 100644 --- a/internal/resources/backup_job.go +++ b/internal/resources/backup_job.go @@ -14,6 +14,7 @@ import ( "k8s.io/apimachinery/pkg/api/resource" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + commonsbatchjob "github.com/keiailab/keiailab-commons/pkg/batchjob" "github.com/keiailab/keiailab-commons/pkg/security" cachev1alpha1 "github.com/keiailab/valkey-operator/api/v1alpha1" @@ -112,50 +113,36 @@ func BuildBackupJob(p BackupJobParams) *batchv1.Job { }) } - backoff := int32(2) - ttl := int32(86400) // 24h — Job 자체 는 24시간 후 자동 정리, PVC 는 보존. - return &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: BackupJobName(p.BackupName), - Namespace: p.Namespace, - Labels: BackupLabels(p.BackupName), - }, - Spec: batchv1.JobSpec{ - BackoffLimit: &backoff, - TTLSecondsAfterFinished: &ttl, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: BackupLabels(p.BackupName)}, - Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyOnFailure, - Containers: []corev1.Container{{ - Name: "rdb-copy", - Image: p.Image, - Command: cmd, - Env: []corev1.EnvVar{{ - Name: "VALKEY_PASSWORD", - ValueFrom: &corev1.EnvVarSource{ - SecretKeyRef: p.PasswordSecret, - }, - }}, - VolumeMounts: volumeMounts, - // iteration 37 (cluster incident fix): PodSecurity restricted invariant. - // data ns 의 enforce=restricted 가 admission 단계에서 capabilities.drop / - // seccompProfile / allowPrivilegeEscalation 미설정 pod 거부 → backup - // job-controller 가 매 5-15s 재시도하며 ValkeyBackup Phase=Copying stuck. - // commons.RestrictedContainer 위임 — RunAsUser=999 (postgres-user 와 분리, - // valkey 표준). - SecurityContext: security.RestrictedContainer(security.WithRunAsUser(999)), - }}, - Volumes: volumes, - SecurityContext: &corev1.PodSecurityContext{ - RunAsNonRoot: new(true), - RunAsUser: new(int64(999)), - FSGroup: new(int64(999)), - }, + // Job 엔벨로프(BackoffLimit/TTL/RestartPolicy/라벨전파)는 keiailab-commons/pkg/batchjob.Build + // 에 위임. 컨테이너(valkey-cli --rdb)/볼륨/SecurityContext 조립은 valkey 도메인 잔류. + return commonsbatchjob.Build(commonsbatchjob.Params{ + Name: BackupJobName(p.BackupName), + Namespace: p.Namespace, + Labels: BackupLabels(p.BackupName), + BackoffLimit: new(int32(2)), + TTLSecondsAfterFinished: new(int32(86400)), // 24h — Job 자동 정리, PVC 보존. + Containers: []corev1.Container{{ + Name: "rdb-copy", + Image: p.Image, + Command: cmd, + Env: []corev1.EnvVar{{ + Name: "VALKEY_PASSWORD", + ValueFrom: &corev1.EnvVarSource{ + SecretKeyRef: p.PasswordSecret, }, - }, + }}, + VolumeMounts: volumeMounts, + // PodSecurity restricted invariant (data ns enforce=restricted admission 통과). + // commons.RestrictedContainer 위임 — RunAsUser=999 (valkey 표준). + SecurityContext: security.RestrictedContainer(security.WithRunAsUser(999)), + }}, + Volumes: volumes, + PodSecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: new(true), + RunAsUser: new(int64(999)), + FSGroup: new(int64(999)), }, - } + }) } func buildBackupShellCommand(p BackupJobParams) string { diff --git a/internal/resources/download_job.go b/internal/resources/download_job.go index aab6d93..88d9950 100644 --- a/internal/resources/download_job.go +++ b/internal/resources/download_job.go @@ -18,6 +18,8 @@ import ( batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + commonsbatchjob "github.com/keiailab/keiailab-commons/pkg/batchjob" ) // RestoreSourcePVCName — ValkeyRestore 가 생성하는 임시 source PVC 이름. @@ -114,48 +116,37 @@ func BuildDownloadJob(p DownloadJobParams) *batchv1.Job { env := s3EnvForJob(p.Endpoint, p.Region, p.ForcePathStyle, p.CredentialsSecretName, p.AccessKeyIDSecretKey, p.SecretAccessKeySecretKey) - backoff := int32(2) - ttl := int32(86400) - return &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: DownloadJobName(p.RestoreName), - Namespace: p.Namespace, - Labels: RestoreLabels(p.RestoreName), - }, - Spec: batchv1.JobSpec{ - BackoffLimit: &backoff, - TTLSecondsAfterFinished: &ttl, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: RestoreLabels(p.RestoreName)}, - Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyOnFailure, - Containers: []corev1.Container{{ - Name: "download", - Image: p.OperatorImage, - Args: args, - Env: env, - VolumeMounts: []corev1.VolumeMount{ - // download 는 write 필요 — ReadOnly=false. - {Name: "backup", MountPath: BackupVolumeMountPath}, - }, - SecurityContext: buildRestrictedContainerSecurityContext(), - }}, - Volumes: []corev1.Volume{ - {Name: "backup", VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: p.PVCName, - }, - }}, - }, - SecurityContext: &corev1.PodSecurityContext{ - RunAsNonRoot: new(true), - RunAsUser: new(int64(65532)), - FSGroup: new(int64(65532)), - }, - }, + // Job 엔벨로프는 keiailab-commons/pkg/batchjob.Build 에 위임. + return commonsbatchjob.Build(commonsbatchjob.Params{ + Name: DownloadJobName(p.RestoreName), + Namespace: p.Namespace, + Labels: RestoreLabels(p.RestoreName), + BackoffLimit: new(int32(2)), + TTLSecondsAfterFinished: new(int32(86400)), + Containers: []corev1.Container{{ + Name: "download", + Image: p.OperatorImage, + Args: args, + Env: env, + VolumeMounts: []corev1.VolumeMount{ + // download 는 write 필요 — ReadOnly=false. + {Name: "backup", MountPath: BackupVolumeMountPath}, }, + SecurityContext: buildRestrictedContainerSecurityContext(), + }}, + Volumes: []corev1.Volume{ + {Name: "backup", VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: p.PVCName, + }, + }}, }, - } + PodSecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: new(true), + RunAsUser: new(int64(65532)), + FSGroup: new(int64(65532)), + }, + }) } // s3EnvForJob — Upload/Download Job 공통 env 빌더 (DRY). diff --git a/internal/resources/hpa.go b/internal/resources/hpa.go index 10d53b6..c1b2d76 100644 --- a/internal/resources/hpa.go +++ b/internal/resources/hpa.go @@ -7,8 +7,8 @@ package resources import ( autoscalingv2 "k8s.io/api/autoscaling/v2" - corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + commonshpa "github.com/keiailab/keiailab-commons/pkg/hpa" cachev1alpha1 "github.com/keiailab/valkey-operator/api/v1alpha1" ) @@ -23,60 +23,34 @@ func HPAName(crName string) string { return crName } // - target: StatefulSet (CRName) // - mode=Replication 만 사용 의도 — caller 가 사전 검증 (webhook + reconciler). // - CPU + Memory metric source (HPA v2 표준). +// +// 빌드 골격(ScaleTargetRef / MinReplicas clamp / Max 보정)은 keiailab-commons/pkg/hpa.Build +// 에 위임. metric 조립은 valkey 도메인(CPU 기본 70 + opt-in Memory)으로 잔류 — +// commons CPUUtilization/MemoryUtilization 헬퍼 사용. MinFloor=2 (valkey 정책). func BuildHorizontalPodAutoscaler(v *cachev1alpha1.Valkey) *autoscalingv2.HorizontalPodAutoscaler { if v.Spec.Autoscaling == nil || !v.Spec.Autoscaling.Enabled { return nil } a := v.Spec.Autoscaling - minR := max(a.MinReplicas, int32(2)) - maxR := max(a.MaxReplicas, minR) - cpuTarget := a.TargetCPUUtilizationPercentage if cpuTarget == 0 { cpuTarget = 70 } - - metrics := []autoscalingv2.MetricSpec{ - { - Type: autoscalingv2.ResourceMetricSourceType, - Resource: &autoscalingv2.ResourceMetricSource{ - Name: corev1.ResourceCPU, - Target: autoscalingv2.MetricTarget{ - Type: autoscalingv2.UtilizationMetricType, - AverageUtilization: new(cpuTarget), - }, - }, - }, - } + metrics := []autoscalingv2.MetricSpec{commonshpa.CPUUtilization(cpuTarget)} if a.TargetMemoryUtilizationPercentage > 0 { - metrics = append(metrics, autoscalingv2.MetricSpec{ - Type: autoscalingv2.ResourceMetricSourceType, - Resource: &autoscalingv2.ResourceMetricSource{ - Name: corev1.ResourceMemory, - Target: autoscalingv2.MetricTarget{ - Type: autoscalingv2.UtilizationMetricType, - AverageUtilization: new(a.TargetMemoryUtilizationPercentage), - }, - }, - }) + metrics = append(metrics, commonshpa.MemoryUtilization(a.TargetMemoryUtilizationPercentage)) } - return &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Name: HPAName(v.Name), - Namespace: v.Namespace, - Labels: CommonLabels(v.Name, "valkey"), - }, - Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ - ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ - APIVersion: "apps/v1", - Kind: "StatefulSet", - Name: StatefulSetName(v.Name), - }, - MinReplicas: new(minR), - MaxReplicas: maxR, - Metrics: metrics, - }, - } + return commonshpa.Build(commonshpa.Params{ + Name: HPAName(v.Name), + Namespace: v.Namespace, + Labels: CommonLabels(v.Name, "valkey"), + TargetKind: "StatefulSet", + TargetName: StatefulSetName(v.Name), + MinReplicas: a.MinReplicas, + MaxReplicas: a.MaxReplicas, + MinFloor: 2, + Metrics: metrics, + }) } diff --git a/internal/resources/pdb.go b/internal/resources/pdb.go index 5b0c083..dd6accd 100644 --- a/internal/resources/pdb.go +++ b/internal/resources/pdb.go @@ -9,36 +9,42 @@ import ( "fmt" policyv1 "k8s.io/api/policy/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/util/intstr" + + commonspdb "github.com/keiailab/keiailab-commons/pkg/pdb" cachev1alpha1 "github.com/keiailab/valkey-operator/api/v1alpha1" ) -// BuildPDB — opt-in PodDisruptionBudget. minAvailable / maxUnavailable 둘 중 하나만 사용. -// 둘 다 nil 이면 minAvailable = replicas-1 (3 노드 RS → minAvailable=2) 기본 적용. -func BuildPDB(crName, namespace string, replicas int32, spec *cachev1alpha1.PodDisruptionBudgetSpec) *policyv1.PodDisruptionBudget { - pdb := &policyv1.PodDisruptionBudget{ - ObjectMeta: metav1.ObjectMeta{ - Name: PDBName(crName), - Namespace: namespace, - Labels: CommonLabels(crName, "valkey"), - }, - Spec: policyv1.PodDisruptionBudgetSpec{ - Selector: &metav1.LabelSelector{MatchLabels: SelectorLabels(crName)}, - }, +// pdbParamsFromSpec — CR 의 PodDisruptionBudgetSpec 을 commons pdb.Params 로 변환한다. +// valkey 의 기존 우선순위(MaxUnavailable 먼저 검사)를 보존하기 위해 *둘 중 하나만* +// commons 로 전달한다 (둘 다 nil 이면 commons 가 DefaultFloor 정책 적용). +func pdbParamsFromSpec(name, namespace string, labels, selector map[string]string, replicas int32, spec *cachev1alpha1.PodDisruptionBudgetSpec) commonspdb.Params { + p := commonspdb.Params{ + Name: name, + Namespace: namespace, + Labels: labels, + Selector: selector, + Replicas: replicas, + DefaultFloor: 1, // valkey: primary 항상 보존 (minAvailable >= 1) } switch { case spec != nil && spec.MaxUnavailable != nil: - pdb.Spec.MaxUnavailable = spec.MaxUnavailable + p.MaxUnavailable = spec.MaxUnavailable case spec != nil && spec.MinAvailable != nil: - pdb.Spec.MinAvailable = spec.MinAvailable - default: - min := max(int(replicas-1), 1) - v := intstr.FromInt(min) - pdb.Spec.MinAvailable = &v + p.MinAvailable = spec.MinAvailable } - return pdb + return p +} + +// BuildPDB — opt-in PodDisruptionBudget. minAvailable / maxUnavailable 둘 중 하나만 사용. +// 둘 다 nil 이면 minAvailable = replicas-1 (3 노드 RS → minAvailable=2) 기본 적용. +// +// 빌드 골격은 keiailab-commons/pkg/pdb.Build 에 위임 (default-floor / min·max 정책 +// SSOT). name / labels / selector / floor 는 valkey 도메인 책임으로 잔류. +func BuildPDB(crName, namespace string, replicas int32, spec *cachev1alpha1.PodDisruptionBudgetSpec) *policyv1.PodDisruptionBudget { + return commonspdb.Build(pdbParamsFromSpec( + PDBName(crName), namespace, CommonLabels(crName, "valkey"), SelectorLabels(crName), replicas, spec, + )) } // ShardPDBName — ValkeyCluster shard-aware PDB name (CDEX-M2). @@ -56,8 +62,8 @@ func ShardSelectorLabels(crName string, shardIdx int) map[string]string { } // BuildShardPDB — CDEX-M2 (2026-05-21): ValkeyCluster shard 별 PDB 생성. opt-in -// (`spec.PodDisruptionBudget.PerShard=true`). mongodb sharded `builder.go:2105` -// per-shard PDB pattern 정합 — drain 시 모든 shard primary 동시 evict 차단. +// (`spec.PodDisruptionBudget.PerShard=true`). drain 시 모든 shard primary 동시 +// evict 차단. // // 동작: // - shardReplicas = 1 (primary) + replicasPerShard @@ -65,27 +71,10 @@ func ShardSelectorLabels(crName string, shardIdx int) map[string]string { // - selector = `app.kubernetes.io/name=valkey + instance= + valkey.keiailab.io/shard=` // - spec.MinAvailable / MaxUnavailable 명시 override 가능 // -// caller (reconciler) 가 shard 별 loop 의무. orphan PDB cleanup = caller 책임. +// 빌드 골격은 keiailab-commons/pkg/pdb.Build 에 위임. caller (reconciler) 가 shard +// 별 loop 의무. orphan PDB cleanup = caller 책임. func BuildShardPDB(crName, namespace string, shardIdx int, shardReplicas int32, spec *cachev1alpha1.PodDisruptionBudgetSpec) *policyv1.PodDisruptionBudget { - pdb := &policyv1.PodDisruptionBudget{ - ObjectMeta: metav1.ObjectMeta{ - Name: ShardPDBName(crName, shardIdx), - Namespace: namespace, - Labels: CommonLabels(crName, "valkey"), - }, - Spec: policyv1.PodDisruptionBudgetSpec{ - Selector: &metav1.LabelSelector{MatchLabels: ShardSelectorLabels(crName, shardIdx)}, - }, - } - switch { - case spec != nil && spec.MaxUnavailable != nil: - pdb.Spec.MaxUnavailable = spec.MaxUnavailable - case spec != nil && spec.MinAvailable != nil: - pdb.Spec.MinAvailable = spec.MinAvailable - default: - min := max(int(shardReplicas-1), 1) - v := intstr.FromInt(min) - pdb.Spec.MinAvailable = &v - } - return pdb + return commonspdb.Build(pdbParamsFromSpec( + ShardPDBName(crName, shardIdx), namespace, CommonLabels(crName, "valkey"), ShardSelectorLabels(crName, shardIdx), shardReplicas, spec, + )) } diff --git a/internal/resources/service.go b/internal/resources/service.go index 6e6c220..0228b20 100644 --- a/internal/resources/service.go +++ b/internal/resources/service.go @@ -9,17 +9,20 @@ import ( "maps" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/util/intstr" + commonsservice "github.com/keiailab/keiailab-commons/pkg/service" + cachev1alpha1 "github.com/keiailab/valkey-operator/api/v1alpha1" ) +// 조립(ObjectMeta + Spec)은 keiailab-commons/pkg/service.Build 에 위임. 포트 구성 +// (clientPorts) + ServiceSpec 옵션 병합 + 서비스 종류 선택은 valkey 도메인 잔류. + // BuildHeadlessService — pod-to-pod stable DNS (StatefulSet 필수). // // tlsEnabled=true 시 TLS-port (6380) 를 추가 expose — Valkey 의 port (6379 plain) / -// tls-port (6380 TLS) 분리 모델 정합. 외부 client (또는 inter-pod TLS replication) -// 가 6380 으로 connect 가능. tls-auth-clients=yes 운영 시 client cert 필요. +// tls-port (6380 TLS) 분리 모델 정합. func BuildHeadlessService( crName, namespace string, clusterMode, tlsEnabled bool, @@ -35,45 +38,30 @@ func BuildHeadlessService( opts := firstServiceSpec(serviceSpec) labels := CommonLabels(crName, "valkey") maps.Copy(labels, opts.Labels) - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: HeadlessServiceName(crName), - Namespace: namespace, - Labels: labels, - Annotations: maps.Clone(opts.Annotations), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "None", - IPFamilyPolicy: opts.IPFamilyPolicy, - IPFamilies: opts.IPFamilies, - PublishNotReadyAddresses: true, // cluster init / replication 시 미준비 pod DNS 필요 - Selector: SelectorLabels(crName), - Ports: ports, - }, - } + return commonsservice.Build(commonsservice.Params{ + Name: HeadlessServiceName(crName), + Namespace: namespace, + Labels: labels, + Annotations: maps.Clone(opts.Annotations), + Selector: SelectorLabels(crName), + Ports: ports, + Headless: true, // ClusterIP None + PublishNotReadyAddresses (cluster init/replication) + IPFamilyPolicy: opts.IPFamilyPolicy, + IPFamilies: opts.IPFamilies, + }) } // BuildMetricsService — exporter sidecar(:9121) 전용 ClusterIP Service. -// -// ServiceMonitor 가 selector 매칭으로 자동 스크랩. 별도 Service 로 분리한 이유: -// ServiceMonitor 의 endpoint discovery 는 *Service 의 ports* 를 보지만 client/headless -// Service 에 metrics 포트를 추가하면 client traffic 과 의미가 섞임 + 사용자 혼란. func BuildMetricsService(crName, namespace string) *corev1.Service { - labels := CommonLabels(crName, "valkey-metrics") - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: MetricsServiceName(crName), - Namespace: namespace, - Labels: labels, - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - Selector: SelectorLabels(crName), - Ports: []corev1.ServicePort{ - {Name: "metrics", Port: PortMetrics, TargetPort: intstr.FromInt(PortMetrics), Protocol: corev1.ProtocolTCP}, - }, + return commonsservice.Build(commonsservice.Params{ + Name: MetricsServiceName(crName), + Namespace: namespace, + Labels: CommonLabels(crName, "valkey-metrics"), + Selector: SelectorLabels(crName), + Ports: []corev1.ServicePort{ + {Name: "metrics", Port: PortMetrics, TargetPort: intstr.FromInt(PortMetrics), Protocol: corev1.ProtocolTCP}, }, - } + }) } // MetricsServiceLabels — ServiceMonitor 가 selector 로 사용할 라벨. @@ -82,71 +70,52 @@ func MetricsServiceLabels(crName string) map[string]string { } // BuildClientService — 외부 클라이언트용 ClusterIP Service. -// -// tlsEnabled=true 시 client-tls (6380) 도 expose — 외부 client 가 TLS connection -// 사용 가능 (rediss:// scheme + tls-auth-clients=yes 시 client cert 필요). func BuildClientService( crName, namespace string, tlsEnabled bool, serviceSpec ...*cachev1alpha1.ServiceSpec, ) *corev1.Service { - ports := clientPorts(tlsEnabled) opts := firstServiceSpec(serviceSpec) - serviceType := opts.Type - if serviceType == "" { - serviceType = corev1.ServiceTypeClusterIP - } labels := CommonLabels(crName, "valkey") maps.Copy(labels, opts.Labels) - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: ClientServiceName(crName), - Namespace: namespace, - Labels: labels, - Annotations: maps.Clone(opts.Annotations), - }, - Spec: corev1.ServiceSpec{ - Type: serviceType, - IPFamilyPolicy: opts.IPFamilyPolicy, - IPFamilies: opts.IPFamilies, - Selector: SelectorLabels(crName), - Ports: ports, - }, - } + return commonsservice.Build(commonsservice.Params{ + Name: ClientServiceName(crName), + Namespace: namespace, + Labels: labels, + Annotations: maps.Clone(opts.Annotations), + Selector: SelectorLabels(crName), + Ports: clientPorts(tlsEnabled), + Type: opts.Type, // "" → commons 가 ClusterIP 기본 적용 + IPFamilyPolicy: opts.IPFamilyPolicy, + IPFamilies: opts.IPFamilies, + }) } // BuildPrimaryService — Replication primary-only ClusterIP Service. // // selector 에 role=primary 라벨 포함 → 컨트롤러(reconcilePrimaryPodLabels)가 -// Status.CurrentPrimary pod 에 부여한 LabelValkeyRole=primary 와 매칭. 쓰기 클라이언트 -// (세션/락/queue)가 이 안정 이름(-primary)으로 접속하면 *항상 현재 master* 도달 → -// RR Client Service(master+replica 양쪽 endpoint)의 write-to-replica READONLY 갭 -// (phpredis 무한 세션락 행 등) 해소. failover 시 컨트롤러가 relabel → kube endpoints -// controller 가 즉시 endpoints 갱신 (polling/수동 Endpoints 불요 — 자동 추종). +// Status.CurrentPrimary pod 에 부여한 LabelValkeyRole=primary 와 매칭. 쓰기 클라이언트가 +// 안정 이름(-primary)으로 접속하면 항상 현재 master 도달. failover 시 relabel → +// endpoints controller 가 즉시 추종. func BuildPrimaryService( crName, namespace string, tlsEnabled bool, serviceSpec ...*cachev1alpha1.ServiceSpec, ) *corev1.Service { - ports := clientPorts(tlsEnabled) opts := firstServiceSpec(serviceSpec) labels := CommonLabels(crName, "valkey-primary") maps.Copy(labels, opts.Labels) - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: PrimaryServiceName(crName), - Namespace: namespace, - Labels: labels, - Annotations: maps.Clone(opts.Annotations), - }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - IPFamilyPolicy: opts.IPFamilyPolicy, - IPFamilies: opts.IPFamilies, - Selector: PrimarySelectorLabels(crName), - Ports: ports, - }, - } + return commonsservice.Build(commonsservice.Params{ + Name: PrimaryServiceName(crName), + Namespace: namespace, + Labels: labels, + Annotations: maps.Clone(opts.Annotations), + Selector: PrimarySelectorLabels(crName), + Ports: clientPorts(tlsEnabled), + Type: corev1.ServiceTypeClusterIP, + IPFamilyPolicy: opts.IPFamilyPolicy, + IPFamilies: opts.IPFamilies, + }) } func firstServiceSpec(specs []*cachev1alpha1.ServiceSpec) *cachev1alpha1.ServiceSpec { diff --git a/internal/resources/statefulset.go b/internal/resources/statefulset.go index 15b0daf..ac4da7a 100644 --- a/internal/resources/statefulset.go +++ b/internal/resources/statefulset.go @@ -18,9 +18,10 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "github.com/keiailab/keiailab-commons/pkg/probes" + commonspvc "github.com/keiailab/keiailab-commons/pkg/pvc" "github.com/keiailab/keiailab-commons/pkg/security" - "github.com/keiailab/keiailab-commons/pkg/storageclass" commonstopology "github.com/keiailab/keiailab-commons/pkg/topology" + commonsvolume "github.com/keiailab/keiailab-commons/pkg/volume" cachev1alpha1 "github.com/keiailab/valkey-operator/api/v1alpha1" ) @@ -78,29 +79,20 @@ func BuildStatefulSet(p STSParams) *appsv1.StatefulSet { storageSize = resource.MustParse("8Gi") } - accessModes := p.Storage.AccessModes - if len(accessModes) == 0 { - accessModes = []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce} - } - dataPVC := corev1.PersistentVolumeClaim{ - ObjectMeta: metav1.ObjectMeta{ - Name: "data", - Labels: maps.Clone(p.Storage.Labels), - Annotations: maps.Clone(p.Storage.Annotations), - }, - Spec: corev1.PersistentVolumeClaimSpec{ - AccessModes: accessModes, - Resources: corev1.VolumeResourceRequirements{ - Requests: corev1.ResourceList{corev1.ResourceStorage: storageSize}, - }, - }, - } storageClass := p.StorageClass if p.Storage.StorageClassName != "" { storageClass = p.Storage.StorageClassName } - // commons storageclass.Normalize — 빈 값 = nil (cluster default StorageClass). - dataPVC.Spec.StorageClassName = storageclass.Normalize(storageClass) + // 빌드 골격은 keiailab-commons/pkg/pvc.BuildDataPVC 에 위임 (accessModes 기본 RWO + + // storageclass.Normalize 빈 값→nil). StorageClass 우선순위는 valkey 도메인 잔류. + dataPVC := commonspvc.BuildDataPVC(commonspvc.DataPVCParams{ + Name: "data", + Labels: maps.Clone(p.Storage.Labels), + Annotations: maps.Clone(p.Storage.Annotations), + AccessModes: p.Storage.AccessModes, + StorageClass: storageClass, + Size: storageSize, + }) // POD_IP — downward API 로 pod 의 실제 IP 주입. cluster mode 에서 // cluster-announce-ip 로 사용 (Defect ②: pod 재시작 후 새 IP 를 gossip 에 @@ -402,20 +394,15 @@ func tlsVolumeMounts(secretName string) []corev1.VolumeMount { if secretName == "" { return nil } - return []corev1.VolumeMount{{Name: "tls", MountPath: TLSDir, ReadOnly: true}} + // 0o400/readonly cert 불변식은 keiailab-commons/pkg/volume.TLSSecretMount 에 위임. + _, mount := commonsvolume.TLSSecretMount("tls", secretName, TLSDir) + return []corev1.VolumeMount{mount} } func tlsVolumes(secretName string) []corev1.Volume { if secretName == "" { return nil } - return []corev1.Volume{{ - Name: "tls", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: secretName, - DefaultMode: new(int32(0o400)), - }, - }, - }} + vol, _ := commonsvolume.TLSSecretMount("tls", secretName, TLSDir) + return []corev1.Volume{vol} } diff --git a/internal/resources/upload_job.go b/internal/resources/upload_job.go index 838e332..efd0abd 100644 --- a/internal/resources/upload_job.go +++ b/internal/resources/upload_job.go @@ -13,7 +13,8 @@ import ( batchv1 "k8s.io/api/batch/v1" corev1 "k8s.io/api/core/v1" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + commonsbatchjob "github.com/keiailab/keiailab-commons/pkg/batchjob" ) // UploadJobName — 업로드 Job 이름. ValkeyBackup CR 이름 + "-upload". @@ -95,46 +96,35 @@ func BuildUploadJob(p UploadJobParams) *batchv1.Job { }, } - backoff := int32(2) - ttl := int32(86400) // 24h - return &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: UploadJobName(p.BackupName), - Namespace: p.Namespace, - Labels: BackupLabels(p.BackupName), - }, - Spec: batchv1.JobSpec{ - BackoffLimit: &backoff, - TTLSecondsAfterFinished: &ttl, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: BackupLabels(p.BackupName)}, - Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyOnFailure, - Containers: []corev1.Container{{ - Name: "upload", - Image: p.OperatorImage, - Args: args, - Env: env, - VolumeMounts: []corev1.VolumeMount{ - {Name: "backup", MountPath: BackupVolumeMountPath, ReadOnly: true}, - }, - SecurityContext: buildRestrictedContainerSecurityContext(), - }}, - Volumes: []corev1.Volume{ - {Name: "backup", VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: p.PVCName, - ReadOnly: true, - }, - }}, - }, - SecurityContext: &corev1.PodSecurityContext{ - RunAsNonRoot: new(true), - RunAsUser: new(int64(65532)), - FSGroup: new(int64(65532)), - }, - }, + // Job 엔벨로프는 keiailab-commons/pkg/batchjob.Build 에 위임. 컨테이너(업로드)/볼륨은 잔류. + return commonsbatchjob.Build(commonsbatchjob.Params{ + Name: UploadJobName(p.BackupName), + Namespace: p.Namespace, + Labels: BackupLabels(p.BackupName), + BackoffLimit: new(int32(2)), + TTLSecondsAfterFinished: new(int32(86400)), // 24h + Containers: []corev1.Container{{ + Name: "upload", + Image: p.OperatorImage, + Args: args, + Env: env, + VolumeMounts: []corev1.VolumeMount{ + {Name: "backup", MountPath: BackupVolumeMountPath, ReadOnly: true}, }, + SecurityContext: buildRestrictedContainerSecurityContext(), + }}, + Volumes: []corev1.Volume{ + {Name: "backup", VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: p.PVCName, + ReadOnly: true, + }, + }}, }, - } + PodSecurityContext: &corev1.PodSecurityContext{ + RunAsNonRoot: new(true), + RunAsUser: new(int64(65532)), + FSGroup: new(int64(65532)), + }, + }) }