Skip to content

Commit 2c36dca

Browse files
myJamonghorsegegunesmayankshah1607
authored
K8SPSMDB-1606: Add CA key persistence for manual TLS to support safe cert re-signing on SAN changes (#2277)
* Add CA key persistence for manual TLS to enable safe cert re-signing on SAN changes * Add unit tests for manual TLS CA persistence and cert re-signing * Add e2e test for split-horizon with manual TLS (without cert-manager) * remove version compare condition * Rename GetSANsFromCert to GetDNSNamesFromCert The function returns only cert.DNSNames, not all SAN types. Renaming to match actual behavior per PR review feedback. * log SAN parse error in needsManualSSLUpdate --------- Co-authored-by: Viacheslav Sarzhan <slava.sarzhan@percona.com> Co-authored-by: Ege Güneş <ege.gunes@percona.com> Co-authored-by: Mayank Shah <mayank.shah@percona.com>
1 parent 4418773 commit 2c36dca

11 files changed

Lines changed: 767 additions & 76 deletions

File tree

e2e-tests/run-pr.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ service-per-pod
7878
serviceless-external-nodes
7979
smart-update
8080
split-horizon
81+
split-horizon-manual-tls
8182
stable-resource-version
8283
storage
8384
tls-issue-cert-manager

e2e-tests/run-release.csv

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ service-per-pod
7878
serviceless-external-nodes
7979
smart-update
8080
split-horizon
81+
split-horizon-manual-tls
8182
stable-resource-version
8283
storage
8384
tls-issue-cert-manager
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[
2+
{
3+
"external" : "some-name-rs0-0.clouddemo.xyz:27017"
4+
},
5+
{
6+
"external" : "some-name-rs0-1.clouddemo.xyz:27017"
7+
},
8+
{
9+
"external" : "some-name-rs0-2.clouddemo.xyz:27017"
10+
}
11+
]
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
[
2+
{
3+
"external" : "some-name-rs0-0.clouddemo.xyz:27017"
4+
},
5+
{
6+
"external" : "some-name-rs0-1.clouddemo.xyz:27017"
7+
},
8+
{
9+
"external" : "some-name-rs0-2.clouddemo.xyz:27017"
10+
},
11+
{
12+
"external" : "some-name-rs0-3.clouddemo.xyz:27017"
13+
},
14+
{
15+
"external" : "some-name-rs0-4.clouddemo.xyz:27017"
16+
}
17+
]
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
apiVersion: psmdb.percona.com/v1
2+
kind: PerconaServerMongoDB
3+
metadata:
4+
name: some-name
5+
spec:
6+
#platform: openshift
7+
image:
8+
imagePullPolicy: Always
9+
backup:
10+
enabled: false
11+
image: perconalab/percona-server-mongodb-operator:main-backup
12+
replsets:
13+
- name: rs0
14+
size: 3
15+
expose:
16+
enabled: true
17+
type: ClusterIP
18+
splitHorizons:
19+
some-name-rs0-0:
20+
external: some-name-rs0-0.clouddemo.xyz
21+
some-name-rs0-1:
22+
external: some-name-rs0-1.clouddemo.xyz
23+
some-name-rs0-2:
24+
external: some-name-rs0-2.clouddemo.xyz
25+
affinity:
26+
antiAffinityTopologyKey: none
27+
resources:
28+
limits:
29+
cpu: 500m
30+
memory: 0.5G
31+
requests:
32+
cpu: 100m
33+
memory: 0.1G
34+
volumeSpec:
35+
persistentVolumeClaim:
36+
resources:
37+
requests:
38+
storage: 1Gi
39+
secrets:
40+
users: some-users
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
apiVersion: psmdb.percona.com/v1
2+
kind: PerconaServerMongoDB
3+
metadata:
4+
name: some-name
5+
spec:
6+
#platform: openshift
7+
image:
8+
imagePullPolicy: Always
9+
backup:
10+
enabled: false
11+
image: perconalab/percona-server-mongodb-operator:main-backup
12+
replsets:
13+
- name: rs0
14+
size: 3
15+
expose:
16+
enabled: true
17+
type: ClusterIP
18+
splitHorizons:
19+
some-name-rs0-0:
20+
external: some-name-rs0-0.clouddemo.xyz
21+
some-name-rs0-1:
22+
external: some-name-rs0-1.clouddemo.xyz
23+
some-name-rs0-2:
24+
external: some-name-rs0-2.clouddemo.xyz
25+
some-name-rs0-3:
26+
external: some-name-rs0-3.clouddemo.xyz
27+
some-name-rs0-4:
28+
external: some-name-rs0-4.clouddemo.xyz
29+
affinity:
30+
antiAffinityTopologyKey: none
31+
resources:
32+
limits:
33+
cpu: 500m
34+
memory: 0.5G
35+
requests:
36+
cpu: 100m
37+
memory: 0.1G
38+
volumeSpec:
39+
persistentVolumeClaim:
40+
resources:
41+
requests:
42+
storage: 1Gi
43+
secrets:
44+
users: some-users
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
apiVersion: psmdb.percona.com/v1
2+
kind: PerconaServerMongoDB
3+
metadata:
4+
name: some-name
5+
spec:
6+
#platform: openshift
7+
image:
8+
imagePullPolicy: Always
9+
backup:
10+
enabled: false
11+
image: perconalab/percona-server-mongodb-operator:main-backup
12+
replsets:
13+
- name: rs0
14+
size: 3
15+
expose:
16+
enabled: true
17+
type: ClusterIP
18+
affinity:
19+
antiAffinityTopologyKey: none
20+
resources:
21+
limits:
22+
cpu: 500m
23+
memory: 0.5G
24+
requests:
25+
cpu: 100m
26+
memory: 0.1G
27+
volumeSpec:
28+
persistentVolumeClaim:
29+
resources:
30+
requests:
31+
storage: 1Gi
32+
secrets:
33+
users: some-users
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
#!/bin/bash
2+
3+
set -o errexit
4+
set -o xtrace
5+
6+
test_dir=$(realpath "$(dirname "$0")")
7+
. "${test_dir}"/../functions
8+
9+
verify_cert_san() {
10+
local secret_name=$1
11+
local expected_san=$2
12+
13+
local san_list
14+
san_list=$(kubectl_bin get secret "${secret_name}" -o jsonpath='{.data.tls\.crt}' \
15+
| base64 -d \
16+
| openssl x509 -text -noout 2>/dev/null \
17+
| grep "DNS:" \
18+
| tr ',' '\n' \
19+
| sed 's/.*DNS://g' \
20+
| xargs)
21+
22+
if echo "${san_list}" | grep -q "${expected_san}"; then
23+
echo "OK: SAN '${expected_san}' found in secret '${secret_name}'"
24+
else
25+
echo "FAIL: SAN '${expected_san}' NOT found in secret '${secret_name}'"
26+
echo " SANs found: ${san_list}"
27+
return 1
28+
fi
29+
}
30+
31+
verify_ca_secret_exists() {
32+
local secret_name=$1
33+
34+
if kubectl_bin get secret "${secret_name}" -o jsonpath='{.data.ca\.crt}' >/dev/null 2>&1 &&
35+
kubectl_bin get secret "${secret_name}" -o jsonpath='{.data.ca\.key}' >/dev/null 2>&1; then
36+
echo "OK: CA secret '${secret_name}' exists with ca.crt and ca.key"
37+
else
38+
echo "FAIL: CA secret '${secret_name}' does not have ca.crt and ca.key"
39+
return 1
40+
fi
41+
}
42+
43+
verify_cert_signed_by_ca() {
44+
local tls_secret_name=$1
45+
local ca_secret_name=$2
46+
47+
local ca_crt
48+
ca_crt=$(kubectl_bin get secret "${ca_secret_name}" -o jsonpath='{.data.ca\.crt}' | base64 -d)
49+
local tls_crt
50+
tls_crt=$(kubectl_bin get secret "${tls_secret_name}" -o jsonpath='{.data.tls\.crt}' | base64 -d)
51+
52+
echo "${ca_crt}" >"${tmp_dir}/ca.crt"
53+
echo "${tls_crt}" >"${tmp_dir}/tls.crt"
54+
55+
if openssl verify -CAfile "${tmp_dir}/ca.crt" "${tmp_dir}/tls.crt" >/dev/null 2>&1; then
56+
echo "OK: '${tls_secret_name}' is signed by CA in '${ca_secret_name}'"
57+
else
58+
echo "FAIL: '${tls_secret_name}' is NOT signed by CA in '${ca_secret_name}'"
59+
return 1
60+
fi
61+
}
62+
63+
save_cert_hash() {
64+
local secret_name=$1
65+
local output_var=$2
66+
67+
kubectl_bin get secret "${secret_name}" -o jsonpath='{.data.tls\.crt}' | base64 -d | openssl x509 -fingerprint -noout 2>/dev/null
68+
}
69+
70+
configure_client_hostAliases() {
71+
local hostAliasesJson='[]'
72+
73+
for svc in $(kubectl get svc | awk '{print $3 "|" $1}' | grep -E '^[0-9].*'); do
74+
hostname=$(echo "${svc}" | awk -F '|' '{print $2}')
75+
ip=$(echo "${svc}" | awk -F '|' '{print $1}')
76+
hostAlias="{\"ip\": \"${ip}\", \"hostnames\": [\"${hostname}.clouddemo.xyz\"]}"
77+
hostAliasesJson=$(echo "$hostAliasesJson" | jq --argjson newAlias "$hostAlias" '. += [$newAlias]')
78+
done
79+
80+
kubectl_bin patch deployment psmdb-client --type='json' -p="[{'op': 'replace', 'path': '/spec/replicas', 'value': 0}]"
81+
82+
wait_for_delete "pod/$(kubectl_bin get pods --selector=name=psmdb-client -o 'jsonpath={.items[].metadata.name}')"
83+
84+
kubectl_bin patch deployment psmdb-client --type='json' -p="[{'op': 'replace', 'path': '/spec/template/spec/hostAliases', 'value': $hostAliasesJson}, {'op': 'replace', 'path': '/spec/replicas', 'value': 1}]"
85+
86+
wait_pod "$(kubectl_bin get pods --selector=name=psmdb-client -o 'jsonpath={.items[].metadata.name}')"
87+
}
88+
89+
main() {
90+
create_infra "${namespace}"
91+
destroy_cert_manager || true # Ensure we test manual TLS, not cert-manager
92+
93+
cluster="some-name"
94+
kubectl_bin apply \
95+
-f "${conf_dir}"/secrets.yml \
96+
-f "${conf_dir}"/client_with_tls.yml
97+
98+
desc 'deploy cluster with 3 split horizons (manual TLS)'
99+
apply_cluster "${test_dir}"/conf/${cluster}-3horizons.yml
100+
wait_for_running "${cluster}-rs0" 3
101+
wait_cluster_consistency ${cluster}
102+
103+
desc 'verify CA secret exists with ca.crt and ca.key'
104+
verify_ca_secret_exists "${cluster}-ca-cert"
105+
106+
desc 'verify TLS secrets are signed by the CA'
107+
verify_cert_signed_by_ca "${cluster}-ssl" "${cluster}-ca-cert"
108+
verify_cert_signed_by_ca "${cluster}-ssl-internal" "${cluster}-ca-cert"
109+
110+
desc 'verify split-horizon DNS names are in certificate SANs'
111+
verify_cert_san "${cluster}-ssl" "some-name-rs0-0.clouddemo.xyz"
112+
verify_cert_san "${cluster}-ssl" "some-name-rs0-1.clouddemo.xyz"
113+
verify_cert_san "${cluster}-ssl" "some-name-rs0-2.clouddemo.xyz"
114+
verify_cert_san "${cluster}-ssl-internal" "some-name-rs0-0.clouddemo.xyz"
115+
116+
desc 'save certificate fingerprint before horizon update'
117+
cert_hash_before=$(save_cert_hash "${cluster}-ssl")
118+
119+
configure_client_hostAliases
120+
121+
sleep 10 # give some time for client pod to be ready
122+
123+
desc 'verify horizons via rs.conf()'
124+
run_mongo_tls "rs.conf().members.map(function(member) { return member.horizons }).sort((a, b) => a.external.localeCompare(b.external))" \
125+
"clusterAdmin:clusterAdmin123456@some-name-rs0-0.clouddemo.xyz,some-name-rs0-1.clouddemo.xyz,some-name-rs0-2.clouddemo.xyz" \
126+
mongodb "" "--quiet" | grep -E -v 'I NETWORK|W NETWORK|Error saving history file|Percona Server for MongoDB|connecting to:|Unable to reach primary for set|Implicit session:|versions do not match|Error saving history file:|does not match the remote host name' >"${tmp_dir}"/horizons-3.json
127+
diff "$test_dir"/compare/horizons-3.json "$tmp_dir"/horizons-3.json
128+
129+
desc 'update to 5 horizons (triggers SAN change and cert re-signing)'
130+
apply_cluster "${test_dir}"/conf/${cluster}-5horizons.yml
131+
wait_for_running "${cluster}-rs0" 3
132+
wait_cluster_consistency ${cluster}
133+
134+
desc 'verify new horizon DNS names are in certificate SANs after re-signing'
135+
verify_cert_san "${cluster}-ssl" "some-name-rs0-3.clouddemo.xyz"
136+
verify_cert_san "${cluster}-ssl" "some-name-rs0-4.clouddemo.xyz"
137+
138+
desc 'verify certificate was re-signed (fingerprint changed)'
139+
cert_hash_after=$(save_cert_hash "${cluster}-ssl")
140+
if [ "${cert_hash_before}" = "${cert_hash_after}" ]; then
141+
echo "FAIL: certificate was not re-signed after horizon update"
142+
exit 1
143+
fi
144+
echo "OK: certificate was re-signed after horizon update"
145+
146+
desc 'verify TLS secrets are still signed by the SAME CA'
147+
verify_cert_signed_by_ca "${cluster}-ssl" "${cluster}-ca-cert"
148+
verify_cert_signed_by_ca "${cluster}-ssl-internal" "${cluster}-ca-cert"
149+
150+
desc 'scale up to 5 members'
151+
kubectl_bin patch psmdb ${cluster} \
152+
--type='json' \
153+
-p='[{"op": "replace", "path": "/spec/replsets/0/size", "value": 5}]'
154+
wait_for_running "${cluster}-rs0" 5
155+
wait_cluster_consistency ${cluster}
156+
157+
desc 'verify horizons after scale up'
158+
run_mongo_tls "rs.conf().members.map(function(member) { return member.horizons }).sort((a, b) => a.external.localeCompare(b.external))" \
159+
"clusterAdmin:clusterAdmin123456@some-name-rs0-0.clouddemo.xyz,some-name-rs0-1.clouddemo.xyz,some-name-rs0-2.clouddemo.xyz" \
160+
mongodb "" "--quiet" | grep -E -v 'I NETWORK|W NETWORK|Error saving history file|Percona Server for MongoDB|connecting to:|Unable to reach primary for set|Implicit session:|versions do not match|Error saving history file:|does not match the remote host name' >"${tmp_dir}"/horizons-5.json
161+
diff "$test_dir"/compare/horizons-5.json "$tmp_dir"/horizons-5.json
162+
163+
desc 'scale down to 3 members'
164+
kubectl_bin patch psmdb ${cluster} \
165+
--type='json' \
166+
-p='[{"op": "replace", "path": "/spec/replsets/0/size", "value": 3}]'
167+
wait_for_running "${cluster}-rs0" 3
168+
wait_cluster_consistency ${cluster}
169+
170+
desc 'verify horizons after scale down'
171+
run_mongo_tls "rs.conf().members.map(function(member) { return member.horizons }).sort((a, b) => a.external.localeCompare(b.external))" \
172+
"clusterAdmin:clusterAdmin123456@some-name-rs0-0.clouddemo.xyz,some-name-rs0-1.clouddemo.xyz,some-name-rs0-2.clouddemo.xyz" \
173+
mongodb "" "--quiet" | grep -E -v 'I NETWORK|W NETWORK|Error saving history file|Percona Server for MongoDB|connecting to:|Unable to reach primary for set|Implicit session:|versions do not match|Error saving history file:|does not match the remote host name' >"${tmp_dir}"/horizons.json
174+
diff "$test_dir"/compare/horizons-3.json "$tmp_dir"/horizons.json
175+
176+
desc 'remove horizon configuration'
177+
apply_cluster "${test_dir}"/conf/${cluster}.yml
178+
wait_for_running "${cluster}-rs0" 3
179+
wait_cluster_consistency ${cluster}
180+
181+
destroy "${namespace}"
182+
183+
desc 'test passed'
184+
}
185+
186+
main

0 commit comments

Comments
 (0)