Skip to content

Commit 41c4a3a

Browse files
committed
Add initial support to interact with a local podman installation
Signed-off-by: Tobias Wolf <[email protected]>
1 parent 6ba28d5 commit 41c4a3a

File tree

6 files changed

+439
-139
lines changed

6 files changed

+439
-139
lines changed

poetry.lock

Lines changed: 189 additions & 133 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pyproject.toml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ packages = [{ include = "gardenlinux", from = "src" }]
1010
[tool.poetry.dependencies]
1111
python = "^3.13"
1212
apt-repo = "^0.5"
13-
boto3 = "^1.40.30"
13+
boto3 = "^1.40.43"
1414
click = "^8.2.1"
15-
cryptography = "^46.0.1"
15+
cryptography = "^46.0.2"
1616
jsonschema = "^4.25.1"
1717
networkx = "^3.5"
1818
oras = "^0.2.38"
19+
podman = "^5.6.0"
1920
pygit2 = "^1.18.2"
2021
pygments = "^2.19.2"
2122
PyYAML = "^6.0.2"
@@ -25,13 +26,13 @@ retrying = "^1.4.2"
2526
[tool.poetry.group.dev.dependencies]
2627
bandit = "^1.8.6"
2728
black = "^25.1.0"
29+
isort = "^6.1.0"
2830
moto = "^5.1.12"
31+
pyright = "^1.1.406"
2932
python-dotenv = "^1.1.1"
3033
pytest = "^8.4.1"
3134
pytest-cov = "^7.0.0"
32-
isort = "^6.0.1"
3335
requests-mock = "^1.12.1"
34-
pyright = "^1.1.403"
3536

3637
[tool.poetry.group.docs.dependencies]
3738
sphinx-rtd-theme = "^3.0.2"

src/gardenlinux/constants.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,11 +153,15 @@
153153

154154
S3_DOWNLOADS_DIR = Path(os.path.dirname(__file__)) / ".." / "s3_downloads"
155155

156-
GLVD_BASE_URL = "https://glvd.ingress.glvd.gardnlinux.shoot.canary.k8s-hana.ondemand.com/v1"
157156
GL_DEB_REPO_BASE_URL = "https://packages.gardenlinux.io/gardenlinux"
157+
GLVD_BASE_URL = (
158+
"https://glvd.ingress.glvd.gardnlinux.shoot.canary.k8s-hana.ondemand.com/v1"
159+
)
158160

159161
GARDENLINUX_GITHUB_RELEASE_BUCKET_NAME = "gardenlinux-github-releases"
160162

163+
PODMAN_CONNECTION_MAX_IDLE_SECONDS = 3
164+
161165
# https://github.com/gardenlinux/gardenlinux/issues/3044
162166
# Empty string is the 'legacy' variant with traditional root fs and still needed/supported
163167
IMAGE_VARIANTS = ["", "_usi", "_tpm2_trustedboot"]

src/gardenlinux/oci/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@
99
from .index import Index
1010
from .layer import Layer
1111
from .manifest import Manifest
12+
from .podman import Podman
1213

13-
__all__ = ["Container", "ImageManifest", "Index", "Layer", "Manifest"]
14+
__all__ = ["Container", "ImageManifest", "Index", "Layer", "Manifest", "Podman"]

src/gardenlinux/oci/podman.py

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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 typing import Dict, Optional
11+
12+
from .podman_context import PodmanContext
13+
from ..logger import LoggerSetup
14+
15+
16+
class Podman(object):
17+
"""
18+
OCI podman provides access to an local podman installation.
19+
20+
:author: Garden Linux Maintainers
21+
:copyright: Copyright 2024 SAP SE
22+
:package: gardenlinux
23+
:subpackage: oci
24+
:since: 0.11.0
25+
:license: https://www.apache.org/licenses/LICENSE-2.0
26+
Apache License, Version 2.0
27+
"""
28+
29+
def __init__(self, logger: Optional[logging.Logger] = None):
30+
"""
31+
Constructor __init__(Podman)
32+
33+
:since: 0.11.0
34+
"""
35+
36+
if logger is None or not logger.hasHandlers():
37+
logger = LoggerSetup.get_logger("gardenlinux.oci")
38+
39+
self._logger = logger
40+
41+
@PodmanContext.wrap
42+
def load_oci_archives_from_directory(
43+
self, oci_dir: str | PathLike[str], /, podman: PodmanContext
44+
) -> Dict[str, str]:
45+
oci_archives = {}
46+
oci_dir = Path(oci_dir)
47+
48+
for oci_archive in oci_dir.iterdir():
49+
if not oci_archive.match("*.oci"):
50+
continue
51+
52+
image = next(podman.images.load(file_path=oci_archive))
53+
oci_archives[oci_archive.name] = image.id
54+
55+
return oci_archives
56+
57+
@PodmanContext.wrap
58+
def save_oci_archive_to_directory(
59+
self, image_id: str, oci_archive_file_name: str | PathLike[str], /, podman: PodmanContext
60+
):
61+
oci_archive_file_name = Path(oci_archive_file_name)
62+
63+
if oci_archive_file_name.exists():
64+
raise RuntimeError("OCI archive file does already exist")
65+
66+
image = podman.images.get(image_id)
67+
68+
with oci_archive_file_name.open("wb") as fp:
69+
fp.write(image.save(chunk_size = None, named=True))
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# -*- coding: utf-8 -*-
2+
3+
"""
4+
OCI podman context
5+
"""
6+
7+
import logging
8+
from contextlib import ExitStack
9+
from functools import wraps
10+
from os import rmdir
11+
from pathlib import Path
12+
from subprocess import PIPE, Popen, STDOUT
13+
from tempfile import mkdtemp
14+
from time import sleep
15+
from typing import Any, Optional
16+
17+
from podman.client import PodmanClient
18+
19+
from ..constants import PODMAN_CONNECTION_MAX_IDLE_SECONDS
20+
from ..logger import LoggerSetup
21+
22+
23+
class PodmanContext(ExitStack):
24+
"""
25+
OCI podman context provides a context manager to be used to interact with
26+
the podman API from Python.
27+
28+
:author: Garden Linux Maintainers
29+
:copyright: Copyright 2024 SAP SE
30+
:package: gardenlinux
31+
:subpackage: oci
32+
:since: 0.11.0
33+
:license: https://www.apache.org/licenses/LICENSE-2.0
34+
Apache License, Version 2.0
35+
"""
36+
37+
def __init__(self, logger: Optional[logging.Logger] = None):
38+
"""
39+
Constructor __init__(PodmanContext)
40+
41+
:since: 0.11.0
42+
"""
43+
44+
ExitStack.__init__(self)
45+
46+
if logger is None or not logger.hasHandlers():
47+
logger = LoggerSetup.get_logger("gardenlinux.oci")
48+
49+
self._logger = logger
50+
self._podman = None
51+
self._podman_daemon = None
52+
self._tmpdir = None
53+
54+
def __enter__(self):
55+
"""
56+
python.org: Enter the runtime context related to this object.
57+
58+
:return: (object) Podman context instance
59+
:since: 0.11.0
60+
"""
61+
62+
self._tmpdir = mkdtemp()
63+
64+
podman_sock = str(Path(self._tmpdir, "podman.sock"))
65+
66+
self._podman = PodmanClient(base_url=f"unix://{podman_sock}")
67+
self._podman_daemon = Popen(
68+
args=[
69+
"py-podman",
70+
"system",
71+
"service",
72+
f"--time={PODMAN_CONNECTION_MAX_IDLE_SECONDS}",
73+
f"unix://{podman_sock}",
74+
],
75+
executable="podman",
76+
stdout=PIPE,
77+
stderr=STDOUT,
78+
)
79+
80+
self.enter_context(self._podman_daemon)
81+
self._wait_for_socket(podman_sock)
82+
self.enter_context(self._podman)
83+
84+
return self
85+
86+
def __exit__(self, exc_type, exc_value, traceback):
87+
"""
88+
python.org: Exit the runtime context related to this object.
89+
90+
:return: (bool) True to suppress exceptions
91+
:since: 0.11.0
92+
"""
93+
94+
try:
95+
self._podman_daemon.terminate()
96+
self._podman_daemon.wait(PODMAN_CONNECTION_MAX_IDLE_SECONDS)
97+
98+
if exc_type is not None:
99+
stdout = self._podman_daemon.stdout.read()
100+
self._logger.error(
101+
f"Podman context encountered an error. Process output: {stdout}"
102+
)
103+
finally:
104+
self._podman_daemon = None
105+
106+
rmdir(self._tmpdir)
107+
self._tmpdir = None
108+
109+
return False
110+
111+
def __getattr__(self, name: str) -> Any:
112+
"""
113+
python.org: Called when an attribute lookup has not found the attribute in
114+
the usual places (i.e. it is not an instance attribute nor is it found in the
115+
class tree for self).
116+
117+
:param name: Attribute name
118+
119+
:return: (mixed) Attribute
120+
:since: 0.11.0
121+
"""
122+
123+
if self._podman_daemon is None:
124+
raise RuntimeError("Podman context not ready")
125+
126+
return getattr(self._podman, name)
127+
128+
def _wait_for_socket(self, sock: str):
129+
"""
130+
Waits for the socket file to be created.
131+
132+
:since: 0.11.0
133+
"""
134+
135+
sock_path = Path(sock)
136+
137+
for _ in range(0, 5 * PODMAN_CONNECTION_MAX_IDLE_SECONDS):
138+
if sock_path.exists():
139+
break
140+
141+
sleep(0.2)
142+
143+
if not sock_path.exists():
144+
raise TimeoutError()
145+
146+
@staticmethod
147+
def wrap(f) -> Any:
148+
"""
149+
Wraps the given function to provide access to a podman client.
150+
151+
:since: 0.11.0
152+
"""
153+
154+
@wraps(f)
155+
def decorator(*args, **kwargs) -> Any:
156+
"""
157+
Decorator for wrapping a function or method with a call context.
158+
"""
159+
160+
if "podman" in kwargs:
161+
raise ValueError(
162+
"Podman context wrapped functions can not be called with `kwargs['podman']`"
163+
)
164+
165+
with PodmanContext() as podman:
166+
kwargs["podman"] = podman
167+
return f(*args, **kwargs)
168+
169+
return decorator

0 commit comments

Comments
 (0)