Skip to content

Commit b5d9294

Browse files
committed
[env_op_images] Add CRI-O pull verification to pulled-images report
Cross-reference the pulled-images report with CRI-O journal logs from cluster nodes to confirm which images were actually pulled by the container runtime. Runs automatically when kubeconfig is defined, same as the pulled-images report itself. Co-authored-by: Cursor <cursor@cursor.com> Signed-off-by: nemarjan <nemarjan@redhat.com>
1 parent bbd3d23 commit b5d9294

6 files changed

Lines changed: 78 additions & 112 deletions

File tree

ci/playbooks/collect-logs.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
chdir: "{{ ansible_user_dir }}/zuul-output/logs/ci-framework-data"
5656
cmd: |
5757
cp -ra {{ ansible_user_dir }}/ci-framework-data/logs . ;
58-
cp -ra {{ ansible_user_dir }}/ci-framework-data/artifacts . || true ;
58+
cp -ra {{ ansible_user_dir }}/ci-framework-data/artifacts . ;
5959
cp -ra {{ ansible_user_dir }}/ci-framework-data/tests . || true ;
6060
6161
- name: Get SELinux listing

plugins/modules/verify_pulled_report_crio.py

Lines changed: 4 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
C(summary.mirror_rules).
2121
- When images carry a C(node) field, evidence is matched against the
2222
specific node's CRI-O log first. If the digest is only found on a
23-
different node the status is set to C(MISMATCH_CROSS_NODE).
23+
different node the entry is counted as a cross-node mismatch.
2424
- Log files are expected to follow the C(<node-name>.crio.log) naming
2525
convention produced by the role task.
2626
@@ -88,7 +88,7 @@
8888
cross_node_entries:
8989
description: >-
9090
Image rows where evidence was found only on a different node
91-
than where the pod ran (C(MISMATCH_CROSS_NODE)).
91+
than where the pod ran.
9292
type: int
9393
returned: always
9494
nodes_with_evidence:
@@ -240,29 +240,16 @@ def run_module():
240240

241241
if node_local_hit:
242242
actual_uri = per_node_evidence[img_node][img_sha]
243-
actual_domain = _apply_evidence(img, actual_uri, img_node, trusted_mirrors)
244-
expected_domain = img.get("expected_pull_location") or ""
245-
img["verification_status"] = (
246-
"MATCH" if actual_domain == expected_domain else "MISMATCH"
247-
)
243+
_apply_evidence(img, actual_uri, img_node, trusted_mirrors)
248244
elif img_sha in global_evidence:
249245
actual_uri, evidence_node = global_evidence[img_sha]
250-
actual_domain = _apply_evidence(
251-
img, actual_uri, evidence_node, trusted_mirrors
252-
)
253-
expected_domain = img.get("expected_pull_location") or ""
246+
_apply_evidence(img, actual_uri, evidence_node, trusted_mirrors)
254247
if img_node:
255-
img["verification_status"] = "MISMATCH_CROSS_NODE"
256248
cross_node_entries += 1
257-
else:
258-
img["verification_status"] = (
259-
"MATCH" if actual_domain == expected_domain else "MISMATCH"
260-
)
261249
else:
262250
img["node_verified_image_origin"] = "cached/unknown"
263251
img["log_evidence_uri"] = None
264252
img["log_evidence_node"] = None
265-
img["verification_status"] = "NOT_FOUND_IN_LOGS"
266253

267254
nodes_with_evidence = sorted(n for n, ev in per_node_evidence.items() if ev)
268255
result = dict(

roles/env_op_images/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ A role to gather the container images used in the openstack deployment with spec
55
* `cifmw_env_op_images_dir`: (String) Directory where the operator_images.yaml will be stored. Defaults to `~/ci-framework-data/artifacts`
66
* `cifmw_env_op_images_file`: (String) Name of the file storing the operator images and tags. Defaults to `operator_images.yaml`
77
* `cifmw_env_op_images_pulled_report_file` / `cifmw_env_op_images_pulled_report_path`: Pulled-images policy report (ICSP/IDMS + pod image refs).
8-
* `cifmw_env_op_images_verified_report_file` / `cifmw_env_op_images_verified_report_path`: Output path for the CRI-O-enriched report. After the pulled report runs, fetches `oc adm node-logs NODE -u crio` per node, then writes this file with digest-level CRI-O fields (`node_verified_image_origin`, `log_evidence_uri`, `verification_status`).
8+
* `cifmw_env_op_images_verified_report_file` / `cifmw_env_op_images_verified_report_path`: Output path for the CRI-O-enriched report. After the pulled report runs, fetches `oc adm node-logs NODE -u crio` per node, then writes this file with digest-level CRI-O fields (`node_verified_image_origin`, `log_evidence_uri`, `log_evidence_node`).
99
* `cifmw_env_op_images_crio_logs_dir`: Directory for per-node `*.crio.log` files used during verification.
1010

1111
## Examples

roles/env_op_images/tasks/pulled_images_report.yml

Lines changed: 17 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,27 @@
3737
state: directory
3838
mode: "0755"
3939

40-
# Legacy OpenShift mirror CRD; empty or error is OK (parsed as no items).
4140
- name: Get ICSP mirror rules
4241
ansible.builtin.command:
4342
cmd: oc get imagecontentsourcepolicy -o json
4443
register: _pulled_report_icsp
4544
failed_when: false
4645

47-
# Preferred / current mirror CRD alongside ICSP.
4846
- name: Get IDMS mirror rules
4947
ansible.builtin.command:
5048
cmd: oc get imagedigestmirrorset -o json
5149
register: _pulled_report_idms
5250
failed_when: false
5351

54-
# One flat list of rules for templating: each mirror list entry becomes
55-
# its own row so prefix matching can use the same loop for all mirrors.
52+
# Flatten ICSP repositoryDigestMirrors and IDMS imageDigestMirrors into
53+
# a single list of {source, mirror} pairs. A source with N mirrors
54+
# produces N entries so the Jinja template can prefix-match every
55+
# mirror in one loop.
56+
# Example output (_pulled_report_mirror_mappings):
57+
# - source: registry.redhat.io/openstack-k8s-operators
58+
# mirror: quay.example.com/rh-osbs/openstack-k8s-operators
59+
# - source: registry.redhat.io/openstack-k8s-operators
60+
# mirror: internal-registry.corp.net/openstack-k8s-operators
5661
- name: Build source-to-mirror mapping from ICSP/IDMS
5762
vars:
5863
_icsp_items: >-
@@ -85,15 +90,6 @@
8590
ansible.builtin.set_fact:
8691
_pulled_report_mirror_mappings: "{{ _mappings | trim | from_yaml }}"
8792

88-
- name: Report ICSP/IDMS mirror rules found
89-
when: _pulled_report_mirror_mappings | length > 0
90-
ansible.builtin.debug:
91-
msg: >-
92-
{{ item.source }} -> {{ item.mirror }}
93-
loop: "{{ _pulled_report_mirror_mappings }}"
94-
loop_control:
95-
label: "{{ item.source }}"
96-
9793
- name: Warn if no ICSP/IDMS mirror rules found
9894
when: _pulled_report_mirror_mappings | length == 0
9995
ansible.builtin.debug:
@@ -115,58 +111,10 @@
115111
label: "{{ item }}"
116112
failed_when: false
117113

118-
# Flatten all loop results, then one report row per container + initContainer.
119114
- name: Build per-pod pulled images report
120-
vars:
121-
_report: >-
122-
{% set entries = [] %}
123-
{% set all_pods = _pulled_report_pods.results |
124-
map(attribute='resources', default=[]) | flatten %}
125-
{% for pod in all_pods %}
126-
{% set pod_labels = pod.metadata.labels | default({}) %}
127-
{% set operator_name = pod_labels.get('openstack.org/operator-name', '') %}
128-
{% set app_label = pod_labels.get('app', pod_labels.get('app.kubernetes.io/name', '')) %}
129-
{# Build a single list so regular and init containers share one loop #}
130-
{% set image_statuses = [] %}
131-
{% for cs in pod.status.containerStatuses | default([]) %}
132-
{% set _ = image_statuses.append({'cs': cs, 'type': 'container'}) %}
133-
{% endfor %}
134-
{% for cs in pod.status.initContainerStatuses | default([]) %}
135-
{% set _ = image_statuses.append({'cs': cs, 'type': 'init_container'}) %}
136-
{% endfor %}
137-
{% for entry in image_statuses %}
138-
{% set cs = entry.cs %}
139-
{% set image = cs.image | default('unknown') %}
140-
{% set image_id = cs.imageID | default('') %}
141-
{% set image_repo = image.split('@')[0].split(':')[0] %}
142-
{% set image_name = image_repo.split('/')[-1] %}
143-
{# Default: host = first path segment of image ref; basis = source #}
144-
{% set match = namespace(host=image.split('/')[0], expected_pull_basis='source') %}
145-
{# First mapping where image startswith source: use mirror registry host #}
146-
{% for m in _pulled_report_mirror_mappings if m.source is defined and m.source and image.startswith(m.source) %}
147-
{% if loop.first %}
148-
{% set match.host = m.mirror.split('/')[0] %}
149-
{% set match.expected_pull_basis = 'mirror' %}
150-
{% endif %}
151-
{% endfor %}
152-
{% set _ = entries.append({
153-
'namespace': pod.metadata.namespace,
154-
'pod': pod.metadata.name,
155-
'node': pod.spec.get('nodeName', 'unknown'),
156-
'container': cs.name,
157-
'container_type': entry.type,
158-
'operator': operator_name if operator_name else 'N/A',
159-
'app': app_label if app_label else image_name,
160-
'image': image,
161-
'image_id': image_id,
162-
'expected_pull_location': match.host,
163-
'expected_pull_basis': match.expected_pull_basis
164-
}) %}
165-
{% endfor %}
166-
{% endfor %}
167-
{{ entries }}
168115
ansible.builtin.set_fact:
169-
_pulled_images_report: "{{ _report | trim | from_yaml }}"
116+
_pulled_images_report: >-
117+
{{ lookup('template', 'pulled_images_report.j2') | trim | from_yaml }}
170118
171119
# Counts for the YAML artifact summary section.
172120
- name: Build report summary
@@ -197,31 +145,11 @@
197145
content: "{{ _full_report | to_nice_yaml }}"
198146
mode: "0644"
199147

200-
# Console visibility: split rows by how ICSP/IDMS classification turned out.
201-
- name: Images with expected_pull_basis source
148+
- name: Pulled images report summary
202149
ansible.builtin.debug:
203150
msg: >-
204-
[{{ item.app }}] {{ item.image }} ->
205-
expected pull location {{ item.expected_pull_location }}
206-
({{ item.container_type }}: {{ item.container }}
207-
| operator: {{ item.operator }}
208-
| pod: {{ item.namespace }}/{{ item.pod }})
209-
loop: >-
210-
{{ _pulled_images_report |
211-
selectattr('expected_pull_basis', 'equalto', 'source') | list }}
212-
loop_control:
213-
label: "{{ item.app }}/{{ item.container }}"
214-
215-
- name: Images with expected_pull_basis mirror
216-
ansible.builtin.debug:
217-
msg: >-
218-
[{{ item.app }}] {{ item.image }} ->
219-
expected pull location {{ item.expected_pull_location }}
220-
({{ item.container_type }}: {{ item.container }}
221-
| operator: {{ item.operator }}
222-
| pod: {{ item.namespace }}/{{ item.pod }})
223-
loop: >-
224-
{{ _pulled_images_report |
225-
selectattr('expected_pull_basis', 'equalto', 'mirror') | list }}
226-
loop_control:
227-
label: "{{ item.app }}/{{ item.container }}"
151+
Pulled images report: {{ _pulled_report_summary.total_containers }} containers
152+
({{ _pulled_report_summary.containers_expected_basis_mirror }} mirror,
153+
{{ _pulled_report_summary.containers_expected_basis_source }} source),
154+
{{ _pulled_report_summary.mirror_rules_found }} mirror rules.
155+
Full report: {{ cifmw_env_op_images_pulled_report_path }}

roles/env_op_images/tasks/verify_pulled_report_crio.yml

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,26 @@
6464
msg: >-
6565
oc get nodes failed (rc={{ _verify_crio_nodes_json.rc }}); cannot fetch CRI-O logs.
6666
67-
# Filename is sanitised to avoid path-traversal with unusual node names.
6867
- name: Fetch CRI-O unit logs per node
69-
ansible.builtin.shell: >-
70-
oc adm node-logs "{{ item }}" -u crio --since=-2h >
71-
"{{ cifmw_env_op_images_crio_logs_dir }}/{{ item | regex_replace('[^A-Za-z0-9._-]+', '_') }}.crio.log"
68+
ansible.builtin.command:
69+
cmd: >-
70+
oc adm node-logs "{{ item }}" -u crio
71+
--since=-4h
7272
loop: "{{ _verify_crio_node_names | default([]) }}"
7373
timeout: 120
7474
register: _verify_crio_fetch
7575

76+
# Filename is sanitised to avoid path-traversal with unusual node names.
77+
- name: Write CRI-O logs to files per node
78+
ansible.builtin.copy:
79+
dest: "{{ cifmw_env_op_images_crio_logs_dir }}/{{ item.item | regex_replace('[^A-Za-z0-9._-]+', '_') }}.crio.log"
80+
content: "{{ item.stdout }}"
81+
mode: "0644"
82+
loop: "{{ _verify_crio_fetch.results | default([]) }}"
83+
loop_control:
84+
label: "{{ item.item | default('') }}"
85+
when: item.rc | default(1) == 0
86+
7687
# Non-fatal: some nodes may be unreachable (e.g. NotReady).
7788
- name: Warn when node log fetch failed for a node
7889
when: item.rc | default(0) != 0
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
{# Produces a YAML list of per-container image entries.
2+
Inputs (Ansible facts):
3+
_pulled_report_pods – register from k8s_info pod queries
4+
_pulled_report_mirror_mappings – flat list of {source, mirror} dicts
5+
#}
6+
{% set entries = [] %}
7+
{% set all_pods = _pulled_report_pods.results |
8+
map(attribute='resources', default=[]) | flatten %}
9+
{% for pod in all_pods %}
10+
{% set image_statuses = [] %}
11+
{% for cs in pod.status.containerStatuses | default([]) %}
12+
{% set _ = image_statuses.append({'cs': cs, 'type': 'container'}) %}
13+
{% endfor %}
14+
{% for cs in pod.status.initContainerStatuses | default([]) %}
15+
{% set _ = image_statuses.append({'cs': cs, 'type': 'init_container'}) %}
16+
{% endfor %}
17+
{% for entry in image_statuses %}
18+
{% set cs = entry.cs %}
19+
{% set image = cs.image | default('unknown') %}
20+
{% set image_id = cs.imageID | default('') %}
21+
{% set match = namespace(host=image.split('/')[0], expected_pull_basis='source') %}
22+
{% for m in _pulled_report_mirror_mappings if m.source is defined and m.source and image.startswith(m.source) %}
23+
{% if loop.first %}
24+
{% set match.host = m.mirror.split('/')[0] %}
25+
{% set match.expected_pull_basis = 'mirror' %}
26+
{% endif %}
27+
{% endfor %}
28+
{% set _ = entries.append({
29+
'namespace': pod.metadata.namespace,
30+
'pod': pod.metadata.name,
31+
'node': pod.spec.get('nodeName', 'unknown'),
32+
'container': cs.name,
33+
'image': image,
34+
'image_id': image_id,
35+
'expected_pull_location': match.host,
36+
'expected_pull_basis': match.expected_pull_basis
37+
}) %}
38+
{% endfor %}
39+
{% endfor %}
40+
{{ entries }}

0 commit comments

Comments
 (0)