diff --git a/internal/resources/builder.go b/internal/resources/builder.go index b8d95d9..8374209 100644 --- a/internal/resources/builder.go +++ b/internal/resources/builder.go @@ -24,7 +24,6 @@ import ( "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" @@ -89,112 +88,6 @@ const MongoTLSMountPath = "/etc/ssl/mongo" // mongod --tlsCertificateKeyFile 가 단일 PEM (cert + key) 를 요구. const MongoTLSPEMPath = "/etc/ssl/mongo-pem" -// BuildPEMMergeInitContainer 는 cert-manager Secret 의 tls.crt + tls.key 를 -// 단일 PEM file 로 합치는 init container 를 반환한다 (mongod 의 --tlsCertificateKeyFile -// 호환). PSA restricted 정합 (busybox + RunAsUser 999 + drop ALL + seccomp RuntimeDefault). -// -// Ownership 은 root chown 이 아니라 PodSecurityContext.FSGroup (=999, -// buildDefaultSecurityContext) 에 위임한다. fsGroup 이 emptyDir `/tls-pem` 을 -// gid 999 로 group-writable 하게 만들어 UID 999 init container 가 server.pem 을 -// 직접 write → file owner 999:999 → mongod (UID 999) read 가능. copy-keyfile init -// 과 동일 패턴 (buildKeyfileInitContainerSecurityContext). -// -// 회귀 주의: 과거 RunAsUser:0 + CHOWN cap 구현 (2026-05-16 chown race 오진) 은 -// PSA restricted enforce namespace 에서 pod 생성 거부 (CHOWN cap / runAsUser=0 / -// seccomp 누락) → StatefulSet rollout 영구 차단 (2026-05-30 KeiaiLab data ns 사고). -// fsGroup 기반이 정합 (라이브 검증: data ns restricted enforce + cfg/shard/mongos 3/3). -// -// Exported — caller (cfg/shard/mongos reconciler) 가 STS build 후 conditional append. -func BuildPEMMergeInitContainer() corev1.Container { - return corev1.Container{ - Name: "tls-pem-merge", - Image: keyfileInitImage, // busybox:1.37 const 단일화 정합 - Command: []string{ - "sh", "-c", - // 멱등(idempotent) — pod/init 재시작 시 emptyDir 의 기존 server.pem(0400)을 - // `> server.pem` 으로 덮어쓰면 owner write 권한 부재로 "Permission denied" 크래시. - // (실 사고 2026-06-22: node/kubelet 재기동 후 init 재실행 → tls-pem-merge - // CrashLoopBackOff → pod not-ready → 클러스터 회복 정체.) temp 파일에 쓴 뒤 - // atomic `mv -f` 로 교체 — rename 은 dir write(fsGroup=999)만 필요, 대상 파일 0400 - // 무관. .tmp 는 chmod 하지 않아 재실행 시 항상 overwrite 가능. - "cat /tls-input/tls.crt /tls-input/tls.key > /tls-pem/server.pem.tmp && " + - "mv -f /tls-pem/server.pem.tmp /tls-pem/server.pem && " + - "chmod 0400 /tls-pem/server.pem", - }, - SecurityContext: buildKeyfileInitContainerSecurityContext(), - VolumeMounts: []corev1.VolumeMount{ - {Name: "tls-server", MountPath: "/tls-input", ReadOnly: true}, - {Name: "tls-server-pem", MountPath: "/tls-pem"}, - }, - } -} - -// buildTLSPEMVolume 는 init container 와 mongod 가 공유하는 emptyDir. -func buildTLSPEMVolume() corev1.Volume { - return corev1.Volume{ - Name: "tls-server-pem", - VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, - } -} - -// buildTLSPEMMount 는 mongod container 의 mount. -func buildTLSPEMMount() corev1.VolumeMount { - return corev1.VolumeMount{ - Name: "tls-server-pem", - MountPath: MongoTLSPEMPath, - ReadOnly: true, - } -} - -// tlsArgs 는 mongod TLS 활성 args. preferTLS mode = plaintext + TLS 양쪽 listen -// → operator client (keyfile + plaintext) 와 외부 TLS client 양쪽 호환. cycle 19 -// Phase 3b 의 *진정 무중단* 정공. -// -// tlsAllowConnectionsWithoutCertificates: client cert 없는 plaintext connection 허용 -// (mTLS 비강제). cycle 19 valkey-operator 의 tls-auth-clients=yes 강제 패턴 회피. -// -// tlsAllowInvalidHostnames (v1.4.17 fix, cycle 19 last): mongos 가 cfg replica set 에 -// outbound TLS connect 시 hostname verification 을 우회. mongos --configdb 의 connection -// string 은 short hostname (keiailab-mongo-cfg-0:27017) 사용 — cert SAN 의 wildcard FQDN -// (*.keiailab-mongo-cfg-headless..svc.cluster.local) 와 직접 매치 안 됨 → TLS verify -// fail → sharding pool init 실패 → 27017 미 listen → kubelet liveness kill cascade. -// cluster-internal CA chain + preferTLS 환경에서 hostname 검증은 의미 적음 (CA 로 ID -// 검증 충분), short/long hostname mix 흡수 의무. -func tlsArgs(tls *mongodbv1alpha1.TLSSpec) []string { - mode := "preferTLS" - if tls != nil && tls.Mode != "" { - mode = tls.Mode - } - args := []string{ - "--tlsMode", mode, - "--tlsCertificateKeyFile", MongoTLSPEMPath + "/server.pem", - "--tlsCAFile", MongoTLSMountPath + "/ca.crt", - "--tlsAllowConnectionsWithoutCertificates", - } - if tls == nil || tls.AllowInvalidHostnames == nil || *tls.AllowInvalidHostnames { - args = append(args, "--tlsAllowInvalidHostnames") - } - return args -} - -// buildTLSServerVolume 은 cert-manager 가 발급한 server cert Secret (-tls) -// 을 mount 하는 Volume 을 반환한다. tlsEnabled=false 시 nil. -// -// defaultMode 0o400: mongo user read-only. mongod 의 키 파일 권한 검사 통과. -// 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 { - // 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 { - _, mount := commonsvolume.TLSSecretMount("tls-server", "", MongoTLSMountPath) - return mount -} - // buildLabels — keiailab-commons/pkg/labels 위임 (iteration 27). // 기존 4-key map (no version, no part-of) 동작 보존 — labels.Set.All() 의 optional // 필드 omit 동작 활용. @@ -420,79 +313,6 @@ func buildAdminBootstrapLifecycle() *corev1.Lifecycle { } } -// BuildMongoDBConfigMap creates a ConfigMap for MongoDB configuration. -// -// 포함 스크립트: -// - readiness-probe.sh: mongod이 ping에 응답하는지 확인. -// - bootstrap-admin.sh: pod 자체가 자기 mongod에 localhost connection으로 -// 첫 admin user를 생성. operator는 더 이상 pods/exec을 수행하지 않는다. -func BuildMongoDBConfigMap(mdb *mongodbv1alpha1.MongoDB) *corev1.ConfigMap { - return &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: mdb.Name + "-scripts", - Namespace: mdb.Namespace, - Labels: buildLabels(mdb.Name, "scripts"), - }, - Data: map[string]string{ - scriptReadiness: buildReadinessScript(mongoDBPort), - scriptBootstrap: buildAdminBootstrapScript(mongoDBPort), - scriptStepDown: buildStepDownScript(mongoDBPort), - }, - } -} - -// BuildCustomConfigMap generates a ConfigMap from spec.pod.customConfig.configInline. -// Returns nil if customConfig is nil or configInline is empty. -func BuildCustomConfigMap(name, namespace string, pod *mongodbv1alpha1.PodSpec) *corev1.ConfigMap { - if pod == nil || pod.CustomConfig == nil || pod.CustomConfig.ConfigInline == "" { - return nil - } - return &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: name + "-custom-config", - Namespace: namespace, - Labels: buildLabels(name, "custom-config"), - }, - Data: map[string]string{ - "mongod.conf": pod.CustomConfig.ConfigInline, - }, - } -} - -// BuildConfigServerScriptsConfigMap는 Config Server StatefulSet에 마운트되는 -// scripts ConfigMap을 만든다. port=27019. -func BuildConfigServerScriptsConfigMap(mdbsh *mongodbv1alpha1.MongoDBSharded) *corev1.ConfigMap { - return &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: mdbsh.Name + "-cfg-scripts", - Namespace: mdbsh.Namespace, - Labels: buildLabels(mdbsh.Name, "configsvr"), - }, - Data: map[string]string{ - scriptReadiness: buildReadinessScript(27019), - scriptBootstrap: buildAdminBootstrapScript(27019), - scriptStepDown: buildStepDownScript(27019), - }, - } -} - -// BuildShardScriptsConfigMap는 Shard StatefulSet에 마운트되는 scripts ConfigMap을 -// 만든다. port=27018, name={instance}-shard-{i}-scripts. -func BuildShardScriptsConfigMap(mdbsh *mongodbv1alpha1.MongoDBSharded, shardIndex int32) *corev1.ConfigMap { - return &corev1.ConfigMap{ - ObjectMeta: metav1.ObjectMeta{ - Name: fmt.Sprintf("%s-shard-%d-scripts", mdbsh.Name, shardIndex), - Namespace: mdbsh.Namespace, - Labels: buildLabels(mdbsh.Name, fmt.Sprintf("shard-%d", shardIndex)), - }, - Data: map[string]string{ - scriptReadiness: buildReadinessScript(27018), - scriptBootstrap: buildAdminBootstrapScript(27018), - scriptStepDown: buildStepDownScript(27018), - }, - } -} - // BuildHeadlessService creates a headless service for StatefulSet. // 조립은 keiailab-commons/pkg/service.Build 에 위임 (Headless → ClusterIP None + // PublishNotReadyAddresses). 포트/라벨은 mongo 도메인 잔류. diff --git a/internal/resources/builder_configmap.go b/internal/resources/builder_configmap.go new file mode 100644 index 0000000..d1cb063 --- /dev/null +++ b/internal/resources/builder_configmap.go @@ -0,0 +1,89 @@ +/* +Copyright 2024 Keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package resources + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + mongodbv1alpha1 "github.com/keiailab/mongodb-operator/api/v1alpha1" +) + +// BuildMongoDBConfigMap creates a ConfigMap for MongoDB configuration. +// +// 포함 스크립트: +// - readiness-probe.sh: mongod이 ping에 응답하는지 확인. +// - bootstrap-admin.sh: pod 자체가 자기 mongod에 localhost connection으로 +// 첫 admin user를 생성. operator는 더 이상 pods/exec을 수행하지 않는다. +func BuildMongoDBConfigMap(mdb *mongodbv1alpha1.MongoDB) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: mdb.Name + "-scripts", + Namespace: mdb.Namespace, + Labels: buildLabels(mdb.Name, "scripts"), + }, + Data: map[string]string{ + scriptReadiness: buildReadinessScript(mongoDBPort), + scriptBootstrap: buildAdminBootstrapScript(mongoDBPort), + scriptStepDown: buildStepDownScript(mongoDBPort), + }, + } +} + +// BuildCustomConfigMap generates a ConfigMap from spec.pod.customConfig.configInline. +// Returns nil if customConfig is nil or configInline is empty. +func BuildCustomConfigMap(name, namespace string, pod *mongodbv1alpha1.PodSpec) *corev1.ConfigMap { + if pod == nil || pod.CustomConfig == nil || pod.CustomConfig.ConfigInline == "" { + return nil + } + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name + "-custom-config", + Namespace: namespace, + Labels: buildLabels(name, "custom-config"), + }, + Data: map[string]string{ + "mongod.conf": pod.CustomConfig.ConfigInline, + }, + } +} + +// BuildConfigServerScriptsConfigMap는 Config Server StatefulSet에 마운트되는 +// scripts ConfigMap을 만든다. port=27019. +func BuildConfigServerScriptsConfigMap(mdbsh *mongodbv1alpha1.MongoDBSharded) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: mdbsh.Name + "-cfg-scripts", + Namespace: mdbsh.Namespace, + Labels: buildLabels(mdbsh.Name, "configsvr"), + }, + Data: map[string]string{ + scriptReadiness: buildReadinessScript(27019), + scriptBootstrap: buildAdminBootstrapScript(27019), + scriptStepDown: buildStepDownScript(27019), + }, + } +} + +// BuildShardScriptsConfigMap는 Shard StatefulSet에 마운트되는 scripts ConfigMap을 +// 만든다. port=27018, name={instance}-shard-{i}-scripts. +func BuildShardScriptsConfigMap(mdbsh *mongodbv1alpha1.MongoDBSharded, shardIndex int32) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("%s-shard-%d-scripts", mdbsh.Name, shardIndex), + Namespace: mdbsh.Namespace, + Labels: buildLabels(mdbsh.Name, fmt.Sprintf("shard-%d", shardIndex)), + }, + Data: map[string]string{ + scriptReadiness: buildReadinessScript(27018), + scriptBootstrap: buildAdminBootstrapScript(27018), + scriptStepDown: buildStepDownScript(27018), + }, + } +} diff --git a/internal/resources/builder_tls.go b/internal/resources/builder_tls.go new file mode 100644 index 0000000..e2a2324 --- /dev/null +++ b/internal/resources/builder_tls.go @@ -0,0 +1,127 @@ +/* +Copyright 2024 Keiailab. + +Licensed under the MIT License. See the LICENSE file for details. +*/ + +package resources + +import ( + corev1 "k8s.io/api/core/v1" + + commonsvolume "github.com/keiailab/keiailab-commons/pkg/volume" + + mongodbv1alpha1 "github.com/keiailab/mongodb-operator/api/v1alpha1" +) + +// TLS 볼륨 이름 (goconst SSOT — PEM merge init container / server cert mount 공용). +const ( + tlsServerVolume = "tls-server" + tlsServerPEMVolume = "tls-server-pem" +) + +// BuildPEMMergeInitContainer 는 cert-manager Secret 의 tls.crt + tls.key 를 +// 단일 PEM file 로 합치는 init container 를 반환한다 (mongod 의 --tlsCertificateKeyFile +// 호환). PSA restricted 정합 (busybox + RunAsUser 999 + drop ALL + seccomp RuntimeDefault). +// +// Ownership 은 root chown 이 아니라 PodSecurityContext.FSGroup (=999, +// buildDefaultSecurityContext) 에 위임한다. fsGroup 이 emptyDir `/tls-pem` 을 +// gid 999 로 group-writable 하게 만들어 UID 999 init container 가 server.pem 을 +// 직접 write → file owner 999:999 → mongod (UID 999) read 가능. copy-keyfile init +// 과 동일 패턴 (buildKeyfileInitContainerSecurityContext). +// +// 회귀 주의: 과거 RunAsUser:0 + CHOWN cap 구현 (2026-05-16 chown race 오진) 은 +// PSA restricted enforce namespace 에서 pod 생성 거부 (CHOWN cap / runAsUser=0 / +// seccomp 누락) → StatefulSet rollout 영구 차단 (2026-05-30 KeiaiLab data ns 사고). +// fsGroup 기반이 정합 (라이브 검증: data ns restricted enforce + cfg/shard/mongos 3/3). +// +// Exported — caller (cfg/shard/mongos reconciler) 가 STS build 후 conditional append. +func BuildPEMMergeInitContainer() corev1.Container { + return corev1.Container{ + Name: "tls-pem-merge", + Image: keyfileInitImage, // busybox:1.37 const 단일화 정합 + Command: []string{ + "sh", "-c", + // 멱등(idempotent) — pod/init 재시작 시 emptyDir 의 기존 server.pem(0400)을 + // `> server.pem` 으로 덮어쓰면 owner write 권한 부재로 "Permission denied" 크래시. + // (실 사고 2026-06-22: node/kubelet 재기동 후 init 재실행 → tls-pem-merge + // CrashLoopBackOff → pod not-ready → 클러스터 회복 정체.) temp 파일에 쓴 뒤 + // atomic `mv -f` 로 교체 — rename 은 dir write(fsGroup=999)만 필요, 대상 파일 0400 + // 무관. .tmp 는 chmod 하지 않아 재실행 시 항상 overwrite 가능. + "cat /tls-input/tls.crt /tls-input/tls.key > /tls-pem/server.pem.tmp && " + + "mv -f /tls-pem/server.pem.tmp /tls-pem/server.pem && " + + "chmod 0400 /tls-pem/server.pem", + }, + SecurityContext: buildKeyfileInitContainerSecurityContext(), + VolumeMounts: []corev1.VolumeMount{ + {Name: tlsServerVolume, MountPath: "/tls-input", ReadOnly: true}, + {Name: tlsServerPEMVolume, MountPath: "/tls-pem"}, + }, + } +} + +// buildTLSPEMVolume 는 init container 와 mongod 가 공유하는 emptyDir. +func buildTLSPEMVolume() corev1.Volume { + return corev1.Volume{ + Name: tlsServerPEMVolume, + VolumeSource: corev1.VolumeSource{EmptyDir: &corev1.EmptyDirVolumeSource{}}, + } +} + +// buildTLSPEMMount 는 mongod container 의 mount. +func buildTLSPEMMount() corev1.VolumeMount { + return corev1.VolumeMount{ + Name: tlsServerPEMVolume, + MountPath: MongoTLSPEMPath, + ReadOnly: true, + } +} + +// tlsArgs 는 mongod TLS 활성 args. preferTLS mode = plaintext + TLS 양쪽 listen +// → operator client (keyfile + plaintext) 와 외부 TLS client 양쪽 호환. cycle 19 +// Phase 3b 의 *진정 무중단* 정공. +// +// tlsAllowConnectionsWithoutCertificates: client cert 없는 plaintext connection 허용 +// (mTLS 비강제). cycle 19 valkey-operator 의 tls-auth-clients=yes 강제 패턴 회피. +// +// tlsAllowInvalidHostnames (v1.4.17 fix, cycle 19 last): mongos 가 cfg replica set 에 +// outbound TLS connect 시 hostname verification 을 우회. mongos --configdb 의 connection +// string 은 short hostname (keiailab-mongo-cfg-0:27017) 사용 — cert SAN 의 wildcard FQDN +// (*.keiailab-mongo-cfg-headless..svc.cluster.local) 와 직접 매치 안 됨 → TLS verify +// fail → sharding pool init 실패 → 27017 미 listen → kubelet liveness kill cascade. +// cluster-internal CA chain + preferTLS 환경에서 hostname 검증은 의미 적음 (CA 로 ID +// 검증 충분), short/long hostname mix 흡수 의무. +func tlsArgs(tls *mongodbv1alpha1.TLSSpec) []string { + mode := "preferTLS" + if tls != nil && tls.Mode != "" { + mode = tls.Mode + } + args := []string{ + "--tlsMode", mode, + "--tlsCertificateKeyFile", MongoTLSPEMPath + "/server.pem", + "--tlsCAFile", MongoTLSMountPath + "/ca.crt", + "--tlsAllowConnectionsWithoutCertificates", + } + if tls == nil || tls.AllowInvalidHostnames == nil || *tls.AllowInvalidHostnames { + args = append(args, "--tlsAllowInvalidHostnames") + } + return args +} + +// buildTLSServerVolume 은 cert-manager 가 발급한 server cert Secret (-tls) +// 을 mount 하는 Volume 을 반환한다. tlsEnabled=false 시 nil. +// +// defaultMode 0o400: mongo user read-only. mongod 의 키 파일 권한 검사 통과. +// 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 { + // 0o400/readonly cert 불변식은 keiailab-commons/pkg/volume.TLSSecretMount 에 위임. + vol, _ := commonsvolume.TLSSecretMount(tlsServerVolume, secretName, MongoTLSMountPath) + return vol +} + +// buildTLSServerMount 는 tls-server Volume 의 mountPath 를 반환한다. +func buildTLSServerMount() corev1.VolumeMount { + _, mount := commonsvolume.TLSSecretMount(tlsServerVolume, "", MongoTLSMountPath) + return mount +}