Skip to content

Commit 4906e9d

Browse files
authored
ci: add versions fixture generator for Red Hat (google#1356)
This adds a `semantic` fixture generator for the Red Hat ecosystem - as this ends up being just under 2mb, I've not committed the file like the others, instead making the test suite only use it if it's actually present. It works by leveraging [`rpm`s built-in Lua interpreter](https://rpm-software-management.github.io/rpm/manual/lua.html), which includes exposing the `vercmp` function meaning we can have `rpm` tell us what the result of comparing two versions are so long as there's a version of that available. In terms of actually getting a version of `rpm`, that turns out to be a lot harder with Docker I assume just because CentOS & co is a less open-source-y distro vs the others; there are images, but they're all generally very big. In an interesting twist though it turns out Debian actually ships a version of `rpm` for building and the like - it has had it's package management abilities neutered, but that's not something we need so a simple `apt install rpm` gives us the dependency we need.
1 parent 4ba4a92 commit 4906e9d

File tree

4 files changed

+335
-0
lines changed

4 files changed

+335
-0
lines changed

.github/workflows/semantic.yml

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,39 @@ jobs:
5959
path: /tmp/debian-versions-generator-cache.csv
6060
key: ${{ runner.os }}-${{ hashFiles('debian-db.zip') }}
6161

62+
generate-redhat-versions:
63+
permissions:
64+
contents: read # to fetch code (actions/checkout)
65+
runs-on: ubuntu-latest
66+
steps:
67+
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
68+
69+
- uses: actions/cache/restore@3624ceb22c1c5a301c8db4169662070a689d9ea8 # v4.1.1
70+
with:
71+
path: /tmp/redhat-versions-generator-cache.csv
72+
key: ${{ runner.os }}-
73+
74+
- uses: actions/checkout@eef61447b9ff4aafe5dcd4e0bbf5d482be7e7871 # v4.2.1
75+
with:
76+
persist-credentials: false
77+
- uses: actions/setup-python@f677139bbe7f9c59b41e40162b753c062f5d49a3 # v5.2.0
78+
with:
79+
python-version: "3.10"
80+
- run: sudo apt install rpm
81+
- run: rpm --version
82+
- run: python3 scripts/generators/generate-redhat-versions.py
83+
- run: git status
84+
- run: stat redhat-db.zip
85+
- uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3
86+
with:
87+
name: generated-redhat-versions
88+
path: internal/semantic/fixtures/redhat-versions-generated.txt
89+
90+
- uses: actions/cache/save@3624ceb22c1c5a301c8db4169662070a689d9ea8 # v4.1.1
91+
with:
92+
path: /tmp/redhat-versions-generator-cache.csv
93+
key: ${{ runner.os }}-${{ hashFiles('redhat-db.zip') }}
94+
6295
generate-packagist-versions:
6396
permissions:
6497
contents: read # to fetch code (actions/checkout)
@@ -173,6 +206,7 @@ jobs:
173206
- generate-rubygems-versions
174207
- generate-maven-versions
175208
- generate-cran-versions
209+
- generate-redhat-versions
176210
if: always()
177211
steps:
178212
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,6 @@
99
*.pprof
1010
.go-version
1111
node_modules
12+
13+
# we don't want to check in this file as it's very very large
14+
/internal/semantic/fixtures/redhat-versions-generated.txt

internal/semantic/compare_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package semantic_test
22

33
import (
44
"bufio"
5+
"errors"
6+
"io/fs"
57
"os"
68
"strings"
79
"testing"
@@ -239,6 +241,23 @@ func TestVersion_Compare_Ecosystems(t *testing.T) {
239241
file: "redhat-versions.txt",
240242
},
241243
}
244+
245+
// we don't check the generated fixture for Red Hat in due to its size
246+
// so we only add it if it exists, so that people can have it locally
247+
// without needing to do a dance with git everytime they commit
248+
_, err := os.Stat("fixtures/redhat-versions-generated.txt")
249+
if err == nil {
250+
tests = append(tests, struct {
251+
name string
252+
file string
253+
}{
254+
name: "Red Hat",
255+
file: "redhat-versions-generated.txt",
256+
})
257+
} else if !errors.Is(err, fs.ErrNotExist) {
258+
t.Fatalf("fixtures/redhat-versions-generated.txt exists but could not be read: %v", err)
259+
}
260+
242261
for _, tt := range tests {
243262
t.Run(tt.name, func(t *testing.T) {
244263
t.Parallel()
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
#!/usr/bin/env python3
2+
3+
import json
4+
import operator
5+
import os
6+
import subprocess
7+
import sys
8+
import urllib.request
9+
import zipfile
10+
from pathlib import Path
11+
12+
# this requires being run on an OS that has a version of "rpm" installed which
13+
# supports evaluating Lua expressions (most versions do); also make sure to consider
14+
# the version of rpm being used in case there are changes to the comparing logic
15+
# (last run with 1.19.7).
16+
#
17+
# note that both alpine and debian have a "rpm" package that supports this, which
18+
# can be installed using "apk add rpm" and "apt install rpm" respectively.
19+
#
20+
# also note that because of the large amount of versions being used there is
21+
# significant overhead in having to use a subprocess, so this generator caches
22+
# the results of said subprocess calls; a typical no-cache run takes about 5+
23+
# minutes whereas with the cache it only takes seconds.
24+
25+
# An array of version comparisons that are known to be unsupported and so
26+
# should be commented out in the generated fixture.
27+
#
28+
# Generally this is because the native implementation has a suspected bug
29+
# that causes the comparison to return incorrect results, and so supporting
30+
# such comparisons in the detector would in fact be wrong.
31+
UNSUPPORTED_COMPARISONS = []
32+
33+
34+
def is_unsupported_comparison(line):
35+
return line in UNSUPPORTED_COMPARISONS
36+
37+
38+
def uncomment(line):
39+
if line.startswith("#"):
40+
return line[1:]
41+
if line.startswith("//"):
42+
return line[2:]
43+
return line
44+
45+
46+
def download_redhat_db():
47+
urllib.request.urlretrieve("https://osv-vulnerabilities.storage.googleapis.com/Red%20Hat/all.zip", "redhat-db.zip")
48+
49+
50+
def extract_packages_with_versions(osvs):
51+
dict = {}
52+
53+
for osv in osvs:
54+
for affected in osv['affected']:
55+
if 'package' not in affected or not affected['package']['ecosystem'].startswith('Red Hat'):
56+
continue
57+
58+
package = affected['package']['name']
59+
60+
if package not in dict:
61+
dict[package] = []
62+
63+
for version in affected.get('versions', []):
64+
dict[package].append(RedHatVersion(version))
65+
66+
for rang in affected.get('ranges', []):
67+
for event in rang['events']:
68+
if 'introduced' in event and event['introduced'] != '0':
69+
dict[package].append(RedHatVersion(event['introduced']))
70+
if 'fixed' in event:
71+
dict[package].append(RedHatVersion(event['fixed']))
72+
73+
# deduplicate and sort the versions for each package
74+
for package in dict:
75+
dict[package] = sorted(list(dict.fromkeys(dict[package])))
76+
77+
return dict
78+
79+
80+
class RedHatVersionComparer:
81+
def __init__(self, cache_path):
82+
self.cache_path = Path(cache_path)
83+
self.cache = {}
84+
85+
self._load_cache()
86+
87+
def _load_cache(self):
88+
if self.cache_path:
89+
self.cache_path.touch()
90+
with open(self.cache_path, "r") as f:
91+
lines = f.readlines()
92+
93+
for line in lines:
94+
line = line.strip()
95+
key, result = line.split(",")
96+
97+
if result == "True":
98+
self.cache[key] = True
99+
continue
100+
if result == "False":
101+
self.cache[key] = False
102+
continue
103+
104+
print(f"ignoring invalid cache entry '{line}'")
105+
106+
def _save_to_cache(self, key, result):
107+
self.cache[key] = result
108+
if self.cache_path:
109+
self.cache_path.touch()
110+
with open(self.cache_path, "a") as f:
111+
f.write(f"{key},{result}\n")
112+
113+
def _compare1(self, a, op, b):
114+
cmd = ["rpm", "--eval", f"%{{lua:print(rpm.vercmp('{a}', '{b}'))}}"]
115+
out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
116+
117+
if out.returncode != 0 or out.stderr:
118+
raise Exception(f"rpm did not like comparing {a} {op} {b}: {out.stderr.decode('utf-8')}")
119+
120+
r = out.stdout.decode('utf-8').strip()
121+
122+
if r == "0" and op == "=":
123+
return True
124+
elif r == "1" and op == ">":
125+
return True
126+
elif r == "-1" and op == "<":
127+
return True
128+
129+
return False
130+
131+
def _compare2(self, a, op, b):
132+
if op == "=":
133+
op = "==" # lua uses == for equality
134+
135+
cmd = ["rpm", "--eval", f"%{{lua:print(rpm.ver('{a}') {op} rpm.ver('{b}'))}}"]
136+
out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
137+
138+
if out.returncode != 0 or out.stderr:
139+
raise Exception(f"rpm did not like comparing {a} {op} {b}: {out.stderr.decode('utf-8')}")
140+
141+
r = out.stdout.decode('utf-8').strip()
142+
143+
if r == "true":
144+
return True
145+
elif r == "false":
146+
return False
147+
148+
raise Exception(f"unexpected result from rpm: {r}")
149+
150+
151+
def compare(self, a, op, b):
152+
key = f"{a} {op} {b}"
153+
if key in self.cache:
154+
return self.cache[key]
155+
156+
r = self._compare1(a, op, b)
157+
# r = self._compare2(a, op, b)
158+
159+
self._save_to_cache(key, r)
160+
return r
161+
162+
163+
redhat_comparer = RedHatVersionComparer("/tmp/redhat-versions-generator-cache.csv")
164+
165+
166+
class RedHatVersion:
167+
def __str__(self):
168+
return self.version
169+
170+
def __hash__(self):
171+
return hash(self.version)
172+
173+
def __init__(self, version):
174+
self.version = version
175+
176+
def __lt__(self, other):
177+
return redhat_comparer.compare(self.version, '<', other.version)
178+
179+
def __gt__(self, other):
180+
return redhat_comparer.compare(self.version, '>', other.version)
181+
182+
def __eq__(self, other):
183+
return redhat_comparer.compare(self.version, '=', other.version)
184+
185+
186+
def compare(v1, relate, v2):
187+
ops = {'<': operator.lt, '=': operator.eq, '>': operator.gt}
188+
return ops[relate](v1, v2)
189+
190+
191+
def compare_versions(lines, select="all"):
192+
has_any_failed = False
193+
194+
for line in lines:
195+
line = line.strip()
196+
197+
if line == "" or line.startswith('#') or line.startswith('//'):
198+
maybe_unsupported = uncomment(line).strip()
199+
200+
if is_unsupported_comparison(maybe_unsupported):
201+
print(f"\033[96mS\033[0m: \033[93m{maybe_unsupported}\033[0m")
202+
continue
203+
204+
v1, op, v2 = line.strip().split(" ")
205+
206+
r = compare(RedHatVersion(v1), op, RedHatVersion(v2))
207+
208+
if not r:
209+
has_any_failed = r
210+
211+
if select == "failures" and r:
212+
continue
213+
214+
if select == "successes" and not r:
215+
continue
216+
217+
color = '\033[92m' if r else '\033[91m'
218+
rs = "T" if r else "F"
219+
print(f"{color}{rs}\033[0m: \033[93m{line}\033[0m")
220+
return has_any_failed
221+
222+
223+
def compare_versions_in_file(filepath, select="all"):
224+
with open(filepath) as f:
225+
lines = f.readlines()
226+
return compare_versions(lines, select)
227+
228+
229+
def generate_version_compares(versions):
230+
comparisons = []
231+
for i, version in enumerate(versions):
232+
if i == 0:
233+
continue
234+
235+
comparison = f"{versions[i - 1]} < {version}\n"
236+
237+
if is_unsupported_comparison(comparison.strip()):
238+
comparison = "# " + comparison
239+
comparisons.append(comparison)
240+
return comparisons
241+
242+
243+
def generate_package_compares(packages):
244+
comparisons = []
245+
for package in packages:
246+
versions = packages[package]
247+
comparisons.extend(generate_version_compares(versions))
248+
249+
# return comparisons
250+
return list(dict.fromkeys(comparisons))
251+
252+
253+
def fetch_packages_versions():
254+
download_redhat_db()
255+
osvs = []
256+
257+
with zipfile.ZipFile('redhat-db.zip') as db:
258+
for fname in db.namelist():
259+
with db.open(fname) as osv:
260+
osvs.append(json.loads(osv.read().decode('utf-8')))
261+
262+
return extract_packages_with_versions(osvs)
263+
264+
265+
outfile = "internal/semantic/fixtures/redhat-versions-generated.txt"
266+
267+
packs = fetch_packages_versions()
268+
with open(outfile, "w") as f:
269+
f.writelines(generate_package_compares(packs))
270+
f.write("\n")
271+
272+
# set this to either "failures" or "successes" to only have those comparison results
273+
# printed; setting it to anything else will have all comparison results printed
274+
show = os.environ.get("VERSION_GENERATOR_PRINT", "failures")
275+
276+
did_any_fail = compare_versions_in_file(outfile, show)
277+
278+
if did_any_fail:
279+
sys.exit(1)

0 commit comments

Comments
 (0)