Skip to content

Commit b5e0dc0

Browse files
committed
Implement feedback, implement difference generator, restructure code
1 parent 9abe888 commit b5e0dc0

File tree

753 files changed

+663
-238
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

753 files changed

+663
-238
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ sphinx-rtd-theme = "^3.0.2"
4141

4242
[tool.poetry.scripts]
4343
gl-cname = "gardenlinux.features.cname_main:main"
44-
gl-diff = "gardenlinux.features.difference_formatter_main:main"
44+
gl-diff = "gardenlinux.features.reproducibility.__main__:main"
4545
gl-features-parse = "gardenlinux.features.__main__:main"
4646
gl-flavors-parse = "gardenlinux.flavors.__main__:main"
4747
gl-gh-release = "gardenlinux.github.release.__main__:main"

src/gardenlinux/features/difference_formatter_main.py

Lines changed: 0 additions & 55 deletions
This file was deleted.

src/gardenlinux/features/parser.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ def graph(self) -> networkx.Graph:
8484

8585
if self._graph is None:
8686
feature_yaml_files = glob("{0}/*/info.yaml".format(self._feature_base_dir))
87-
features = [self._read_feature_yaml(i) for i in feature_yaml_files]
87+
features = [self.read_feature_yaml(i) for i in feature_yaml_files]
8888

8989
feature_graph = networkx.DiGraph()
9090

@@ -345,7 +345,7 @@ def _get_node_features(self, node: Dict[str, Any]) -> Dict[str, Any]:
345345

346346
return node.get("content", {}).get("features", {}) # type: ignore[no-any-return]
347347

348-
def _read_feature_yaml(self, feature_yaml_file: str) -> Dict[str, Any]:
348+
def read_feature_yaml(self, feature_yaml_file: str) -> Dict[str, Any]:
349349
"""
350350
Reads and returns the content of the given features file.
351351
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
#!/usr/bin/env python3
2+
# -*- coding: utf-8 -*-
3+
4+
"""
5+
gl-diff main entrypoint
6+
"""
7+
8+
import argparse
9+
import json
10+
import pathlib
11+
from os.path import basename, dirname
12+
13+
from .comparator import Comparator
14+
from .markdown_formatter import MarkdownFormatter
15+
16+
17+
def generate(args) -> None:
18+
"""
19+
Call Comparator
20+
21+
:param args: Parsed args
22+
23+
:since: 1.0.0
24+
"""
25+
26+
comparator = Comparator(nightly=args.nightly)
27+
28+
files, whitelist = comparator.generate(args.a, args.b)
29+
30+
result = "\n".join(files)
31+
32+
if files == [] and whitelist:
33+
result = "whitelist"
34+
35+
if result != "":
36+
result += "\n"
37+
38+
print(result, end="")
39+
40+
if files != []:
41+
exit(1)
42+
43+
44+
def format(args) -> None:
45+
"""
46+
Call MarkdownFormatter
47+
48+
:param args: Parsed args
49+
50+
:since: 1.0.0
51+
"""
52+
53+
gardenlinux_root = dirname(args.feature_dir)
54+
55+
if gardenlinux_root == "":
56+
gardenlinux_root = "."
57+
58+
feature_dir_name = basename(args.feature_dir)
59+
60+
formatter = MarkdownFormatter(
61+
json.loads(args.flavors_matrix),
62+
json.loads(args.bare_flavors_matrix),
63+
pathlib.Path(args.diff_dir),
64+
pathlib.Path(args.nightly_stats),
65+
gardenlinux_root,
66+
feature_dir_name,
67+
)
68+
69+
print(str(formatter), end="")
70+
71+
72+
def main() -> None:
73+
"""
74+
gl-diff main()
75+
76+
:since: 1.0.0
77+
"""
78+
79+
parser = argparse.ArgumentParser()
80+
81+
subparser = parser.add_subparsers(
82+
title="Options",
83+
description="You can eiter generate the comparison result or format the result to markdown.",
84+
required=True,
85+
)
86+
87+
generate_parser = subparser.add_parser("generate")
88+
generate_parser.add_argument("--nightly", action="store_true")
89+
generate_parser.add_argument("a")
90+
generate_parser.add_argument("b")
91+
generate_parser.set_defaults(func=generate)
92+
93+
format_parser = subparser.add_parser("format")
94+
format_parser.add_argument("--feature-dir", default="features")
95+
format_parser.add_argument("--diff-dir", default="diffs")
96+
format_parser.add_argument("--nightly-stats", default="nightly_stats")
97+
format_parser.add_argument("flavors_matrix")
98+
format_parser.add_argument("bare_flavors_matrix")
99+
format_parser.set_defaults(func=format)
100+
101+
args = parser.parse_args()
102+
args.func(args)
103+
104+
105+
if __name__ == "__main__":
106+
main()
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
diff-files comparator generating the list of files for reproducibility test workflow
5+
"""
6+
7+
import filecmp
8+
import json
9+
import re
10+
import tarfile
11+
import tempfile
12+
from os import PathLike
13+
from pathlib import Path
14+
15+
16+
class Comparator(object):
17+
"""
18+
This class takes either two .tar or two .oci files and identifies differences in the filesystems
19+
20+
:author: Garden Linux Maintainers
21+
:copyright: Copyright 2026 SAP SE
22+
:package: gardenlinux
23+
:subpackage: features
24+
:since: 1.0.0
25+
:license: https://www.apache.org/licenses/LICENSE-2.0
26+
Apache License, Version 2.0
27+
"""
28+
29+
_default_whitelist = []
30+
31+
_nightly_whitelist = [
32+
r"/etc/apt/sources\.list\.d/gardenlinux\.sources",
33+
r"/etc/os-release",
34+
r"/etc/shadow",
35+
r"/etc/update-motd\.d/05-logo",
36+
r"/var/lib/apt/lists/packages\.gardenlinux\.io_gardenlinux_dists_[0-9]*\.[0-9]*\.[0-9]*_.*",
37+
r"/var/lib/apt/lists/packages\.gardenlinux\.io_gardenlinux_dists_[0-9]*\.[0-9]*\.[0-9]*_main_binary-(arm64|amd64)_Packages",
38+
r"/efi/loader/entries/Default-[0-9]*\.[0-9]*\.[0-9]*-(cloud-)?(arm64|amd64)\.conf",
39+
r"/efi/Default/[0-9]*\.[0-9]*\.[0-9]*-(cloud-)?(arm64|amd64)/initrd",
40+
r"/boot/initrd\.img-[0-9]*\.[0-9]*\.[0-9]*-(cloud-)?(arm64|amd64)",
41+
]
42+
43+
def __init__(
44+
self, nightly: bool = False, whitelist: list[str] = _default_whitelist
45+
):
46+
"""
47+
Constructor __init__(Comparator)
48+
49+
:param nightly: Flag indicating if the nightlywhitelist should be used
50+
:param whitelst: Additional whitelist
51+
52+
:since: 1.0.0
53+
"""
54+
self.whitelist = whitelist
55+
if nightly:
56+
self.whitelist += self._nightly_whitelist
57+
58+
@staticmethod
59+
def _unpack(file: PathLike[str]) -> tempfile.TemporaryDirectory:
60+
"""
61+
Unpack a .tar archive or .oci image into a temporary dictionary
62+
63+
:param file: .tar or .oci file
64+
65+
:return: TemporaryDirectory Temporary directory containing the unpacked file
66+
:since: 1.0.0
67+
"""
68+
69+
output_dir = tempfile.TemporaryDirectory()
70+
file = Path(file)
71+
if file.name.endswith(".oci"):
72+
with tempfile.TemporaryDirectory() as extracted:
73+
# Extract .oci file
74+
with tarfile.open(file, "r") as tar:
75+
tar.extractall(path=extracted)
76+
77+
layers_dir = Path(extracted).joinpath("blobs/sha256")
78+
assert layers_dir.is_dir()
79+
80+
with open(Path(extracted).joinpath("index.json"), "r") as f:
81+
index = json.load(f)
82+
83+
# Only support first manifest
84+
manifest = index["manifests"][0]["digest"].split(":")[1]
85+
86+
with open(layers_dir.joinpath(manifest), "r") as f:
87+
manifest = json.load(f)
88+
89+
layers = [layer["digest"].split(":")[1] for layer in manifest["layers"]]
90+
91+
# Extract layers in order
92+
for layer in layers:
93+
layer_path = layers_dir.joinpath(layer)
94+
if tarfile.is_tarfile(layer_path):
95+
with tarfile.open(layer_path, "r") as tar:
96+
for member in tar.getmembers():
97+
try:
98+
tar.extract(member, path=output_dir.name)
99+
except tarfile.AbsoluteLinkError:
100+
# Convert absolute link to relative link
101+
member.linkpath = (
102+
"../" * member.path.count("/")
103+
+ member.linkpath[1:]
104+
)
105+
tar.extract(member, path=output_dir.name)
106+
except tarfile.TarError as e:
107+
print(f"Skipping {member.name} due to error: {e}")
108+
else:
109+
with tarfile.open(file, "r") as tar:
110+
tar.extractall(path=output_dir.name, filter="fully_trusted")
111+
112+
return output_dir
113+
114+
def _diff_files(
115+
self, cmp: filecmp.dircmp, left_root: PathLike[str] = None
116+
) -> list[Path]:
117+
"""
118+
Recursively compare files
119+
120+
:param cmp: Dircmp to recursively compare
121+
:param left_root: Left root to obtain the archive relative path
122+
123+
:return: list[Path] List of paths with different content
124+
:since: 1.0.0
125+
"""
126+
127+
result = []
128+
if not left_root:
129+
left_root = cmp.left
130+
for name in cmp.diff_files:
131+
result.append(f"/{Path(cmp.left).relative_to(left_root).joinpath(name)}")
132+
for sub_cmp in cmp.subdirs.values():
133+
result += self._diff_files(sub_cmp, left_root=left_root)
134+
return result
135+
136+
def generate(self, a: PathLike[str], b: PathLike[str]) -> tuple[list[Path], bool]:
137+
"""
138+
Compare two .tar/.oci images with each other
139+
140+
:param a: First .tar/.oci file
141+
:param b: Second .tar/.oci file
142+
143+
:return: list[Path], bool Filtered list of paths with different content and flag indicating if whitelist was applied
144+
:since: 1.0.0
145+
"""
146+
147+
if filecmp.cmp(a, b):
148+
return []
149+
150+
with self._unpack(a) as unpacked_a, self._unpack(b) as unpacked_b:
151+
cmp = filecmp.dircmp(unpacked_a, unpacked_b, shallow=False)
152+
153+
diff_files = self._diff_files(cmp)
154+
155+
filtered = [
156+
file
157+
for file in diff_files
158+
if not any(re.match(pattern, file) for pattern in self.whitelist)
159+
]
160+
whitelist = len(diff_files) != len(filtered)
161+
162+
return filtered, whitelist

0 commit comments

Comments
 (0)