Skip to content

Commit 467c9f1

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 e3c54a1 commit 467c9f1

4 files changed

Lines changed: 496 additions & 0 deletions

File tree

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
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: This playbook enables "create-only mode" on the SpireServer CR to prevent
7+
# the operator from reverting our manual patches. This is done via the annotation:
8+
# ztwim.openshift.io/create-only: "true"
9+
#
10+
# NOTE: The create-only mode is enabled AFTER verifying the ConfigMap has the correct
11+
# cluster name. This prevents a race condition where the operator hasn't fully reconciled
12+
# the ConfigMap before we lock it.
13+
14+
- name: Configure SPIRE Server for x509pop attestation
15+
become: false
16+
connection: local
17+
hosts: localhost
18+
gather_facts: false
19+
vars:
20+
spire_namespace: "zero-trust-workload-identity-manager"
21+
configmap_name: "spire-server"
22+
statefulset_name: "spire-server"
23+
ca_configmap_name: "spire-x509pop-ca"
24+
ca_mount_path: "/run/spire/x509pop-ca"
25+
tasks:
26+
- name: Get SpireServer CR to determine expected cluster name
27+
kubernetes.core.k8s_info:
28+
api_version: operator.openshift.io/v1alpha1
29+
kind: SpireServer
30+
name: cluster
31+
namespace: "{{ spire_namespace }}"
32+
register: spire_server_cr
33+
retries: 30
34+
delay: 10
35+
until: spire_server_cr.resources | length > 0
36+
37+
- name: Extract expected cluster name from SpireServer CR
38+
ansible.builtin.set_fact:
39+
expected_cluster_name: "{{ spire_server_cr.resources[0].spec.clusterName }}"
40+
41+
- name: Display expected cluster name
42+
ansible.builtin.debug:
43+
msg: "Expected cluster name from SpireServer CR: {{ expected_cluster_name }}"
44+
45+
- name: Wait for SPIRE Server ConfigMap with correct cluster name
46+
kubernetes.core.k8s_info:
47+
kind: ConfigMap
48+
namespace: "{{ spire_namespace }}"
49+
name: "{{ configmap_name }}"
50+
register: spire_configmap
51+
retries: 60
52+
delay: 5
53+
until: >
54+
spire_configmap.resources | length > 0 and
55+
(spire_configmap.resources[0].data['server.conf'] | from_json).plugins.NodeAttestor[0].k8s_psat.plugin_data.clusters[0][expected_cluster_name] is defined
56+
57+
- name: ConfigMap has correct cluster name
58+
ansible.builtin.debug:
59+
msg: "ConfigMap verified with correct cluster name: {{ expected_cluster_name }}"
60+
61+
- name: Get current SPIRE Server configuration
62+
kubernetes.core.k8s_info:
63+
kind: ConfigMap
64+
namespace: "{{ spire_namespace }}"
65+
name: "{{ configmap_name }}"
66+
register: spire_config
67+
68+
- name: Parse server configuration
69+
ansible.builtin.set_fact:
70+
server_conf: "{{ spire_config.resources[0].data['server.conf'] | from_json }}"
71+
72+
- name: Check if x509pop already configured
73+
ansible.builtin.set_fact:
74+
x509pop_exists: "{{ server_conf.plugins.NodeAttestor | selectattr('x509pop', 'defined') | list | length > 0 }}"
75+
76+
- name: Add x509pop NodeAttestor plugin
77+
kubernetes.core.k8s:
78+
state: present
79+
definition:
80+
apiVersion: v1
81+
kind: ConfigMap
82+
metadata:
83+
name: "{{ configmap_name }}"
84+
namespace: "{{ spire_namespace }}"
85+
data:
86+
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 }}"
87+
when: not x509pop_exists
88+
89+
- name: Wait for SPIRE Server StatefulSet to exist
90+
kubernetes.core.k8s_info:
91+
kind: StatefulSet
92+
namespace: "{{ spire_namespace }}"
93+
name: "{{ statefulset_name }}"
94+
register: spire_statefulset
95+
retries: 30
96+
delay: 10
97+
until: spire_statefulset.resources | length > 0
98+
99+
- name: Check if CA volume already mounted
100+
ansible.builtin.set_fact:
101+
ca_volume_exists: "{{ spire_statefulset.resources[0].spec.template.spec.volumes | selectattr('name', 'equalto', 'x509pop-ca') | list | length > 0 }}"
102+
103+
- name: Add CA volume to SPIRE Server StatefulSet
104+
kubernetes.core.k8s:
105+
state: patched
106+
kind: StatefulSet
107+
namespace: "{{ spire_namespace }}"
108+
name: "{{ statefulset_name }}"
109+
definition:
110+
spec:
111+
template:
112+
spec:
113+
volumes:
114+
- name: x509pop-ca
115+
configMap:
116+
name: "{{ ca_configmap_name }}"
117+
containers:
118+
- name: spire-server
119+
volumeMounts:
120+
- name: x509pop-ca
121+
mountPath: "{{ ca_mount_path }}"
122+
readOnly: true
123+
when: not ca_volume_exists
124+
125+
- name: Restart SPIRE Server to apply configuration
126+
kubernetes.core.k8s:
127+
state: absent
128+
kind: Pod
129+
namespace: "{{ spire_namespace }}"
130+
label_selectors:
131+
- app.kubernetes.io/name=server
132+
when: (not x509pop_exists) or (not ca_volume_exists)
133+
134+
- name: Configuration status
135+
ansible.builtin.debug:
136+
msg: "{{ 'x509pop already configured' if (x509pop_exists and ca_volume_exists) else 'x509pop NodeAttestor plugin and CA volume mount configured successfully' }}"
137+
138+
- name: Enable create-only mode on SpireServer CR
139+
ansible.builtin.debug:
140+
msg: "Enabling create-only mode to prevent operator from reverting x509pop configuration"
141+
142+
- name: Set create-only annotation on SpireServer CR
143+
kubernetes.core.k8s:
144+
state: patched
145+
api_version: operator.openshift.io/v1alpha1
146+
kind: SpireServer
147+
name: cluster
148+
namespace: "{{ spire_namespace }}"
149+
definition:
150+
metadata:
151+
annotations:
152+
ztwim.openshift.io/create-only: "true"
153+
154+
- name: Final status
155+
ansible.builtin.debug:
156+
msg: "x509pop configuration complete. Create-only mode enabled to preserve manual patches."

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)