Skip to content

Commit 20a7f6e

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 20a7f6e

5 files changed

Lines changed: 319 additions & 4 deletions

File tree

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/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",

src/gardenlinux/oci/image.py

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
OCI podman
5+
"""
6+
7+
import logging
8+
from os import PathLike
9+
from pathlib import Path
10+
from tarfile import open as tarfile_open
11+
from urllib.parse import urlencode
12+
from tempfile import TemporaryDirectory
13+
from typing import Any, Dict, List, Optional
14+
15+
from podman.domain.images import Image as _Image
16+
17+
from ..constants import (
18+
PODMAN_FS_CHANGE_ADDED,
19+
PODMAN_FS_CHANGE_DELETED,
20+
PODMAN_FS_CHANGE_MODIFIED,
21+
PODMAN_FS_CHANGE_UNSUPPORTED,
22+
)
23+
from .podman_context import PodmanContext
24+
from .podman_object_context import PodmanObjectContext
25+
26+
PODMAN_CHANGES_KINDS = {
27+
0: PODMAN_FS_CHANGE_MODIFIED,
28+
1: PODMAN_FS_CHANGE_ADDED,
29+
2: PODMAN_FS_CHANGE_DELETED,
30+
}
31+
32+
33+
class Image(PodmanObjectContext):
34+
"""
35+
Podman image class with extended API features support.
36+
37+
:author: Garden Linux Maintainers
38+
:copyright: Copyright 2024 SAP SE
39+
:package: gardenlinux
40+
:subpackage: oci
41+
:since: 1.0.0
42+
:license: https://www.apache.org/licenses/LICENSE-2.0
43+
Apache License, Version 2.0
44+
"""
45+
46+
def __init__(self, image: _Image, logger: Optional[logging.Logger] = None):
47+
"""
48+
Constructor __init__(Image)
49+
50+
:since: 1.0.0
51+
"""
52+
53+
PodmanObjectContext.__init__(self, logger)
54+
self._image_id = image.id
55+
56+
@property
57+
def id(self) -> str:
58+
"""
59+
podman-py.readthedocs.io: Returns the identifier for the object.
60+
61+
:return: (str) Identifier for the object
62+
:since: 1.0.0
63+
"""
64+
65+
return self._image_id # type: ignore[no-any-return]
66+
67+
@property
68+
@PodmanContext.wrap
69+
def labels(self, podman: PodmanContext) -> Dict[str, str]:
70+
"""
71+
podman-py.readthedocs.io: Returns the identifier for the object.
72+
73+
:return: (str) Identifier for the object
74+
:since: 1.0.0
75+
"""
76+
77+
return self._get(podman=podman).labels # type: ignore[no-any-return]
78+
79+
@property
80+
@PodmanContext.wrap
81+
def layer_image_ids(self, podman: PodmanContext) -> List[str]:
82+
"""
83+
Returns the podman image IDs of all parent layers.
84+
85+
:param podman: Podman context
86+
87+
:return: (list) Podman layer image IDs
88+
:since: 1.0.0
89+
"""
90+
91+
return [
92+
image_data["Id"]
93+
for image_data in self.history(podman=podman)
94+
if len(image_data["Id"]) == 64
95+
]
96+
97+
def __getattr__(
98+
self,
99+
name: str,
100+
) -> Any:
101+
"""
102+
python.org: Called when an attribute lookup has not found the attribute in
103+
the usual places (i.e. it is not an instance attribute nor is it found in the
104+
class tree for self).
105+
106+
:param name: Attribute name
107+
108+
:return: (mixed) Attribute
109+
:since: 1.0.0
110+
"""
111+
112+
@PodmanObjectContext.wrap
113+
def wrapped_context(podman: PodmanContext, *args: Any, **kwargs: Any) -> Any:
114+
"""
115+
Wrapping function to use the podman context.
116+
"""
117+
118+
py_attr = getattr(self._get(podman=podman), name)
119+
return py_attr(*args, **kwargs)
120+
121+
return wrapped_context
122+
123+
def _get(self, podman: PodmanContext) -> _Image:
124+
"""
125+
Returns the underlying podman image object.
126+
127+
:param podman: Podman context
128+
129+
:return: (podman.domains.images.Image) Podman image object
130+
:since: 1.0.0
131+
"""
132+
133+
return podman.images.get(self._image_id)
134+
135+
@PodmanContext.wrap
136+
def get_filesystem_changes(
137+
self, podman: PodmanContext, parent_layer_image_id: Optional[str] = None
138+
) -> Dict[str, List[str]]:
139+
"""
140+
Returns the underlying podman image object.
141+
142+
:param podman: Podman context
143+
144+
:return: (_Image) Podman image object
145+
:since: 1.0.0
146+
"""
147+
148+
changes: Dict[str, List[str]] = {
149+
PODMAN_FS_CHANGE_ADDED: [],
150+
PODMAN_FS_CHANGE_DELETED: [],
151+
PODMAN_FS_CHANGE_MODIFIED: [],
152+
PODMAN_FS_CHANGE_UNSUPPORTED: [],
153+
}
154+
155+
query = ""
156+
157+
if parent_layer_image_id is not None:
158+
query = urlencode({"parent": parent_layer_image_id})
159+
160+
resp = self._raw_request(
161+
"get", f"/images/{self._image_id}/changes?{query}", podman=podman
162+
)
163+
164+
resp.raise_for_status()
165+
166+
for entry in resp.json():
167+
changes[
168+
PODMAN_CHANGES_KINDS.get(entry["Kind"], PODMAN_FS_CHANGE_UNSUPPORTED)
169+
].append(entry["Path"])
170+
171+
return changes
172+
173+
@staticmethod
174+
@PodmanContext.wrap
175+
def import_plain_tar(
176+
tar_file_name: PathLike[str], podman: PodmanContext
177+
) -> str:
178+
"""
179+
Import a plain filesystem tar archive into an OCI image.
180+
181+
:param tar_file_name: Plain filesystem tar archive
182+
:param podman: Podman context
183+
184+
:return: (str) Podman image ID
185+
:since: 1.0.0
186+
"""
187+
188+
image_id = None
189+
190+
with TemporaryDirectory() as tmpdir:
191+
container_file_name = Path(tmpdir, "ContainerFile")
192+
tarfile_open(tar_file_name, dereference=True).extractall(
193+
path=Path(tmpdir, "archive_content"),
194+
filter="fully_trusted",
195+
numeric_owner=True,
196+
)
197+
198+
with container_file_name.open("w") as container_file:
199+
container_file.write("FROM scratch\nCOPY archive_content/ /")
200+
201+
image, _ = podman.images.build(path=tmpdir, dockerfile=container_file_name)
202+
image_id = image.id
203+
204+
return image_id # type: ignore[no-any-return]

src/gardenlinux/oci/podman.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@
1111
from pathlib import Path
1212
from typing import Any, Dict, List, Optional
1313

14+
1415
from ..logger import LoggerSetup
16+
from .image import Image
1517
from .podman_context import PodmanContext
1618

1719

@@ -116,12 +118,12 @@ def build_and_save_oci_archive(
116118
return {oci_archive_file_name.name: image_id}
117119

118120
@PodmanContext.wrap
119-
def get_image_id(
121+
def get_image(
120122
self,
121123
container: str,
122124
podman: PodmanContext,
123125
oci_tag: Optional[str] = None,
124-
) -> str:
126+
) -> Image:
125127
"""
126128
Returns the Podman image ID for a given OCI container tag.
127129
@@ -136,7 +138,22 @@ def get_image_id(
136138
else:
137139
container_tag += f":{oci_tag}"
138140

139-
image = podman.images.get(container_tag)
141+
return Image(podman.images.get(container_tag))
142+
143+
@PodmanContext.wrap
144+
def get_image_id(
145+
self,
146+
container: str,
147+
podman: PodmanContext,
148+
oci_tag: Optional[str] = None,
149+
) -> str:
150+
"""
151+
Returns the Podman image ID for a given OCI container tag.
152+
153+
:since: 1.0.0
154+
"""
155+
156+
image = self.get_image(container, oci_tag=oci_tag, podman=podman)
140157
return image.id # type: ignore[no-any-return]
141158

142159
@PodmanContext.wrap
@@ -202,7 +219,7 @@ def pull(
202219
kwargs["tag"] = oci_tag
203220

204221
image = podman.images.pull(container, **kwargs)
205-
return image.id
222+
return image.id # type: ignore[no-any-return]
206223

207224
@PodmanContext.wrap
208225
def push(
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
OCI podman context
5+
"""
6+
7+
import logging
8+
from functools import wraps
9+
from typing import Any, Optional
10+
11+
from requests import Response
12+
13+
from ..logger import LoggerSetup
14+
from .podman_context import PodmanContext
15+
16+
17+
class PodmanObjectContext(object):
18+
"""
19+
Podman object context handles access to the podman context for API calls.
20+
21+
:author: Garden Linux Maintainers
22+
:copyright: Copyright 2024 SAP SE
23+
:package: gardenlinux
24+
:subpackage: oci
25+
:since: 1.0.0
26+
:license: https://www.apache.org/licenses/LICENSE-2.0
27+
Apache License, Version 2.0
28+
"""
29+
30+
def __init__(self, logger: Optional[logging.Logger] = None):
31+
"""
32+
Constructor __init__(PodmanObjectContext)
33+
34+
:since: 1.0.0
35+
"""
36+
37+
if logger is None or not logger.hasHandlers():
38+
logger = LoggerSetup.get_logger("gardenlinux.oci")
39+
40+
self._logger = logger
41+
42+
@PodmanContext.wrap
43+
def _raw_request(
44+
self,
45+
method: str,
46+
path_and_parameters: str,
47+
podman: PodmanContext,
48+
**kwargs: Any,
49+
) -> Response:
50+
"""
51+
Returns the podman API response for the request given.
52+
53+
:param method: Podman API method
54+
:param path_and_parameters: Podman API path and query parameters
55+
:param podman: Podman context
56+
57+
:return: (Response) Podman API response
58+
:since: 1.0.0
59+
"""
60+
61+
method_callable = getattr(podman.api, method)
62+
return method_callable(path_and_parameters, **kwargs) # type: ignore[no-any-return]
63+
64+
@staticmethod
65+
def wrap(f: Any) -> Any:
66+
"""
67+
Wraps the given function to provide access to a podman client.
68+
69+
:since: 1.0.0
70+
"""
71+
72+
@wraps(f)
73+
@PodmanContext.wrap
74+
def decorator(*args: Any, **kwargs: Any) -> Any:
75+
"""
76+
Decorator for wrapping a function or method with a call context.
77+
"""
78+
79+
podman = kwargs.get("podman")
80+
81+
if podman is None:
82+
raise RuntimeError("Podman context not ready")
83+
84+
del kwargs["podman"]
85+
86+
return f(podman=podman, *args, **kwargs)
87+
88+
return decorator

0 commit comments

Comments
 (0)