summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChen Qi <Qi.Chen@windriver.com>2025-04-03 00:45:11 -0700
committerKhem Raj <raj.khem@gmail.com>2025-04-04 19:18:46 -0700
commit14ff80c9dc4090a15cb6ebc849f50683dc0cad5f (patch)
tree20a5e2ee2440f8e2237f183b34f02006b3ab1436
parent0f08684d7f550c5d783f3af2a0adfffad2fbc5b5 (diff)
downloadmeta-openembedded-14ff80c9dc4090a15cb6ebc849f50683dc0cad5f.tar.gz
version-check.conf: add mechanism for checking version mismatch
Add a mechanism to check mismatch between runtime version and build time version. To use, add the following line to local.conf: include conf/version-check.conf Ideally, layers will have their own conf/version-check.conf to establish some baseline, so that any future warning indicates some error. In such case, users can use include_all: include_all conf/version-check.conf The basic idea is to use qemu to run executables at build time, extract possible versions, and check if there's a mismatch found. Python meta data and .pc files are also checked for quick match. This is because such info are also easy to be checked by users. check-version-mismatch.bbclass is the class that does the actual work. A new variable, CHECK_VERSION_PV, is introduced. It defaults to ${PKGV}, but also allows override. This allows us to handle special cases in each layer. version-check.conf is the configuration file that makes this functionality easier to use and draws some baseline. It contains some override settings for some recipes. With these overrides, all recipes in oe-core are handled well. All warnings are valid warnings. Note that 'ps' is added to HOSTTOOLS in version-check.conf. This is because we need 'ps' to find stale processes and then clean them. The warnings are like below: WARNING: time-1.9-r0 do_package_check_version_mismatch: Possible runtime versions ['UNKNOWN'] do not match recipe version 1.9 WARNING: python3-unittest-automake-output-0.2-r0 do_package_check_version_mismatch: Possible runtime versions ['0.1'] do not match recipe version 0.2 WARNING: pinentry-1.3.1-r0 do_package_check_version_mismatch: Possible runtime versions ['1.3.1-unknown'] do not match recipe version 1.3.1 ... There will be a data directory containing all details: tmp/check-version-mismatch. This directory contains detailed data for each recipe that is built. If users don't want it, they can set DEBUG_VERSION_MISMATCH_CHECK to 0. Signed-off-by: Chen Qi <Qi.Chen@windriver.com> Signed-off-by: Khem Raj <raj.khem@gmail.com>
-rw-r--r--meta-oe/classes/check-version-mismatch.bbclass399
-rw-r--r--meta-oe/conf/version-check.conf22
2 files changed, 421 insertions, 0 deletions
diff --git a/meta-oe/classes/check-version-mismatch.bbclass b/meta-oe/classes/check-version-mismatch.bbclass
new file mode 100644
index 0000000000..7b46151b03
--- /dev/null
+++ b/meta-oe/classes/check-version-mismatch.bbclass
@@ -0,0 +1,399 @@
1inherit qemu
2
3ENABLE_VERSION_MISMATCH_CHECK ?= "${@'1' if bb.utils.contains('MACHINE_FEATURES', 'qemu-usermode', True, False, d) else '0'}"
4DEBUG_VERSION_MISMATCH_CHECK ?= "1"
5CHECK_VERSION_PV ?= ""
6
7DEPENDS:append:class-target = "${@' qemu-native' if bb.utils.to_boolean(d.getVar('ENABLE_VERSION_MISMATCH_CHECK')) else ''}"
8
9QEMU_EXEC ?= "${@qemu_wrapper_cmdline(d, '${STAGING_DIR_HOST}', ['${STAGING_DIR_HOST}${libdir}','${STAGING_DIR_HOST}${base_libdir}', '${PKGD}${libdir}', '${PKGD}${base_libdir}'])}"
10
11python do_package_check_version_mismatch() {
12 import re
13 import subprocess
14 import shutil
15 import signal
16
17 classes_skip = ["nopackage", "image", "native", "cross", "crosssdk", "cross-canadian"]
18 for cs in classes_skip:
19 if bb.data.inherits_class(cs, d):
20 bb.note(f"Skip do_package_check_version_mismatch as {cs} is inherited.")
21 return
22
23 if not bb.utils.to_boolean(d.getVar('ENABLE_VERSION_MISMATCH_CHECK')):
24 bb.note("Skip do_package_check_version_mismatch as ENABLE_VERSION_MISMATCH_CHECK is disabled.")
25 return
26
27 __regexp_version_broad_match__ = re.compile(r"(?:\s|^|-|_|/|=| go|\()" +
28 r"(?P<version>v?[0-9][0-9.][0-9+.\-_~\(\)]*?|UNKNOWN)" +
29 r"(?:[+\-]release.*|[+\-]stable.*|)" +
30 r"(?P<extra>[+\-]unknown|[+\-]dirty|[+\-]rc?\d{1,3}|\+cargo-[0-9.]+|" +
31 r"[a-z]|-?[pP][0-9]{1,3}|-?beta[^\s]*|-?alpha[^\s]*|)" +
32 r"(?P<extra2>[+\-]dev|[+\-]devel|)" +
33 r"(?:,|:|\.|\)|-[0-9a-g]{6,42}|)" +
34 r"(?=\s|$)"
35 )
36 __regexp_exclude_year__ = re.compile(r"^(19|20)[0-9]{2}$")
37 __regexp_single_number_ending_with_dot__ = re.compile(r"^\d\.$")
38
39 def is_shared_library(filepath):
40 return re.match(r'.*\.so(\.\d+)*$', filepath) is not None
41
42 def get_possible_versions(output_contents, full_cmd=None, max_lines=None):
43 #
44 # Algorithm:
45 # 1. Check version line by line.
46 # 2. Skip some lines which we know that do not contain version information, e.g., License, Copyright.
47 # 3. Do broad match, finding all possible versions.
48 # 4. If there's a version found by any match, do exclude match (e.g., exclude years)
49 # 5. If there's a valid version, do stripping and converting and then add to possible_versions.
50 # 6. Return possible_versions
51 #
52 possible_versions = []
53 content_lines = output_contents.split("\n")
54 if max_lines:
55 content_lines = content_lines[0:max_lines]
56 if full_cmd:
57 base_cmd = os.path.basename(full_cmd)
58 __regex_help_format__ = re.compile(r"-[^\s].*")
59 for line in content_lines:
60 line = line.strip()
61 # skip help lines
62 if __regex_help_format__.match(line):
63 continue
64 # avoid command itself affecting output
65 if full_cmd:
66 if line.startswith(base_cmd):
67 line = line[len(base_cmd):]
68 elif line.startswith(full_cmd):
69 line = line[len(full_cmd):]
70 # skip specific lines
71 skip_keywords_start = ["copyright", "license", "compiled", "build", "built"]
72 skip_line = False
73 for sks in skip_keywords_start:
74 if line.lower().startswith(sks):
75 skip_line = True
76 break
77 if skip_line:
78 continue
79
80 # try broad match
81 for match in __regexp_version_broad_match__.finditer(line):
82 version = match.group("version")
83 #print(f"version = {version}")
84 # do exclude match
85 exclude_match = __regexp_exclude_year__.match(version)
86 if exclude_match:
87 continue
88 exclude_match = __regexp_single_number_ending_with_dot__.match(version)
89 if exclude_match:
90 continue
91 # do some stripping and converting
92 if version.startswith("("):
93 version = version[1:-1]
94 if version.startswith("v"):
95 version = version[1:]
96 if version.endswith(")") and "(" not in version:
97 version = version[:-1]
98 # handle extra version info
99 version = version + match.group("extra") + match.group("extra2")
100 possible_versions.append(version)
101 return possible_versions
102
103 def is_version_mismatch(rvs, pv):
104 got_match = False
105 if pv.startswith("git"):
106 return False
107 if "-pre" in pv:
108 pv = pv.split("-pre")[0]
109 if pv.startswith("v"):
110 pv = pv[1:]
111 for rv in rvs:
112 if rv == pv:
113 got_match = True
114 break
115 pv = pv.split("+git")[0]
116 # handle % character in pv which means matching any chars
117 if '%' in pv:
118 escaped_pv = re.escape(pv)
119 regex_pattern = escaped_pv.replace('%', '.*')
120 regex_pattern = f'^{regex_pattern}$'
121 if re.fullmatch(regex_pattern, rv):
122 got_match = True
123 break
124 else:
125 continue
126 # handle cases such as 2.36.0-r0 v.s. 2.36.0
127 if "-r" in rv:
128 rv = rv.split("-r")[0]
129 chars_to_replace = ["-", "+", "_", "~"]
130 # convert to use "." as the version seperator
131 for cr in chars_to_replace:
132 rv = rv.replace(cr, ".")
133 pv = pv.replace(cr, ".")
134 if rv == pv:
135 got_match = True
136 break
137 # handle case such as 5.2.37(1) v.s. 5.2.37
138 if "(" in rv:
139 rv = rv.split("(")[0]
140 if rv == pv:
141 got_match = True
142 break
143 # handle case such as 4.4.3p1
144 if "p" in pv and "p" in rv.lower():
145 pv = pv.lower().replace(".p", "p")
146 rv = rv.lower().replace(".p", "p")
147 if pv == rv:
148 got_match = True
149 break
150 # handle cases such as 6.00 v.s. 6.0
151 if rv.startswith(pv):
152 if rv == pv + "0" or rv == pv + ".0":
153 got_match = True
154 break
155 elif pv.startswith(rv):
156 if pv == rv + "0" or pv == rv + ".0":
157 got_match = True
158 break
159 # handle cases such as 21306 v.s. 2.13.6
160 if "." in pv and not "." in rv:
161 pv_components = pv.split(".")
162 if rv.startswith(pv_components[0]):
163 pv_num = 0
164 for i in range(0, len(pv_components)):
165 pv_num = pv_num * 100 + int(pv_components[i])
166 if pv_num == int(rv):
167 got_match = True
168 break
169 if got_match:
170 return False
171 else:
172 return True
173
174 # helper function to get PKGV, useful for recipes such as perf
175 def get_pkgv(pn):
176 pkgdestwork = d.getVar("PKGDESTWORK")
177 recipe_data_fn = pkgdestwork + "/" + pn
178 pn_data = oe.packagedata.read_pkgdatafile(recipe_data_fn)
179 if not "PACKAGES" in pn_data:
180 return d.getVar("PV")
181 packages = pn_data["PACKAGES"].split()
182 for pkg in packages:
183 pkg_fn = pkgdestwork + "/runtime/" + pkg
184 pkg_data = oe.packagedata.read_pkgdatafile(pkg_fn)
185 if "PKGV" in pkg_data:
186 return pkg_data["PKGV"]
187
188 #
189 # traverse PKGD, find executables and run them to get runtime version information and compare it with recipe version information
190 #
191 enable_debug = bb.utils.to_boolean(d.getVar("DEBUG_VERSION_MISMATCH_CHECK"))
192 pkgd = d.getVar("PKGD")
193 pn = d.getVar("PN")
194 pv = d.getVar("CHECK_VERSION_PV")
195 if not pv:
196 pv = get_pkgv(pn)
197 qemu_exec = d.getVar("QEMU_EXEC").strip()
198 executables = []
199 possible_versions_all = []
200 data_lines = []
201
202 if enable_debug:
203 debug_directory = d.getVar("TMPDIR") + "/check-version-mismatch"
204 debug_data_file = debug_directory + "/" + pn
205 os.makedirs(debug_directory, exist_ok=True)
206 data_lines.append("pv: %s\n" % pv)
207
208 got_quick_match_result = False
209 # handle python3-xxx recipes quickly
210 __regex_python_module_version__ = re.compile(r"(?:^|.*:)Version: (?P<version>.*)$")
211 if "python3-" in pn:
212 version_check_cmd = "find %s -name 'METADATA' | xargs grep '^Version: '" % pkgd
213 try:
214 output = subprocess.check_output(version_check_cmd, shell=True).decode("utf-8")
215 data_lines.append("version_check_cmd: %s\n" % version_check_cmd)
216 data_lines.append("output:\n'''\n%s'''\n" % output)
217 possible_versions = []
218 for line in output.split("\n"):
219 match = __regex_python_module_version__.match(line)
220 if match:
221 possible_versions.append(match.group("version"))
222 possible_versions = sorted(set(possible_versions))
223 data_lines.append("possible versions: %s\n" % possible_versions)
224 if is_version_mismatch(possible_versions, pv):
225 data_lines.append("FINAL RESULT: MISMATCH (%s v.s. %s)\n\n" % (possible_versions, pv))
226 bb.warn("Possible runtime versions %s do not match recipe version %s" % (possible_versions, pv))
227 else:
228 data_lines.append("FINAL RESULT: MATCH (%s v.s. %s)\n\n" % (possible_versions, pv))
229 got_quick_match_result = True
230 except:
231 data_lines.append("version_check_cmd: %s\n" % version_check_cmd)
232 data_lines.append("result: RUN_FAILED\n\n")
233 if got_quick_match_result:
234 if enable_debug:
235 with open(debug_data_file, "w") as f:
236 f.writelines(data_lines)
237 return
238
239 # handle .pc files
240 version_check_cmd = "find %s -name '*.pc' | xargs grep -i version" % pkgd
241 try:
242 output = subprocess.check_output(version_check_cmd, shell=True).decode("utf-8")
243 data_lines.append("version_check_cmd: %s\n" % version_check_cmd)
244 data_lines.append("output:\n'''\n%s'''\n" % output)
245 possible_versions = get_possible_versions(output)
246 possible_versions = sorted(set(possible_versions))
247 data_lines.append("possible versions: %s\n" % possible_versions)
248 if is_version_mismatch(possible_versions, pv):
249 if pn.startswith("lib"):
250 data_lines.append("FINAL RESULT: MISMATCH (%s v.s. %s)\n\n" % (possible_versions, pv))
251 bb.warn("Possible runtime versions %s do not match recipe version %s" % (possible_versions, pv))
252 got_quick_match_result = True
253 else:
254 data_lines.append("result: MISMATCH (%s v.s. %s)\n\n" % (possible_versions, pv))
255 else:
256 data_lines.append("FINAL RESULT: MATCH (%s v.s. %s)\n\n" % (possible_versions, pv))
257 got_quick_match_result = True
258 except:
259 data_lines.append("version_check_cmd: %s\n" % version_check_cmd)
260 data_lines.append("result: RUN_FAILED\n\n")
261 if got_quick_match_result:
262 if enable_debug:
263 with open(debug_data_file, "w") as f:
264 f.writelines(data_lines)
265 return
266
267 skipped_directories = [".debug", "ptest", "installed-tests", "tests", "test", "__pycache__", "testcases"]
268 pkgd_libdir = pkgd + d.getVar("libdir")
269 pkgd_base_libdir = pkgd + d.getVar("base_libdir")
270 extra_exec_libdirs = []
271 for root, dirs, files in os.walk(pkgd):
272 for dname in dirs:
273 fdir = os.path.join(root, dname)
274 if os.path.isdir(fdir) and fdir != pkgd_libdir and fdir != pkgd_base_libdir:
275 if fdir.startswith(pkgd_libdir) or fdir.startswith(pkgd_base_libdir):
276 for sd in skipped_directories:
277 if fdir.endswith("/" + sd) or ("/" + sd + "/") in fdir:
278 break
279 else:
280 extra_exec_libdirs.append(fdir)
281 for fname in files:
282 fpath = os.path.join(root, fname)
283 if os.path.isfile(fpath) and os.access(fpath, os.X_OK):
284 for sd in skipped_directories:
285 if ("/" + sd + "/") in fpath:
286 break
287 else:
288 if is_shared_library(fpath):
289 # we don't check shared libraries
290 continue
291 else:
292 executables.append(fpath)
293 if enable_debug:
294 data_lines.append("executables: %s\n" % executables)
295
296 found_match = False
297 some_cmd_succeed = False
298 if not executables:
299 bb.debug(1, "No executable found for %s" % pn)
300 data_lines.append("FINAL RESULT: NO_EXECUTABLE_FOUND\n\n")
301 else:
302 # first we extend qemu_exec to include library path if needed
303 if extra_exec_libdirs:
304 qemu_exec += ":" + ":".join(extra_exec_libdirs)
305 for fexec in executables:
306 for version_option in ["--version", "-V", "-v", "--help"]:
307 version_check_cmd_full = "%s %s %s" % (qemu_exec, fexec, version_option)
308 version_check_cmd = version_check_cmd_full
309 #version_check_cmd = "%s %s" % (os.path.relpath(fexec, pkgd), version_option)
310
311 try:
312 cwd_temp = d.getVar("TMPDIR") + "/check-version-mismatch/cwd-temp/" + pn
313 os.makedirs(cwd_temp, exist_ok=True)
314 # avoid pseudo to manage any file we create
315 sp_env = os.environ.copy()
316 sp_env["PSEUDO_UNLOAD"] = "1"
317 output = subprocess.check_output(version_check_cmd_full,
318 shell=True,
319 stderr=subprocess.STDOUT,
320 cwd=cwd_temp,
321 timeout=10,
322 env=sp_env).decode("utf-8")
323 some_cmd_succeed = True
324 data_lines.append("version_check_cmd: %s\n" % version_check_cmd)
325 data_lines.append("output:\n'''\n%s'''\n" % output)
326 if version_option == "--help":
327 max_lines = 5
328 else:
329 max_lines = None
330 possible_versions = get_possible_versions(output, full_cmd=fexec, max_lines=max_lines)
331 if "." in pv:
332 possible_versions = [item for item in possible_versions if "." in item or item == "UNKNOWN"]
333 data_lines.append("possible versions: %s\n" % possible_versions)
334 if not possible_versions:
335 data_lines.append("result: NO_RUNTIME_VERSION_FOUND\n\n")
336 continue
337 possible_versions_all.extend(possible_versions)
338 possible_versions_all = sorted(set(possible_versions_all))
339 if is_version_mismatch(possible_versions, pv):
340 data_lines.append("result: MISMATCH (%s v.s. %s)\n\n" % (possible_versions, pv))
341 else:
342 found_match = True
343 data_lines.append("result: MATCH (%s v.s. %s)\n\n" % (possible_versions, pv))
344 break
345 except:
346 data_lines.append("version_check_cmd: %s\n" % version_check_cmd)
347 data_lines.append("result: RUN_FAILED\n\n")
348 finally:
349 shutil.rmtree(cwd_temp)
350 if found_match:
351 break
352 if executables:
353 if found_match:
354 data_lines.append("FINAL RESULT: MATCH (%s v.s. %s)\n" % (possible_versions_all, pv))
355 elif len(possible_versions_all) == 0:
356 if some_cmd_succeed:
357 bb.debug(1, "No valid runtime version found")
358 data_lines.append("FINAL RESULT: NO_VALID_RUNTIME_VERSION_FOUND\n")
359 else:
360 bb.debug(1, "All version check command failed")
361 data_lines.append("FINAL RESULT: RUN_FAILED\n")
362 else:
363 bb.warn("Possible runtime versions %s do not match recipe version %s" % (possible_versions_all, pv))
364 data_lines.append("FINAL RESULT: MISMATCH (%s v.s. %s)\n" % (possible_versions_all, pv))
365
366 if enable_debug:
367 with open(debug_data_file, "w") as f:
368 f.writelines(data_lines)
369
370 # clean up stale processes
371 process_name_common_prefix = "%s %s" % (' '.join(qemu_exec.split()[1:]), pkgd)
372 find_stale_process_cmd = "ps -e -o pid,args | grep -v grep | grep -F '%s'" % process_name_common_prefix
373 try:
374 stale_process_output = subprocess.check_output(find_stale_process_cmd, shell=True).decode("utf-8")
375 stale_process_pids = []
376 for line in stale_process_output.split("\n"):
377 line = line.strip()
378 if not line:
379 continue
380 pid = line.split()[0]
381 stale_process_pids.append(pid)
382 for pid in stale_process_pids:
383 os.kill(int(pid), signal.SIGKILL)
384 except Exception as e:
385 bb.debug(1, "No stale process")
386}
387
388addtask do_package_check_version_mismatch after do_package before do_build
389
390do_build[rdeptask] += "do_package_check_version_mismatch"
391do_rootfs[recrdeptask] += "do_package_check_version_mismatch"
392
393SSTATETASKS += "do_package_check_version_mismatch"
394do_package_check_version_mismatch[sstate-inputdirs] = ""
395do_package_check_version_mismatch[sstate-outputdirs] = ""
396python do_package_check_version_mismatch_setscene () {
397 sstate_setscene(d)
398}
399addtask do_package_check_version_mismatch_setscene
diff --git a/meta-oe/conf/version-check.conf b/meta-oe/conf/version-check.conf
new file mode 100644
index 0000000000..c41df0d496
--- /dev/null
+++ b/meta-oe/conf/version-check.conf
@@ -0,0 +1,22 @@
1INHERIT += "check-version-mismatch"
2# we need ps command to clean stale processes
3HOSTTOOLS += "ps"
4
5# Special cases that need to be handled.
6# % has the same meaning as in bbappend files, that is, match any chars.
7
8# oe-core
9CHECK_VERSION_PV:pn-rust-llvm = "${LLVM_RELEASE}"
10CHECK_VERSION_PV:pn-igt-gpu-tools = "${PV}-${PV}"
11CHECK_VERSION_PV:pn-vim = "${@'.'.join(d.getVar('PV').split('.')[:-1])}"
12CHECK_VERSION_PV:pn-vim-tiny = "${@'.'.join(d.getVar('PV').split('.')[:-1])}"
13CHECK_VERSION_PV:pn-ncurses = "${PV}.%"
14CHECK_VERSION_PV:pn-alsa-tools = "%"
15CHECK_VERSION_PV:pn-gst-examples = "%"
16CHECK_VERSION_PV:pn-libedit = "${@d.getVar('PV').split('-')[1]}"
17
18# meta-oe
19CHECK_VERSION_PV:pn-iozone3 = "3.${PV}"
20CHECK_VERSION_PV:pn-can-utils = "%"
21CHECK_VERSION_PV:pn-luajit = "${PV}.%"
22CHECK_VERSION_PV:pn-sg3-utils = "%"