diff --git a/go.mod b/go.mod index 4976ab2..122314c 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ toolchain go1.26.4 require ( // TODO(#236): keiailab-commons v1.0.0 안정 릴리스 시 업그레이드 예정. // 현재 v0.10.0 — API 변경 가능성 잔존. - github.com/keiailab/keiailab-commons v0.11.0 + github.com/keiailab/keiailab-commons v0.13.0 github.com/onsi/ginkgo/v2 v2.29.0 github.com/onsi/gomega v1.41.0 github.com/prometheus/client_golang v1.23.2 diff --git a/go.sum b/go.sum index 32401da..6a9b60e 100644 --- a/go.sum +++ b/go.sum @@ -105,6 +105,12 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/keiailab/keiailab-commons v0.11.0 h1:aydaheRHz3OjqgWU6C6dpssYA87ba9wZsd3G2kTAxCY= github.com/keiailab/keiailab-commons v0.11.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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/internal/controller/password_rotation.go b/internal/controller/password_rotation.go index c57623f..a2c64af 100644 --- a/internal/controller/password_rotation.go +++ b/internal/controller/password_rotation.go @@ -2,9 +2,8 @@ package controller import ( "context" - "crypto/sha256" - "fmt" + commonssecrethash "github.com/keiailab/keiailab-commons/pkg/secrethash" commonsstatus "github.com/keiailab/keiailab-commons/pkg/status" corev1 "k8s.io/api/core/v1" @@ -62,12 +61,12 @@ func (r *MongoDBReconciler) reconcilePasswordRotation(ctx context.Context, mdb * } func hashSecretData(secret *corev1.Secret) string { - h := sha256.New() - for k, v := range secret.Data { - h.Write([]byte(k)) - h.Write(v) - } - return fmt.Sprintf("%x", h.Sum(nil))[:16] + // 결정적 정렬-누적은 keiailab-commons/pkg/secrethash.Hash 에 위임 — 기존 + // `for k, v := range secret.Data` map 순회의 *비결정성 버그*(동일 Secret 이 + // reconcile 마다 다른 해시 → 불필요한 rotation 신호) 를 구조적 교정. 16자 + // truncation 은 annotation 형식 보존을 위해 유지 (determinism 만 교정 → + // adopt 시 1회 해시 변화 후 안정). + return commonssecrethash.Hash(secret.Data)[:16] } func getAnnotation(mdb *mongodbv1alpha1.MongoDB, key string) string { diff --git a/internal/controller/password_rotation_hash_test.go b/internal/controller/password_rotation_hash_test.go new file mode 100644 index 0000000..4035cfc --- /dev/null +++ b/internal/controller/password_rotation_hash_test.go @@ -0,0 +1,37 @@ +package controller + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" +) + +// TestHashSecretData_Deterministic 은 commons secrethash 위임으로 기존 map-순회 +// 비결정성 버그가 교정됐음을 보증한다 — 동일 Secret 은 항상 동일 해시. +func TestHashSecretData_Deterministic(t *testing.T) { + t.Parallel() + s := &corev1.Secret{Data: map[string][]byte{ + "username": []byte("admin"), + "password": []byte("p@ss"), + "keyfile": []byte("xyzkeyfilecontents"), + }} + first := hashSecretData(s) + for range 200 { + if got := hashSecretData(s); got != first { + t.Fatalf("hashSecretData 비결정적: %s != %s", got, first) + } + } + if len(first) != 16 { + t.Fatalf("해시 길이 %d, want 16 (annotation 형식 truncation 보존)", len(first)) + } +} + +// 값 변경이 해시에 반영돼야 rotation 신호가 동작한다. +func TestHashSecretData_ValueChangeChangesHash(t *testing.T) { + t.Parallel() + a := hashSecretData(&corev1.Secret{Data: map[string][]byte{"password": []byte("OLD")}}) + b := hashSecretData(&corev1.Secret{Data: map[string][]byte{"password": []byte("NEW")}}) + if a == b { + t.Fatal("값 변경이 해시에 반영되지 않음") + } +} diff --git a/internal/resources/builder.go b/internal/resources/builder.go index 5102a85..5f7cc22 100644 --- a/internal/resources/builder.go +++ b/internal/resources/builder.go @@ -24,10 +24,15 @@ import ( "k8s.io/apimachinery/pkg/util/intstr" "k8s.io/utils/ptr" + commonsbatchjob "github.com/keiailab/keiailab-commons/pkg/batchjob" + commonshpa "github.com/keiailab/keiailab-commons/pkg/hpa" commonslabels "github.com/keiailab/keiailab-commons/pkg/labels" commonsnp "github.com/keiailab/keiailab-commons/pkg/networkpolicy" + commonspdb "github.com/keiailab/keiailab-commons/pkg/pdb" "github.com/keiailab/keiailab-commons/pkg/probes" + commonsservice "github.com/keiailab/keiailab-commons/pkg/service" commonstopology "github.com/keiailab/keiailab-commons/pkg/topology" + commonsvolume "github.com/keiailab/keiailab-commons/pkg/volume" mongodbv1alpha1 "github.com/keiailab/mongodb-operator/api/v1alpha1" "github.com/keiailab/mongodb-operator/internal/assets" @@ -210,24 +215,15 @@ func tlsArgs(tls *mongodbv1alpha1.TLSSpec) []string { // Phase 3a 는 volume mount 만 추가 — mongod args 의 --tlsCertificateKeyFile 통합은 // Phase 3b 에서 init container PEM merge (cat tls.crt tls.key > server.pem) 후. func buildTLSServerVolume(secretName string) corev1.Volume { - return corev1.Volume{ - Name: "tls-server", - VolumeSource: corev1.VolumeSource{ - Secret: &corev1.SecretVolumeSource{ - SecretName: secretName, - DefaultMode: ptr.To[int32](0o400), - }, - }, - } + // 0o400/readonly cert 불변식은 keiailab-commons/pkg/volume.TLSSecretMount 에 위임. + vol, _ := commonsvolume.TLSSecretMount("tls-server", secretName, MongoTLSMountPath) + return vol } // buildTLSServerMount 는 tls-server Volume 의 mountPath 를 반환한다. func buildTLSServerMount() corev1.VolumeMount { - return corev1.VolumeMount{ - Name: "tls-server", - MountPath: MongoTLSMountPath, - ReadOnly: true, - } + _, mount := commonsvolume.TLSSecretMount("tls-server", "", MongoTLSMountPath) + return mount } // buildLabels — keiailab-commons/pkg/labels 위임 (iteration 27). @@ -528,42 +524,35 @@ func BuildShardScriptsConfigMap(mdbsh *mongodbv1alpha1.MongoDBSharded, shardInde } } -// BuildHeadlessService creates a headless service for StatefulSet +// BuildHeadlessService creates a headless service for StatefulSet. +// 조립은 keiailab-commons/pkg/service.Build 에 위임 (Headless → ClusterIP None + +// PublishNotReadyAddresses). 포트/라벨은 mongo 도메인 잔류. func BuildHeadlessService(mdb *mongodbv1alpha1.MongoDB) *corev1.Service { - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: mdb.Name + "-headless", - Namespace: mdb.Namespace, - Labels: buildLabels(mdb.Name, "headless"), - }, - Spec: corev1.ServiceSpec{ - ClusterIP: "None", - Selector: buildLabels(mdb.Name, "replicaset"), - Ports: []corev1.ServicePort{ - {Name: "mongodb", Port: mongoDBPort, TargetPort: intstr.FromInt(mongoDBPort)}, - }, - PublishNotReadyAddresses: true, - }, - } + return commonsservice.Build(commonsservice.Params{ + Name: mdb.Name + "-headless", + Namespace: mdb.Namespace, + Labels: buildLabels(mdb.Name, "headless"), + Selector: buildLabels(mdb.Name, "replicaset"), + Ports: []corev1.ServicePort{ + {Name: "mongodb", Port: mongoDBPort, TargetPort: intstr.FromInt(mongoDBPort)}, + }, + Headless: true, + }) } -// BuildClientService creates a client service for MongoDB access +// BuildClientService creates a client service for MongoDB access. func BuildClientService(mdb *mongodbv1alpha1.MongoDB) *corev1.Service { - return &corev1.Service{ - ObjectMeta: metav1.ObjectMeta{ - Name: mdb.Name, - Namespace: mdb.Namespace, - Labels: buildLabels(mdb.Name, "client"), + return commonsservice.Build(commonsservice.Params{ + Name: mdb.Name, + Namespace: mdb.Namespace, + Labels: buildLabels(mdb.Name, "client"), + Selector: buildLabels(mdb.Name, "replicaset"), + Type: corev1.ServiceTypeClusterIP, + Ports: []corev1.ServicePort{ + {Name: "mongodb", Port: mongoDBPort, TargetPort: intstr.FromInt(mongoDBPort)}, + {Name: "metrics", Port: metricsPort, TargetPort: intstr.FromInt(metricsPort)}, }, - Spec: corev1.ServiceSpec{ - Type: corev1.ServiceTypeClusterIP, - Selector: buildLabels(mdb.Name, "replicaset"), - Ports: []corev1.ServicePort{ - {Name: "mongodb", Port: mongoDBPort, TargetPort: intstr.FromInt(mongoDBPort)}, - {Name: "metrics", Port: metricsPort, TargetPort: intstr.FromInt(metricsPort)}, - }, - }, - } + }) } // BuildReplicaSetStatefulSet creates a StatefulSet for MongoDB ReplicaSet. @@ -2054,44 +2043,34 @@ func BuildBackupJob(backup *mongodbv1alpha1.MongoDBBackup, authSecretName string // Build backup script script := buildBackupScript(backup) - return &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: backup.Name, - Namespace: backup.Namespace, - Labels: labels, - }, - Spec: batchv1.JobSpec{ - BackoffLimit: &backoff, - TTLSecondsAfterFinished: &ttl, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{ - Labels: labels, - }, - Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyOnFailure, - Containers: []corev1.Container{ - { - Name: "backup", - Image: defaultImage, - Command: []string{"/bin/bash", "-c"}, - Args: []string{script}, - Env: envVars, - Resources: corev1.ResourceRequirements{ - Requests: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("100m"), - corev1.ResourceMemory: resource.MustParse("256Mi"), - }, - Limits: corev1.ResourceList{ - corev1.ResourceCPU: resource.MustParse("500m"), - corev1.ResourceMemory: resource.MustParse("1Gi"), - }, - }, - }, + // Job 엔벨로프는 keiailab-commons/pkg/batchjob.Build 에 위임. 컨테이너(mongodump + // 스크립트) 조립은 mongo 도메인 잔류. + return commonsbatchjob.Build(commonsbatchjob.Params{ + Name: backup.Name, + Namespace: backup.Namespace, + Labels: labels, + BackoffLimit: &backoff, + TTLSecondsAfterFinished: &ttl, + Containers: []corev1.Container{ + { + Name: "backup", + Image: defaultImage, + Command: []string{"/bin/bash", "-c"}, + Args: []string{script}, + Env: envVars, + Resources: corev1.ResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("100m"), + corev1.ResourceMemory: resource.MustParse("256Mi"), + }, + Limits: corev1.ResourceList{ + corev1.ResourceCPU: resource.MustParse("500m"), + corev1.ResourceMemory: resource.MustParse("1Gi"), }, }, }, }, - } + }) } // BuildBackupCronJob creates a CronJob that periodically creates MongoDBBackup CRs. @@ -2228,47 +2207,38 @@ fi mongorestore --uri "${MONGODB_URI}" ${RESTORE_FLAGS} echo "[restore] completed" ` - return &batchv1.Job{ - ObjectMeta: metav1.ObjectMeta{ - Name: backup.Name + "-restore", - Namespace: backup.Namespace, - Labels: labels, + // Job 엔벨로프는 keiailab-commons/pkg/batchjob.Build 에 위임. 컨테이너(mongorestore)/볼륨 잔류. + return commonsbatchjob.Build(commonsbatchjob.Params{ + Name: backup.Name + "-restore", + Namespace: backup.Namespace, + Labels: labels, + BackoffLimit: &backoff, + TTLSecondsAfterFinished: &ttl, + Containers: []corev1.Container{ + { + Name: "mongorestore", + Image: getMongoDBImage(mongodbv1alpha1.MongoDBVersion{Version: "8.2"}), + Command: []string{"sh", "-c", script}, + Env: envVars, + VolumeMounts: []corev1.VolumeMount{ + {Name: "source", MountPath: "/data/source", ReadOnly: true}, + }, + SecurityContext: buildDefaultContainerSecurityContext(), + }, }, - Spec: batchv1.JobSpec{ - BackoffLimit: &backoff, - TTLSecondsAfterFinished: &ttl, - Template: corev1.PodTemplateSpec{ - ObjectMeta: metav1.ObjectMeta{Labels: labels}, - Spec: corev1.PodSpec{ - RestartPolicy: corev1.RestartPolicyOnFailure, - Containers: []corev1.Container{ - { - Name: "mongorestore", - Image: getMongoDBImage(mongodbv1alpha1.MongoDBVersion{Version: "8.2"}), - Command: []string{"sh", "-c", script}, - Env: envVars, - VolumeMounts: []corev1.VolumeMount{ - {Name: "source", MountPath: "/data/source", ReadOnly: true}, - }, - SecurityContext: buildDefaultContainerSecurityContext(), - }, - }, - Volumes: []corev1.Volume{ - { - Name: "source", - VolumeSource: corev1.VolumeSource{ - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: backup.Spec.Restore.SourceBackupName, - ReadOnly: true, - }, - }, - }, + Volumes: []corev1.Volume{ + { + Name: "source", + VolumeSource: corev1.VolumeSource{ + PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ + ClaimName: backup.Spec.Restore.SourceBackupName, + ReadOnly: true, }, - SecurityContext: buildDefaultSecurityContext(), }, }, }, - }, nil + PodSecurityContext: buildDefaultSecurityContext(), + }), nil } // BuildMongoDBPDB는 MongoDB ReplicaSet workload를 위한 PodDisruptionBudget을 생성한다. @@ -2282,32 +2252,19 @@ func BuildMongoDBPDB(mdb *mongodbv1alpha1.MongoDB) *policyv1.PodDisruptionBudget if pdbSpec == nil || !pdbSpec.Enabled { return nil } - pdb := &policyv1.PodDisruptionBudget{ - ObjectMeta: metav1.ObjectMeta{ - Name: mdb.Name + "-pdb", - Namespace: mdb.Namespace, - Labels: buildLabels(mdb.Name, "replicaset"), - }, - Spec: policyv1.PodDisruptionBudgetSpec{ - Selector: &metav1.LabelSelector{ - MatchLabels: buildLabels(mdb.Name, "replicaset"), - }, - }, - } - switch { - case pdbSpec.MinAvailable != nil: - pdb.Spec.MinAvailable = pdbSpec.MinAvailable - case pdbSpec.MaxUnavailable != nil: - pdb.Spec.MaxUnavailable = pdbSpec.MaxUnavailable - default: - minAvail := mdb.Spec.Members - 1 - if minAvail < 0 { - minAvail = 0 - } - v := intstr.FromInt(int(minAvail)) - pdb.Spec.MinAvailable = &v - } - return pdb + labels := buildLabels(mdb.Name, "replicaset") + // 빌드 골격은 keiailab-commons/pkg/pdb.Build 에 위임 (DefaultFloor=0 = mongo). + // mongo 는 MinAvailable-우선 = commons 기본과 동일 → 거동 보존. + return commonspdb.Build(commonspdb.Params{ + Name: mdb.Name + "-pdb", + Namespace: mdb.Namespace, + Labels: labels, + Selector: labels, + Replicas: mdb.Spec.Members, + DefaultFloor: 0, + MinAvailable: pdbSpec.MinAvailable, + MaxUnavailable: pdbSpec.MaxUnavailable, + }) } // BuildMongoDBNetworkPolicy는 MongoDB ReplicaSet pods에 대한 deny-by-default @@ -2359,23 +2316,16 @@ func convertAdditionalPeers(in []mongodbv1alpha1.NetworkPolicyPeer) []commonsnp. // pdbBaseSpec은 PodDisruptionBudgetSpec를 받아 기본 minAvailable=replicas-1을 // 적용한 K8s PDB spec을 만든다(MinAvailable/MaxUnavailable 미지정 시). func pdbBaseSpec(spec *mongodbv1alpha1.PodDisruptionBudgetSpec, replicas int32, selector map[string]string) policyv1.PodDisruptionBudgetSpec { - out := policyv1.PodDisruptionBudgetSpec{ - Selector: &metav1.LabelSelector{MatchLabels: selector}, - } - switch { - case spec.MinAvailable != nil: - out.MinAvailable = spec.MinAvailable - case spec.MaxUnavailable != nil: - out.MaxUnavailable = spec.MaxUnavailable - default: - minAvail := replicas - 1 - if minAvail < 0 { - minAvail = 0 - } - v := intstr.FromInt(int(minAvail)) - out.MinAvailable = &v - } - return out + // 빌드 골격(min/max 우선순위 + default-floor)은 keiailab-commons/pkg/pdb.Build + // 에 위임 (DefaultFloor=0 = mongo). BuildShardedPDBs 가 ObjectMeta 를 별도 + // 설정하므로 여기서는 Spec 만 반환한다. + return commonspdb.Build(commonspdb.Params{ + Selector: selector, + Replicas: replicas, + DefaultFloor: 0, + MinAvailable: spec.MinAvailable, + MaxUnavailable: spec.MaxUnavailable, + }).Spec } // BuildShardedPDBs는 sharded cluster의 cfg/shards/mongos 각 컴포넌트에 대한 @@ -2526,27 +2476,20 @@ func BuildConfigServerHPA(mdbsh *mongodbv1alpha1.MongoDBSharded) *autoscalingv2. func buildHPAForTarget(name, namespace string, labels map[string]string, kind, refName string, as *mongodbv1alpha1.AutoScalingSpec, ) *autoscalingv2.HorizontalPodAutoscaler { - min := as.MinReplicas - if min < 1 { - min = 1 - } - return &autoscalingv2.HorizontalPodAutoscaler{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - Labels: labels, - }, - Spec: autoscalingv2.HorizontalPodAutoscalerSpec{ - ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{ - APIVersion: "apps/v1", - Kind: kind, - Name: refName, - }, - MinReplicas: &min, - MaxReplicas: as.MaxReplicas, - Metrics: buildHPAMetrics(as.Metrics), - }, - } + // 빌드 골격(ScaleTargetRef apps/v1 + MinReplicas clamp)은 + // keiailab-commons/pkg/hpa.Build 에 위임 (MinFloor=1 = mongo). metric 조립은 + // buildHPAMetrics(cpu/memory/custom) 도메인 잔류. + return commonshpa.Build(commonshpa.Params{ + Name: name, + Namespace: namespace, + Labels: labels, + TargetKind: kind, + TargetName: refName, + MinReplicas: as.MinReplicas, + MaxReplicas: as.MaxReplicas, + MinFloor: 1, + Metrics: buildHPAMetrics(as.Metrics), + }) } // IsRSHPAActive는 RS HPA가 reconcile에서 활성 상태인지 검사한다(이중 가드). @@ -2590,44 +2533,16 @@ func IsShardScaleDeliberate(mdbsh *mongodbv1alpha1.MongoDBSharded) bool { func buildHPAMetrics(metrics []mongodbv1alpha1.AutoScalingMetric) []autoscalingv2.MetricSpec { if len(metrics) == 0 { // 아무 metric도 없으면 cpu 80% 기본값 — upstream chart 등 표준 기본. - v := int32(80) - return []autoscalingv2.MetricSpec{{ - Type: autoscalingv2.ResourceMetricSourceType, - Resource: &autoscalingv2.ResourceMetricSource{ - Name: corev1.ResourceCPU, - Target: autoscalingv2.MetricTarget{ - Type: autoscalingv2.UtilizationMetricType, - AverageUtilization: &v, - }, - }, - }} + return []autoscalingv2.MetricSpec{commonshpa.CPUUtilization(80)} } out := make([]autoscalingv2.MetricSpec, 0, len(metrics)) for _, m := range metrics { target := m.Target switch m.Type { case "cpu": - out = append(out, autoscalingv2.MetricSpec{ - Type: autoscalingv2.ResourceMetricSourceType, - Resource: &autoscalingv2.ResourceMetricSource{ - Name: corev1.ResourceCPU, - Target: autoscalingv2.MetricTarget{ - Type: autoscalingv2.UtilizationMetricType, - AverageUtilization: &target, - }, - }, - }) + out = append(out, commonshpa.CPUUtilization(target)) case "memory": - out = append(out, autoscalingv2.MetricSpec{ - Type: autoscalingv2.ResourceMetricSourceType, - Resource: &autoscalingv2.ResourceMetricSource{ - Name: corev1.ResourceMemory, - Target: autoscalingv2.MetricTarget{ - Type: autoscalingv2.UtilizationMetricType, - AverageUtilization: &target, - }, - }, - }) + out = append(out, commonshpa.MemoryUtilization(target)) case "custom": if m.CustomMetric == nil || m.CustomMetric.Name == "" { continue