Skip to content

Commit 4282933

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 38f1714 commit 4282933

7 files changed

Lines changed: 340 additions & 5 deletions

File tree

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
#!/usr/bin/python
2+
3+
# Copyright: (c) 2026, Nemanja Marjanovic <nemarjan@redhat.com>
4+
# Apache License Version 2.0 (see LICENSE)
5+
6+
from __future__ import absolute_import, division, print_function
7+
8+
__metaclass__ = type
9+
10+
DOCUMENTATION = r"""
11+
---
12+
module: verify_pulled_report_crio
13+
14+
short_description: Enrich pulled_images_report with CRI-O pull evidence
15+
16+
description:
17+
- Reads the YAML produced by the env_op_images pulled-images report role task.
18+
- "Parses CRI-O journal lines for C(msg=\"Pulled image: ...@sha256:...\")."
19+
- Adds per-row verification fields using trusted mirror domains from
20+
C(summary.mirror_rules).
21+
22+
options:
23+
report_path:
24+
description: Path to C(pulled_images_report.yaml) (input).
25+
required: true
26+
type: str
27+
output_path:
28+
description: Path for the enriched YAML report (output).
29+
required: true
30+
type: str
31+
log_paths:
32+
description:
33+
- Explicit list of log files to parse (e.g. per-node CRI-O logs).
34+
- Combined with files found under I(log_dir) when set.
35+
required: false
36+
type: list
37+
elements: str
38+
default: []
39+
log_dir:
40+
description:
41+
- Directory containing CRI-O log files matching I(log_glob).
42+
required: false
43+
type: str
44+
log_glob:
45+
description: Glob under I(log_dir). Used only when I(log_dir) is set.
46+
required: false
47+
default: "*.crio.log"
48+
type: str
49+
50+
author:
51+
- Red Hat
52+
53+
notes:
54+
- Requires PyYAML on the controller (same as other cifmw.general modules).
55+
"""
56+
57+
EXAMPLES = r"""
58+
- name: Enrich pulled report using fetched node logs
59+
cifmw.general.verify_pulled_report_crio:
60+
report_path: "{{ cifmw_env_op_images_pulled_report_path }}"
61+
log_dir: "{{ cifmw_env_op_images_crio_logs_dir }}"
62+
output_path: "{{ cifmw_env_op_images_verified_report_path }}"
63+
"""
64+
65+
RETURN = r"""
66+
changed:
67+
description: Whether the output file was written.
68+
type: bool
69+
trusted_mirrors:
70+
description: Hostnames extracted from mirror rules in the report summary.
71+
type: list
72+
elements: str
73+
log_files:
74+
description: Number of log files read.
75+
type: int
76+
entries_with_digest:
77+
description: Image rows that had a sha256 digest in C(image_id).
78+
type: int
79+
"""
80+
81+
import glob
82+
import os
83+
import re
84+
85+
import yaml
86+
from ansible.module_utils.basic import AnsibleModule
87+
88+
LOG_PATTERN = re.compile(
89+
r'msg="Pulled image: (?P<actual_uri>[^@\s]+)@(?P<id>sha256:[a-f0-9]+)"'
90+
)
91+
92+
93+
def _collect_log_evidence(paths, module):
94+
evidence = {}
95+
for path in paths:
96+
try:
97+
with open(path, "r") as f:
98+
for line in f:
99+
match = LOG_PATTERN.search(line)
100+
if match:
101+
evidence[match.group("id")] = match.group("actual_uri")
102+
except IOError as exc:
103+
module.fail_json(
104+
msg="Cannot read CRI-O log file {0}: {1}".format(path, str(exc))
105+
)
106+
return evidence
107+
108+
109+
def run_module():
110+
module_args = dict(
111+
report_path=dict(type="str", required=True),
112+
output_path=dict(type="str", required=True),
113+
log_paths=dict(type="list", required=False, elements="str", default=[]),
114+
log_dir=dict(type="str", required=False),
115+
log_glob=dict(type="str", required=False, default="*.crio.log"),
116+
)
117+
118+
module = AnsibleModule(argument_spec=module_args, supports_check_mode=True)
119+
120+
report_path = module.params["report_path"]
121+
output_path = module.params["output_path"]
122+
log_paths = module.params["log_paths"] or []
123+
log_dir = module.params["log_dir"]
124+
log_glob = module.params["log_glob"]
125+
126+
paths = list(log_paths)
127+
if log_dir:
128+
paths.extend(sorted(glob.glob(os.path.join(log_dir, log_glob))))
129+
130+
if not paths:
131+
module.fail_json(
132+
msg="No CRI-O log files: set log_paths and/or log_dir with matching files."
133+
)
134+
135+
try:
136+
with open(report_path, "r") as f:
137+
data = yaml.safe_load(f)
138+
except IOError as exc:
139+
module.fail_json(
140+
msg="Cannot read report {0}: {1}".format(report_path, str(exc))
141+
)
142+
except yaml.YAMLError as exc:
143+
module.fail_json(msg="Invalid YAML in report: {0}".format(str(exc)))
144+
145+
if not isinstance(data, dict):
146+
module.fail_json(msg="Report root must be a mapping (dict).")
147+
148+
trusted_mirrors = set()
149+
summary_section = data.get("summary") or {}
150+
for rule in summary_section.get("mirror_rules") or []:
151+
if not isinstance(rule, dict):
152+
continue
153+
mirror_url = rule.get("mirror") or ""
154+
if mirror_url:
155+
domain = mirror_url.split("/")[0].strip()
156+
if domain:
157+
trusted_mirrors.add(domain)
158+
159+
log_evidence = _collect_log_evidence(paths, module)
160+
161+
images_list = data.get("images") or []
162+
entries_with_digest = 0
163+
for img in images_list:
164+
if not isinstance(img, dict):
165+
continue
166+
image_id = img.get("image_id") or ""
167+
sha_match = re.search(r"sha256:[a-f0-9]+", image_id)
168+
if not sha_match:
169+
continue
170+
entries_with_digest += 1
171+
img_sha = sha_match.group(0)
172+
173+
if img_sha in log_evidence:
174+
actual_uri = log_evidence[img_sha]
175+
actual_domain = actual_uri.split("/")[0].strip()
176+
is_mirror_domain = actual_domain in trusted_mirrors
177+
img["node_verified_image_origin"] = "mirror" if is_mirror_domain else "source"
178+
img["log_evidence_uri"] = actual_uri
179+
expected_domain = img.get("expected_pull_location") or ""
180+
img["verification_status"] = (
181+
"MATCH" if actual_domain == expected_domain else "MISMATCH"
182+
)
183+
else:
184+
img["node_verified_image_origin"] = "cached/unknown"
185+
img["verification_status"] = "NOT_FOUND_IN_LOGS"
186+
187+
result = dict(
188+
changed=False,
189+
trusted_mirrors=sorted(trusted_mirrors),
190+
log_files=len(paths),
191+
entries_with_digest=entries_with_digest,
192+
)
193+
194+
if module.check_mode:
195+
result["changed"] = True
196+
module.exit_json(**result)
197+
198+
try:
199+
with open(output_path, "w") as f:
200+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
201+
except IOError as exc:
202+
module.fail_json(
203+
msg="Cannot write verified report {0}: {1}".format(output_path, str(exc))
204+
)
205+
206+
result["changed"] = True
207+
module.exit_json(**result)
208+
209+
210+
def main():
211+
run_module()
212+
213+
214+
if __name__ == "__main__":
215+
main()

roles/env_op_images/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ A role to gather the container images used in the openstack deployment with spec
44
## Parameters
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`
7+
* `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`).
9+
* `cifmw_env_op_images_crio_logs_dir`: Directory for per-node `*.crio.log` files used during verification.
710

811
## Examples
912
```YAML

roles/env_op_images/defaults/main.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@ cifmw_env_op_images_file: operator_images.yaml
2323
cifmw_env_op_images_dryrun: false
2424

2525
cifmw_env_op_images_pulled_report_file: pulled_images_report.yaml
26+
cifmw_env_op_images_pulled_report_path: >-
27+
{{
28+
(cifmw_env_op_images_dir, 'artifacts', cifmw_env_op_images_pulled_report_file)
29+
| path_join
30+
}}
31+
32+
cifmw_env_op_images_verified_report_file: pulled_images_report_verified.yaml
33+
cifmw_env_op_images_verified_report_path: >-
34+
{{
35+
(cifmw_env_op_images_dir, 'artifacts', cifmw_env_op_images_verified_report_file)
36+
| path_join
37+
}}
38+
cifmw_env_op_images_crio_logs_dir: >-
39+
{{ (cifmw_env_op_images_dir, 'artifacts', 'crio_logs') | path_join }}
40+
2641
cifmw_env_op_images_pulled_report_namespaces:
2742
- "{{ cifmw_openstack_namespace | default('openstack') }}"
2843
- "{{ operator_namespace | default('openstack-operators') }}"

roles/env_op_images/tasks/main.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,3 +158,6 @@
158158

159159
- name: Generate pulled images registry report
160160
ansible.builtin.include_tasks: pulled_images_report.yml
161+
162+
- name: Verify pulled report against CRI-O node logs
163+
ansible.builtin.include_tasks: verify_pulled_report_crio.yml

roles/env_op_images/tasks/pulled_images_report.yml

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,11 +192,7 @@
192192
summary: "{{ _pulled_report_summary }}"
193193
images: "{{ _pulled_images_report }}"
194194
ansible.builtin.copy:
195-
dest: >-
196-
{{
197-
(cifmw_env_op_images_dir, 'artifacts',
198-
cifmw_env_op_images_pulled_report_file) | path_join
199-
}}
195+
dest: "{{ cifmw_env_op_images_pulled_report_path }}"
200196
content: "{{ _full_report | to_nice_yaml }}"
201197
mode: "0644"
202198

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
---
2+
# Copyright Red Hat, Inc.
3+
# All Rights Reserved.
4+
#
5+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
6+
# not use this file except in compliance with the License. You may obtain
7+
# a copy of the License at
8+
#
9+
# http://www.apache.org/licenses/LICENSE-2.0
10+
#
11+
# Unless required by applicable law or agreed to in writing, software
12+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14+
# License for the specific language governing permissions and limitations
15+
# under the License.
16+
17+
# Cross-reference the pulled-images report (from pulled_images_report.yml)
18+
# with CRI-O journal logs from every cluster node to confirm which images
19+
# were actually pulled by the container runtime.
20+
21+
- name: Verify pulled report against CRI-O node logs
22+
when:
23+
- cifmw_openshift_kubeconfig is defined
24+
environment:
25+
KUBECONFIG: "{{ cifmw_openshift_kubeconfig }}"
26+
PATH: "{{ cifmw_path }}"
27+
block:
28+
- name: Check pulled images report exists
29+
ansible.builtin.stat:
30+
path: "{{ cifmw_env_op_images_pulled_report_path }}"
31+
register: _verify_crio_pulled_stat
32+
33+
- name: Fail when pulled report is missing
34+
when: not _verify_crio_pulled_stat.stat.exists | bool
35+
ansible.builtin.fail:
36+
msg: >-
37+
Pulled report not found at {{ cifmw_env_op_images_pulled_report_path }}.
38+
Run pulled_images_report first.
39+
40+
- name: Ensure CRI-O logs directory exists
41+
ansible.builtin.file:
42+
path: "{{ cifmw_env_op_images_crio_logs_dir }}"
43+
state: directory
44+
mode: "0755"
45+
46+
- name: List cluster nodes
47+
ansible.builtin.command:
48+
cmd: oc get nodes -o json
49+
register: _verify_crio_nodes_json
50+
changed_when: false
51+
52+
- name: Extract node names
53+
when: _verify_crio_nodes_json.rc == 0
54+
ansible.builtin.set_fact:
55+
_verify_crio_node_names: >-
56+
{{
57+
(_verify_crio_nodes_json.stdout | from_json).get('items', [])
58+
| map(attribute='metadata.name') | list
59+
}}
60+
61+
- name: Fail when oc get nodes did not succeed
62+
when: _verify_crio_nodes_json.rc != 0
63+
ansible.builtin.fail:
64+
msg: >-
65+
oc get nodes failed (rc={{ _verify_crio_nodes_json.rc }}); cannot fetch CRI-O logs.
66+
67+
# Filename is sanitised to avoid path-traversal with unusual node names.
68+
- name: Fetch CRI-O unit logs per node
69+
ansible.builtin.shell: >-
70+
oc adm node-logs "{{ item }}" -u crio >
71+
"{{ cifmw_env_op_images_crio_logs_dir }}/{{ item | regex_replace('[^A-Za-z0-9._-]+', '_') }}.crio.log"
72+
loop: "{{ _verify_crio_node_names | default([]) }}"
73+
register: _verify_crio_fetch
74+
75+
# Non-fatal: some nodes may be unreachable (e.g. NotReady).
76+
- name: Warn when node log fetch failed for a node
77+
when: item.rc | default(0) != 0
78+
ansible.builtin.debug:
79+
msg: "oc adm node-logs failed for node (rc={{ item.rc | default('n/a') }}): {{ item.item | default('unknown') }}"
80+
loop: "{{ _verify_crio_fetch.results | default([]) }}"
81+
loop_control:
82+
label: "{{ item.item | default('') }}"
83+
84+
- name: Find fetched CRI-O log files
85+
ansible.builtin.find:
86+
paths: "{{ cifmw_env_op_images_crio_logs_dir }}"
87+
patterns: "*.crio.log"
88+
register: _verify_crio_log_files
89+
90+
- name: Enrich pulled report with CRI-O evidence
91+
when: _verify_crio_log_files.matched | int > 0
92+
cifmw.general.verify_pulled_report_crio:
93+
report_path: "{{ cifmw_env_op_images_pulled_report_path }}"
94+
log_dir: "{{ cifmw_env_op_images_crio_logs_dir }}"
95+
output_path: "{{ cifmw_env_op_images_verified_report_path }}"
96+
97+
- name: Fail when no CRI-O logs were written
98+
when: _verify_crio_log_files.matched | int == 0
99+
ansible.builtin.fail:
100+
msg: >-
101+
No *.crio.log files under {{ cifmw_env_op_images_crio_logs_dir }}.
102+
Check cluster credentials and oc adm node-logs access.

tests/sanity/ignore.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ plugins/modules/tempest_list_skipped.py validate-modules:missing-gplv3-license #
55
plugins/modules/cephx_key.py validate-modules:missing-gplv3-license # ignore license check
66
plugins/modules/krb_request.py validate-modules:missing-gplv3-license # ignore license check
77
plugins/modules/pem_read.py validate-modules:missing-gplv3-license # ignore license check
8+
plugins/modules/verify_pulled_report_crio.py validate-modules:missing-gplv3-license # ignore license check

0 commit comments

Comments
 (0)