Skip to content

Commit 242e613

Browse files
committed
Add support to analyze OCI image layers with Podman.
Signed-off-by: Tobias Wolf <wolf@b1-systems.de> On-behalf-of: SAP <tobias.wolf@sap.com>
1 parent ba4cfd4 commit 242e613

File tree

6 files changed

+382
-124
lines changed

6 files changed

+382
-124
lines changed

src/gardenlinux/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,10 @@
167167
GLVD_BASE_URL = "https://security.gardenlinux.org/v1"
168168

169169
PODMAN_CONNECTION_MAX_IDLE_SECONDS = 3
170+
PODMAN_FS_CHANGE_ADDED = "added"
171+
PODMAN_FS_CHANGE_DELETED = "deleted"
172+
PODMAN_FS_CHANGE_MODIFIED = "modified"
173+
PODMAN_FS_CHANGE_UNSUPPORTED = "unsupported"
170174

171175
# https://github.com/gardenlinux/gardenlinux/issues/3044
172176
# Empty string is the 'legacy' variant with traditional root fs and still needed/supported

src/gardenlinux/features/reproducibility/comparator.py

Lines changed: 66 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,15 @@
99
import importlib.resources
1010
import json
1111
import re
12-
import tarfile
13-
import tempfile
1412
from os import PathLike
1513
from pathlib import Path
16-
from typing import Optional
14+
15+
from ...constants import (
16+
PODMAN_FS_CHANGE_ADDED,
17+
PODMAN_FS_CHANGE_DELETED,
18+
PODMAN_FS_CHANGE_MODIFIED,
19+
)
20+
from ...oci import Image, Podman, PodmanContext
1721

1822

1923
class Comparator(object):
@@ -29,120 +33,27 @@ class Comparator(object):
2933
Apache License, Version 2.0
3034
"""
3135

32-
_default_whitelist: list[str] = []
33-
34-
_nightly_whitelist = json.loads(
35-
importlib.resources.read_text(__name__, "nightly_whitelist.json")
36-
)
37-
38-
def __init__(
39-
self, nightly: bool = False, whitelist: list[str] = _default_whitelist
40-
):
36+
def __init__(self, nightly: bool = False, whitelist: list[str] = []):
4137
"""
4238
Constructor __init__(Comparator)
4339
4440
:param nightly: Flag indicating if the nightlywhitelist should be used
4541
:param whitelst: Additional whitelist
4642
47-
:since: 1.0.0
48-
"""
49-
self.whitelist = whitelist
50-
if nightly:
51-
self.whitelist += self._nightly_whitelist
52-
53-
@staticmethod
54-
def _unpack(file: PathLike[str]) -> tempfile.TemporaryDirectory[str]:
55-
"""
56-
Unpack a .tar archive or .oci image into a temporary dictionary
57-
58-
:param file: .tar or .oci file
59-
60-
:return: TemporaryDirectory Temporary directory containing the unpacked file
6143
:since: 1.0.0
6244
"""
6345

64-
output_dir = tempfile.TemporaryDirectory()
65-
file = Path(file).resolve()
66-
if file.name.endswith(".oci"):
67-
with tempfile.TemporaryDirectory() as extracted:
68-
# Extract .oci file
69-
with tarfile.open(file, "r") as tar:
70-
tar.extractall(
71-
path=extracted, filter="fully_trusted", members=tar.getmembers()
72-
)
73-
74-
layers_dir = Path(extracted).joinpath("blobs/sha256")
75-
assert layers_dir.is_dir()
76-
77-
with open(Path(extracted).joinpath("index.json"), "r") as f:
78-
index = json.load(f)
79-
80-
# Only support first manifest
81-
manifest = index["manifests"][0]["digest"].split(":")[1]
82-
83-
with open(layers_dir.joinpath(manifest), "r") as f:
84-
manifest = json.load(f)
85-
86-
layers = [layer["digest"].split(":")[1] for layer in manifest["layers"]]
87-
88-
# Extract layers in order
89-
for layer in layers:
90-
layer_path = layers_dir.joinpath(layer)
91-
if tarfile.is_tarfile(layer_path):
92-
with tarfile.open(layer_path, "r") as tar:
93-
for member in tar.getmembers():
94-
try:
95-
tar.extract(
96-
member,
97-
path=output_dir.name,
98-
filter="fully_trusted",
99-
)
100-
except tarfile.AbsoluteLinkError:
101-
# Convert absolute link to relative link
102-
member.linkpath = (
103-
"../" * member.path.count("/")
104-
+ member.linkpath[1:]
105-
)
106-
tar.extract(
107-
member,
108-
path=output_dir.name,
109-
filter="fully_trusted",
110-
)
111-
except tarfile.TarError as e:
112-
print(f"Skipping {member.name} due to error: {e}")
113-
else:
114-
with tarfile.open(file, "r") as tar:
115-
tar.extractall(
116-
path=output_dir.name,
117-
filter="fully_trusted",
118-
members=tar.getmembers(),
119-
)
120-
121-
return output_dir
122-
123-
def _diff_files(
124-
self, cmp: filecmp.dircmp[str], left_root: Optional[Path] = None
125-
) -> list[str]:
126-
"""
127-
Recursively compare files
128-
129-
:param cmp: Dircmp to recursively compare
130-
:param left_root: Left root to obtain the archive relative path
131-
132-
:return: list[Path] List of paths with different content
133-
:since: 1.0.0
134-
"""
135-
136-
result = []
137-
if not left_root:
138-
left_root = Path(cmp.left)
139-
for name in cmp.diff_files:
140-
result.append(f"/{Path(cmp.left).relative_to(left_root).joinpath(name)}")
141-
for sub_cmp in cmp.subdirs.values():
142-
result += self._diff_files(sub_cmp, left_root=left_root)
143-
return result
46+
self.whitelist = whitelist
14447

145-
def generate(self, a: PathLike[str], b: PathLike[str]) -> tuple[list[str], bool]:
48+
if nightly:
49+
self.whitelist += json.loads(
50+
importlib.resources.read_text(__name__, "nightly_whitelist.json")
51+
)
52+
53+
@PodmanContext.wrap
54+
def generate(
55+
self, a: PathLike[str], b: PathLike[str], podman: PodmanContext
56+
) -> tuple[list[str], bool]:
14657
"""
14758
Compare two .tar/.oci images with each other
14859
@@ -156,16 +67,51 @@ def generate(self, a: PathLike[str], b: PathLike[str]) -> tuple[list[str], bool]
15667
if filecmp.cmp(a, b, shallow=False):
15768
return [], False
15869

159-
with self._unpack(a) as unpacked_a, self._unpack(b) as unpacked_b:
160-
cmp = filecmp.dircmp(unpacked_a, unpacked_b, shallow=False)
161-
162-
diff_files = self._diff_files(cmp)
163-
164-
filtered = [
165-
file
166-
for file in diff_files
167-
if not any(re.match(pattern, file) for pattern in self.whitelist)
168-
]
169-
whitelist = len(diff_files) != len(filtered)
170-
171-
return filtered, whitelist
70+
a = Path(a)
71+
a_image_id = None
72+
73+
b = Path(b)
74+
b_image_id = None
75+
76+
differences = []
77+
podman_api = Podman()
78+
79+
try:
80+
if a.suffix == ".oci":
81+
a_image_id = podman_api.load_oci_archive(a, podman=podman)
82+
elif a.suffix == ".tar":
83+
a_image_id = Image.import_plain_tar(a, podman=podman)
84+
else:
85+
raise RuntimeError(f"Unsupported file type for comparison: {a.name}")
86+
87+
if b.suffix == ".oci":
88+
b_image_id = podman_api.load_oci_archive(b, podman=podman)
89+
elif b.suffix == ".tar":
90+
b_image_id = Image.import_plain_tar(b, podman=podman)
91+
else:
92+
raise RuntimeError(f"Unsupported file type for comparison: {b.name}")
93+
94+
image = podman_api.get_image(a_image_id, podman=podman)
95+
96+
result = image.get_filesystem_changes(
97+
parent_layer_image_id=b_image_id, podman=podman
98+
)
99+
100+
differences = (
101+
result[PODMAN_FS_CHANGE_ADDED] + result[PODMAN_FS_CHANGE_DELETED]
102+
)
103+
104+
whitelist = False
105+
106+
for entry in result[PODMAN_FS_CHANGE_MODIFIED]:
107+
if not any(re.match(pattern, entry) for pattern in self.whitelist):
108+
differences.append(entry)
109+
else:
110+
whitelist = True
111+
finally:
112+
if a_image_id is not None:
113+
podman.images.remove(a_image_id)
114+
if b_image_id is not None:
115+
podman.images.remove(b_image_id)
116+
117+
return differences, whitelist

src/gardenlinux/oci/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
"""
66

77
from .container import Container
8+
from .image import Image
89
from .image_manifest import ImageManifest
910
from .index import Index
1011
from .layer import Layer
@@ -15,6 +16,7 @@
1516
__all__ = [
1617
"Container",
1718
"ImageManifest",
19+
"Image",
1820
"Index",
1921
"Layer",
2022
"Manifest",

0 commit comments

Comments
 (0)