Skip to content

Commit 7d242c6

Browse files
authored
feat: add support for comparing Alpine versions (#299)
1 parent 18c147c commit 7d242c6

File tree

7 files changed

+15401
-2
lines changed

7 files changed

+15401
-2
lines changed
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 atexit
4+
import json
5+
import operator
6+
import os
7+
import subprocess
8+
import sys
9+
import urllib.request
10+
import zipfile
11+
from pathlib import Path
12+
13+
# this requires being run on an OS with docker available to run an alpine container
14+
# through which apk can be invoked to compare versions natively.
15+
#
16+
# this generator will attempt to run an alpine container in the background
17+
# for the lifetime of the generator that will be used to exec apk; this is a lot faster
18+
# than running a dedicated container for each invocation, but does mean the container
19+
# may need to be cleaned up manually if the generator explodes in a way that prevents
20+
# it from stopping the container before exiting.
21+
#
22+
# this generator also uses cache to store the results of comparisons given the large
23+
# volume of packages and versions to compare, which is stored in the /tmp directory.
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_alpine_db():
47+
urllib.request.urlretrieve('https://osv-vulnerabilities.storage.googleapis.com/Alpine/all.zip', 'alpine-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('Alpine'):
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(AlpineVersion(version))
65+
66+
# deduplicate and sort the versions for each package
67+
for package in dict:
68+
dict[package] = sorted(list(dict.fromkeys(dict[package])))
69+
70+
return dict
71+
72+
73+
class AlpineVersionComparer:
74+
def __init__(self, cache_path, how):
75+
self.cache_path = Path(cache_path)
76+
self.cache = {}
77+
78+
self._alpine_version = '3.10'
79+
self._compare_method = how
80+
self._docker_container = None
81+
self._load_cache()
82+
83+
def _start_docker_container(self):
84+
"""
85+
Starts the Alpine docker container for use in comparing versions using apk,
86+
assigning the name of the container to `self._docker_container` if success.
87+
88+
If a container has already been started, this does nothing.
89+
"""
90+
91+
if self._docker_container is not None:
92+
return
93+
94+
container_name = f'alpine-{self._alpine_version}-container'
95+
96+
cmd = ['docker', 'run', '--rm', '--name', container_name, '-d', f'alpine:{self._alpine_version}', 'tail', '-f', '/dev/null']
97+
out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
98+
99+
if out.returncode != 0:
100+
raise Exception(f'failed to start {container_name} container: {out.stderr.decode('utf-8')}')
101+
self._docker_container = container_name
102+
atexit.register(self._stop_docker_container)
103+
104+
def _stop_docker_container(self):
105+
if self._docker_container is None:
106+
raise Exception('called to stop docker container when none was started')
107+
108+
cmd = ['docker', 'stop', '-t', '0', self._docker_container]
109+
out = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
110+
111+
if out.returncode != 0:
112+
raise Exception(f'failed to stop {self._docker_container} container: {out.stderr.decode("utf-8")}')
113+
114+
def _load_cache(self):
115+
if self.cache_path:
116+
self.cache_path.touch()
117+
with open(self.cache_path, 'r') as f:
118+
lines = f.readlines()
119+
120+
for line in lines:
121+
line = line.strip()
122+
key, result = line.split(',')
123+
124+
if result == 'True':
125+
self.cache[key] = True
126+
continue
127+
if result == 'False':
128+
self.cache[key] = False
129+
continue
130+
131+
print(f"ignoring invalid cache entry '{line}'")
132+
133+
def _save_to_cache(self, key, result):
134+
self.cache[key] = result
135+
if self.cache_path:
136+
self.cache_path.touch()
137+
with open(self.cache_path, 'a') as f:
138+
f.write(f'{key},{result}\n')
139+
140+
def _compare_command(self, a, b):
141+
if self._compare_method == 'run':
142+
return ['docker', 'run', '--rm', f'alpine:{self._alpine_version}', 'apk', 'version', '-t', a, b]
143+
144+
self._start_docker_container()
145+
146+
return ['docker', 'exec', self._docker_container, 'apk', 'version', '-t', a, b]
147+
148+
def compare(self, a, op, b):
149+
key = f'{a} {op} {b}'
150+
if key in self.cache:
151+
return self.cache[key]
152+
153+
out = subprocess.run(self._compare_command(a, b), stdout=subprocess.PIPE, stderr=subprocess.PIPE)
154+
155+
if out.returncode != 0:
156+
raise Exception(f'apk did not like comparing {a} {op} {b}: {out.stderr.decode("utf-8")}')
157+
158+
r = out.stdout.decode('utf-8').strip() == op
159+
self._save_to_cache(key, r)
160+
return r
161+
162+
163+
alpine_comparer = AlpineVersionComparer('/tmp/alpine-versions-generator-cache.csv', 'exec')
164+
165+
166+
class AlpineVersion:
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 alpine_comparer.compare(self.version, '<', other.version)
178+
179+
def __gt__(self, other):
180+
return alpine_comparer.compare(self.version, '>', other.version)
181+
182+
def __eq__(self, other):
183+
return alpine_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(AlpineVersion(v1), op, AlpineVersion(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_alpine_db()
255+
osvs = []
256+
257+
with zipfile.ZipFile('alpine-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 = 'pkg/semantic/fixtures/alpine-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)

pkg/semantic/compare_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,14 @@ func TestVersion_Compare_Ecosystems(t *testing.T) {
226226
name: "CRAN",
227227
file: "cran-versions-generated.txt",
228228
},
229+
{
230+
name: "Alpine",
231+
file: "alpine-versions.txt",
232+
},
233+
{
234+
name: "Alpine",
235+
file: "alpine-versions-generated.txt",
236+
},
229237
}
230238
for _, tt := range tests {
231239
t.Run(tt.name, func(t *testing.T) {

0 commit comments

Comments
 (0)