Skip to content

Commit 2e0df9b

Browse files
committed
coco: add imperative job to configure x509pop
Add automated configuration for SPIRE Server x509pop NodeAttestor plugin required for CoCo peer-pods attestation. CoCo peer-pods run on untrusted cloud infrastructure. Using k8s_psat would require trusting the cloud provider's cluster. Instead, pods perform hardware TEE attestation to KBS to obtain x509 certificates as cryptographic proof of running in genuine confidential hardware, then use x509pop to register with SPIRE. The Red Hat SPIRE Operator's SpireServer CRD does not expose x509pop configuration, requiring a ConfigMap patch via this imperative job. Signed-off-by: Beraldo Leal <bleal@redhat.com>
1 parent 68b27ae commit 2e0df9b

4 files changed

Lines changed: 477 additions & 0 deletions

File tree

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
---
2+
# Configure SPIRE Server to support x509pop node attestation for CoCo pods
3+
# The Red Hat SPIRE Operator's SpireServer CRD does not expose x509pop plugin configuration
4+
# This job patches the operator-generated ConfigMap and StatefulSet to add x509pop support
5+
#
6+
# IMPORTANT: The operator must have CREATE_ONLY_MODE=true env var set (via subscription
7+
# config) to prevent it from reverting our manual patches. Without this, the operator
8+
# continuously reconciles and overwrites x509pop changes.
9+
# Note: In v0.2.0 (tech-preview) this was done via a CR annotation
10+
# (ztwim.openshift.io/create-only). In v1.0.0 (GA) it changed to the env var.
11+
12+
- name: Configure SPIRE Server for x509pop attestation
13+
become: false
14+
connection: local
15+
hosts: localhost
16+
gather_facts: false
17+
vars:
18+
spire_namespace: "zero-trust-workload-identity-manager"
19+
configmap_name: "spire-server"
20+
statefulset_name: "spire-server"
21+
ca_configmap_name: "spire-x509pop-ca"
22+
ca_mount_path: "/run/spire/x509pop-ca"
23+
tasks:
24+
- name: Get ZeroTrustWorkloadIdentityManager CR to determine expected cluster name
25+
kubernetes.core.k8s_info:
26+
api_version: operator.openshift.io/v1alpha1
27+
kind: ZeroTrustWorkloadIdentityManager
28+
name: cluster
29+
register: ztwim_cr
30+
retries: 30
31+
delay: 10
32+
until: ztwim_cr.resources | length > 0
33+
34+
- name: Extract expected cluster name from ZTWIM CR
35+
ansible.builtin.set_fact:
36+
expected_cluster_name: "{{ ztwim_cr.resources[0].spec.clusterName }}"
37+
38+
- name: Display expected cluster name
39+
ansible.builtin.debug:
40+
msg: "Expected cluster name from ZTWIM CR: {{ expected_cluster_name }}"
41+
42+
- name: Wait for SPIRE Server ConfigMap with correct cluster name
43+
kubernetes.core.k8s_info:
44+
kind: ConfigMap
45+
namespace: "{{ spire_namespace }}"
46+
name: "{{ configmap_name }}"
47+
register: spire_configmap
48+
retries: 60
49+
delay: 5
50+
until: >
51+
spire_configmap.resources | length > 0 and
52+
(spire_configmap.resources[0].data['server.conf'] | from_json).plugins.NodeAttestor[0].k8s_psat.plugin_data.clusters[0][expected_cluster_name] is defined
53+
54+
- name: ConfigMap has correct cluster name
55+
ansible.builtin.debug:
56+
msg: "ConfigMap verified with correct cluster name: {{ expected_cluster_name }}"
57+
58+
- name: Get current SPIRE Server configuration
59+
kubernetes.core.k8s_info:
60+
kind: ConfigMap
61+
namespace: "{{ spire_namespace }}"
62+
name: "{{ configmap_name }}"
63+
register: spire_config
64+
65+
- name: Parse server configuration
66+
ansible.builtin.set_fact:
67+
server_conf: "{{ spire_config.resources[0].data['server.conf'] | from_json }}"
68+
69+
- name: Check if x509pop already configured
70+
ansible.builtin.set_fact:
71+
x509pop_exists: "{{ server_conf.plugins.NodeAttestor | selectattr('x509pop', 'defined') | list | length > 0 }}"
72+
73+
- name: Add x509pop NodeAttestor plugin
74+
kubernetes.core.k8s:
75+
state: present
76+
definition:
77+
apiVersion: v1
78+
kind: ConfigMap
79+
metadata:
80+
name: "{{ configmap_name }}"
81+
namespace: "{{ spire_namespace }}"
82+
data:
83+
server.conf: "{{ server_conf | combine({'plugins': {'NodeAttestor': server_conf.plugins.NodeAttestor + [{'x509pop': {'plugin_data': {'ca_bundle_path': '/run/spire/x509pop-ca/ca-bundle.pem'}}}]}}, recursive=True) | to_json }}"
84+
when: not x509pop_exists
85+
86+
- name: Wait for SPIRE Server StatefulSet to exist
87+
kubernetes.core.k8s_info:
88+
kind: StatefulSet
89+
namespace: "{{ spire_namespace }}"
90+
name: "{{ statefulset_name }}"
91+
register: spire_statefulset
92+
retries: 30
93+
delay: 10
94+
until: spire_statefulset.resources | length > 0
95+
96+
- name: Check if CA volume already mounted
97+
ansible.builtin.set_fact:
98+
ca_volume_exists: "{{ spire_statefulset.resources[0].spec.template.spec.volumes | selectattr('name', 'equalto', 'x509pop-ca') | list | length > 0 }}"
99+
100+
- name: Add CA volume to SPIRE Server StatefulSet
101+
kubernetes.core.k8s:
102+
state: patched
103+
kind: StatefulSet
104+
namespace: "{{ spire_namespace }}"
105+
name: "{{ statefulset_name }}"
106+
definition:
107+
spec:
108+
template:
109+
spec:
110+
volumes:
111+
- name: x509pop-ca
112+
configMap:
113+
name: "{{ ca_configmap_name }}"
114+
containers:
115+
- name: spire-server
116+
volumeMounts:
117+
- name: x509pop-ca
118+
mountPath: "{{ ca_mount_path }}"
119+
readOnly: true
120+
when: not ca_volume_exists
121+
122+
- name: Restart SPIRE Server to apply configuration
123+
kubernetes.core.k8s:
124+
state: absent
125+
kind: Pod
126+
namespace: "{{ spire_namespace }}"
127+
label_selectors:
128+
- app.kubernetes.io/name=server
129+
when: (not x509pop_exists) or (not ca_volume_exists)
130+
131+
- name: Configuration status
132+
ansible.builtin.debug:
133+
msg: "{{ 'x509pop already configured' if (x509pop_exists and ca_volume_exists) else 'x509pop NodeAttestor plugin and CA volume mount configured successfully' }}"
134+
135+
- name: Final status
136+
ansible.builtin.debug:
137+
msg: "x509pop configuration complete. CREATE_ONLY_MODE env var on the operator prevents reverts."

ansible/generate-certificate.yaml

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
---
2+
# Generic certificate generation task
3+
# Can generate both CA certificates and agent certificates
4+
# Parameters:
5+
# cert_name: Name for the certificate (e.g., "x509pop-ca", "qtodo-agent")
6+
# cert_type: "ca" or "agent"
7+
# namespace: Kubernetes namespace to store certificate
8+
# output_configmap: Create ConfigMap with cert (true/false)
9+
# output_secret: Create Secret with key (true/false)
10+
# cert_dir: Temporary directory for cert generation
11+
# For agent certs only:
12+
# ca_cert_path: Path to CA certificate file
13+
# ca_key_path: Path to CA private key file
14+
# Certificate details:
15+
# common_name: Certificate CN
16+
# organization: Certificate O
17+
# country: Certificate C
18+
# validity_days: Certificate validity period
19+
# key_usage: List of key usage extensions
20+
# extended_key_usage: List of extended key usage (optional, for agent certs)
21+
22+
- name: "Set certificate paths for {{ cert_name }}"
23+
ansible.builtin.set_fact:
24+
key_path: "{{ cert_dir }}/{{ cert_name }}-key.pem"
25+
cert_path: "{{ cert_dir }}/{{ cert_name }}-cert.pem"
26+
csr_path: "{{ cert_dir }}/{{ cert_name }}.csr"
27+
28+
- name: "Check if {{ cert_name }} already exists"
29+
kubernetes.core.k8s_info:
30+
kind: "{{ 'ConfigMap' if output_configmap else 'Secret' }}"
31+
namespace: "{{ namespace }}"
32+
name: "{{ cert_name if output_configmap else (key_secret_name | default(cert_name + '-key')) }}"
33+
register: existing_cert
34+
35+
- name: "Skip {{ cert_name }} - already exists"
36+
ansible.builtin.debug:
37+
msg: "Certificate {{ cert_name }} already exists, skipping"
38+
when: existing_cert.resources | length > 0
39+
40+
- name: "Generate private key for {{ cert_name }}"
41+
community.crypto.openssl_privatekey:
42+
path: "{{ key_path }}"
43+
size: "{{ 4096 if cert_type == 'ca' else 2048 }}"
44+
when: existing_cert.resources | length == 0
45+
46+
- name: "Generate CSR for CA certificate {{ cert_name }}"
47+
community.crypto.openssl_csr:
48+
path: "{{ csr_path }}"
49+
privatekey_path: "{{ key_path }}"
50+
common_name: "{{ common_name }}"
51+
organization_name: "{{ organization }}"
52+
country_name: "{{ country }}"
53+
basic_constraints:
54+
- "CA:TRUE"
55+
basic_constraints_critical: true
56+
key_usage: "{{ key_usage }}"
57+
key_usage_critical: true
58+
when:
59+
- existing_cert.resources | length == 0
60+
- cert_type == "ca"
61+
62+
- name: "Generate self-signed CA certificate for {{ cert_name }}"
63+
community.crypto.x509_certificate:
64+
path: "{{ cert_path }}"
65+
csr_path: "{{ csr_path }}"
66+
privatekey_path: "{{ key_path }}"
67+
provider: selfsigned
68+
selfsigned_not_after: "+{{ validity_days }}d"
69+
when:
70+
- existing_cert.resources | length == 0
71+
- cert_type == "ca"
72+
73+
- name: "Generate CSR for {{ cert_name }}"
74+
community.crypto.openssl_csr:
75+
path: "{{ csr_path }}"
76+
privatekey_path: "{{ key_path }}"
77+
common_name: "{{ common_name }}"
78+
organization_name: "{{ organization }}"
79+
country_name: "{{ country }}"
80+
key_usage: "{{ key_usage }}"
81+
key_usage_critical: true
82+
extended_key_usage: "{{ extended_key_usage | default(omit) }}"
83+
when:
84+
- existing_cert.resources | length == 0
85+
- cert_type == "agent"
86+
87+
- name: "Sign agent certificate for {{ cert_name }}"
88+
community.crypto.x509_certificate:
89+
path: "{{ cert_path }}"
90+
csr_path: "{{ csr_path }}"
91+
provider: ownca
92+
ownca_path: "{{ ca_cert_path }}"
93+
ownca_privatekey_path: "{{ ca_key_path }}"
94+
ownca_not_after: "+{{ validity_days }}d"
95+
when:
96+
- existing_cert.resources | length == 0
97+
- cert_type == "agent"
98+
99+
- name: "Create ConfigMap with certificate for {{ cert_name }}"
100+
kubernetes.core.k8s:
101+
state: present
102+
definition:
103+
apiVersion: v1
104+
kind: ConfigMap
105+
metadata:
106+
name: "{{ cert_name }}"
107+
namespace: "{{ namespace }}"
108+
data:
109+
ca-bundle.pem: "{{ lookup('file', cert_path) }}"
110+
when:
111+
- existing_cert.resources | length == 0
112+
- output_configmap | default(false)
113+
114+
- name: "Check if {{ cert_name }} key secret exists"
115+
kubernetes.core.k8s_info:
116+
kind: Secret
117+
namespace: "{{ namespace }}"
118+
name: "{{ key_secret_name | default(cert_name + '-key') }}"
119+
register: existing_key_secret
120+
when: output_secret | default(false)
121+
122+
- name: "Create Secret with CA private key for {{ cert_name }}"
123+
kubernetes.core.k8s:
124+
state: present
125+
definition:
126+
apiVersion: v1
127+
kind: Secret
128+
metadata:
129+
name: "{{ key_secret_name | default(cert_name + '-key') }}"
130+
namespace: "{{ namespace }}"
131+
type: Opaque
132+
stringData:
133+
ca-key.pem: "{{ lookup('file', key_path) }}"
134+
when:
135+
- output_secret | default(false)
136+
- (existing_key_secret.resources | default([])) | length == 0
137+
- cert_type == 'ca'
138+
139+
- name: "Create Secret with agent private key for {{ cert_name }}"
140+
kubernetes.core.k8s:
141+
state: present
142+
definition:
143+
apiVersion: v1
144+
kind: Secret
145+
metadata:
146+
name: "{{ key_secret_name | default(cert_name + '-key') }}"
147+
namespace: "{{ namespace }}"
148+
type: Opaque
149+
stringData:
150+
key: "{{ lookup('file', key_path) }}"
151+
when:
152+
- output_secret | default(false)
153+
- (existing_key_secret.resources | default([])) | length == 0
154+
- cert_type == 'agent'
155+
156+
- name: "Create Secret with certificate for {{ cert_name }}"
157+
kubernetes.core.k8s:
158+
state: present
159+
definition:
160+
apiVersion: v1
161+
kind: Secret
162+
metadata:
163+
name: "{{ cert_secret_name | default(cert_name) }}"
164+
namespace: "{{ namespace }}"
165+
type: Opaque
166+
stringData:
167+
cert: "{{ lookup('file', cert_path) }}"
168+
when:
169+
- existing_cert.resources | length == 0
170+
- cert_type == "agent"
171+
- not (output_configmap | default(false))
172+
173+
# Push agent certificates to Vault for KBS to serve
174+
- name: "Create PushSecret for certificate {{ cert_secret_name | default(cert_name) }}"
175+
kubernetes.core.k8s:
176+
state: present
177+
definition:
178+
apiVersion: external-secrets.io/v1alpha1
179+
kind: PushSecret
180+
metadata:
181+
name: "push-{{ cert_secret_name | default(cert_name) }}"
182+
namespace: "{{ namespace }}"
183+
spec:
184+
updatePolicy: Replace
185+
deletionPolicy: Delete
186+
refreshInterval: 10s
187+
secretStoreRefs:
188+
- name: vault-backend
189+
kind: ClusterSecretStore
190+
selector:
191+
secret:
192+
name: "{{ cert_secret_name | default(cert_name) }}"
193+
data:
194+
- match:
195+
secretKey: cert
196+
remoteRef:
197+
remoteKey: "pushsecrets/{{ cert_secret_name | default(cert_name) }}"
198+
property: cert
199+
when:
200+
- cert_type == "agent"
201+
- not (output_configmap | default(false))
202+
203+
- name: "Create PushSecret for private key {{ key_secret_name | default(cert_name + '-key') }}"
204+
kubernetes.core.k8s:
205+
state: present
206+
definition:
207+
apiVersion: external-secrets.io/v1alpha1
208+
kind: PushSecret
209+
metadata:
210+
name: "push-{{ key_secret_name | default(cert_name + '-key') }}"
211+
namespace: "{{ namespace }}"
212+
spec:
213+
updatePolicy: Replace
214+
deletionPolicy: Delete
215+
refreshInterval: 10s
216+
secretStoreRefs:
217+
- name: vault-backend
218+
kind: ClusterSecretStore
219+
selector:
220+
secret:
221+
name: "{{ key_secret_name | default(cert_name + '-key') }}"
222+
data:
223+
- match:
224+
secretKey: key
225+
remoteRef:
226+
remoteKey: "pushsecrets/{{ key_secret_name | default(cert_name + '-key') }}"
227+
property: key
228+
when:
229+
- cert_type == "agent"
230+
- output_secret | default(false)

0 commit comments

Comments
 (0)