podman-spawner 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- podman_spawner/__init__.py +0 -0
- podman_spawner/assets/defaults/pod-build/Dockerfile +44 -0
- podman_spawner/assets/defaults/pod-build/config.toml +3 -0
- podman_spawner/assets/defaults/pod-build/home-dir/.bashrc +29 -0
- podman_spawner/assets/defaults/pod-build/home-dir/.on_build.bash +2 -0
- podman_spawner/cli.py +324 -0
- podman_spawner/config.py +20 -0
- podman_spawner/port.py +52 -0
- podman_spawner/tools.py +135 -0
- podman_spawner-0.1.0.dist-info/METADATA +204 -0
- podman_spawner-0.1.0.dist-info/RECORD +13 -0
- podman_spawner-0.1.0.dist-info/WHEEL +4 -0
- podman_spawner-0.1.0.dist-info/entry_points.txt +3 -0
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# L'image de base. C'est une version très allégée de Debian.
|
|
2
|
+
FROM bitnami/minideb:trixie
|
|
3
|
+
|
|
4
|
+
ENV LANG='en_US.UTF-8' LANGUAGE='en_US:en' LC_ALL='en_US.UTF-8'
|
|
5
|
+
# Installation des dépendences.
|
|
6
|
+
# Par défaut, il n'y a pas de navigateur, on installe donc Firefox.
|
|
7
|
+
# Ne pas installer la version de base de firefox (qui utilise snap !)
|
|
8
|
+
RUN apt update; apt upgrade -y
|
|
9
|
+
RUN install_packages openjdk-21-jre openjdk-21-jdk junit5 firefox-esr ipython3 \
|
|
10
|
+
bash-completion command-not-found python3 git tree locales nano passwd sudo \
|
|
11
|
+
xdg-utils git-delta curl
|
|
12
|
+
RUN apt update; apt upgrade -y
|
|
13
|
+
RUN echo "LC_ALL=en_US.UTF-8" >> /etc/environment && \
|
|
14
|
+
echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && \
|
|
15
|
+
echo "LANG=en_US.UTF-8" > /etc/locale.conf && \
|
|
16
|
+
locale-gen en_US.UTF-8
|
|
17
|
+
|
|
18
|
+
# Ajout de Junit 5
|
|
19
|
+
# ADD https://repo1.maven.org/maven2/org/junit/platform/junit-platform-console-standalone/1.13.1/junit-platform-console-standalone-1.13.1.jar /root/junit
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# Installation de uv
|
|
23
|
+
RUN curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
24
|
+
|
|
25
|
+
# Utilisateur par défaut
|
|
26
|
+
ARG USER=tester
|
|
27
|
+
# Regex check: Allow only alphanumeric characters and underscores
|
|
28
|
+
RUN echo "$USER" | grep -qE '^[a-zA-Z_][a-zA-Z0-9_]*$' || \
|
|
29
|
+
(echo "ERROR: Invalid username format detected! ($USER)" && exit 1)
|
|
30
|
+
# Create the user and add to the sudo group
|
|
31
|
+
RUN useradd -m -s /bin/bash "$USER" && usermod -aG sudo "$USER"
|
|
32
|
+
# Allow the user to run sudo without a password
|
|
33
|
+
RUN echo "$USER ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers
|
|
34
|
+
|
|
35
|
+
USER $USER
|
|
36
|
+
WORKDIR /home/$USER
|
|
37
|
+
|
|
38
|
+
# Copy defaults user's home files.
|
|
39
|
+
COPY "home-dir/" "/home/$USER"
|
|
40
|
+
RUN ls -a "/home/$USER" && bash "/home/$USER/.on_build.bash"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# ~/.bashrc: executed by bash(1) for non-login shells.
|
|
2
|
+
|
|
3
|
+
# Note: PS1 and umask are already set in /etc/profile. You should not
|
|
4
|
+
# need this unless you want different defaults for root.
|
|
5
|
+
# PS1='${debian_chroot:+($debian_chroot)}\h:\w\$ '
|
|
6
|
+
# umask 022
|
|
7
|
+
|
|
8
|
+
# You may uncomment the following lines if you want `ls' to be colorized:
|
|
9
|
+
export LS_OPTIONS='--color=auto'
|
|
10
|
+
# eval "$(dircolors)"
|
|
11
|
+
# alias ls='ls $LS_OPTIONS'
|
|
12
|
+
# alias ll='ls $LS_OPTIONS -l'
|
|
13
|
+
# alias l='ls $LS_OPTIONS -lA'
|
|
14
|
+
#
|
|
15
|
+
# Some more alias to avoid making mistakes:
|
|
16
|
+
# alias rm='rm -i'
|
|
17
|
+
# alias cp='cp -i'
|
|
18
|
+
# alias mv='mv -i'
|
|
19
|
+
# Some color, please! :)
|
|
20
|
+
export PS1='\[\e[1;36m\]\H\[\e[0;36m\]:\w # \[\e[0m\]'
|
|
21
|
+
# set PATH so it includes user's private bin if it exists
|
|
22
|
+
if [ -d "$HOME/bin" ] ; then
|
|
23
|
+
PATH="$HOME/bin:$PATH"
|
|
24
|
+
for d in "$HOME/bin"/*/; do PATH="$d:$PATH"; done
|
|
25
|
+
fi
|
|
26
|
+
# set PATH so it includes user's private bin if it exists
|
|
27
|
+
if [ -d "$HOME/.local/bin" ] ; then
|
|
28
|
+
PATH="$HOME/.local/bin:$PATH"
|
|
29
|
+
fi
|
podman_spawner/cli.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import fire # type: ignore
|
|
7
|
+
from colored_messages import print_error, print_info, print_success, print_warning
|
|
8
|
+
|
|
9
|
+
from podman_spawner.config import (
|
|
10
|
+
ASSETS_DIR,
|
|
11
|
+
POD_BUILD_DIRNAME,
|
|
12
|
+
TEST,
|
|
13
|
+
)
|
|
14
|
+
from podman_spawner.port import port_from_name
|
|
15
|
+
from podman_spawner.tools import (
|
|
16
|
+
State,
|
|
17
|
+
config,
|
|
18
|
+
containers_states,
|
|
19
|
+
get_state,
|
|
20
|
+
podman,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# pod info
|
|
25
|
+
def info(name: str) -> None:
|
|
26
|
+
"""Print state and port-forwarding information for a container."""
|
|
27
|
+
print("Container name:", name)
|
|
28
|
+
print("State:", get_state(name).name)
|
|
29
|
+
print("Port forwarding:")
|
|
30
|
+
podman("port", name)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# pod list
|
|
34
|
+
def list_containers() -> None:
|
|
35
|
+
"""List all containers whose name starts with the configured prefix."""
|
|
36
|
+
podman(
|
|
37
|
+
"ps",
|
|
38
|
+
"-a",
|
|
39
|
+
"--format",
|
|
40
|
+
"table {{.Names}}\t{{.Status}}\t{{.Ports}}",
|
|
41
|
+
"--filter",
|
|
42
|
+
f"name=^{config().prefix}",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def initialize_directory(force: bool = False, update: str | Path | None = None) -> None:
|
|
47
|
+
"""Initialize (or update) the pod-build directory.
|
|
48
|
+
|
|
49
|
+
Without ``--update``, copies the full default skeleton into the target
|
|
50
|
+
directory. If the current working directory is named ``pod-build`` it is
|
|
51
|
+
used directly; otherwise a ``pod-build/`` subdirectory is created. The
|
|
52
|
+
operation is refused if the target already exists and is non-empty, unless
|
|
53
|
+
``--force`` is passed.
|
|
54
|
+
|
|
55
|
+
With ``--update <path>``, only the single file or subdirectory at
|
|
56
|
+
``<path>`` (relative to the skeleton root) is refreshed, leaving the rest
|
|
57
|
+
of the directory untouched. This is useful for pulling in an updated
|
|
58
|
+
``Dockerfile`` or ``home-dir/`` without clobbering local changes.
|
|
59
|
+
"""
|
|
60
|
+
cwd = Path.cwd()
|
|
61
|
+
dst = cwd if cwd.name == POD_BUILD_DIRNAME else cwd / POD_BUILD_DIRNAME
|
|
62
|
+
dst = Path(dst).absolute()
|
|
63
|
+
if update is None:
|
|
64
|
+
# If the directory exists and is not empty, it should not be overwritten, unless `force` is set to True.
|
|
65
|
+
if dst.exists() and any(dst.iterdir()) and not force:
|
|
66
|
+
print_error(f"Path already exists: '{dst}'.")
|
|
67
|
+
print_info("Use `pod init --force` to overwrite it.")
|
|
68
|
+
sys.exit(1)
|
|
69
|
+
shutil.copytree(ASSETS_DIR / "defaults/pod-build", dst, dirs_exist_ok=True)
|
|
70
|
+
print_success(f"The directory `{dst.name}` was successfully initialized.")
|
|
71
|
+
else:
|
|
72
|
+
src = ASSETS_DIR / "defaults/pod-build" / update
|
|
73
|
+
if not src.exists():
|
|
74
|
+
print_error(f"Path does not exist: '{src}'.")
|
|
75
|
+
sys.exit(1)
|
|
76
|
+
dst = dst / update
|
|
77
|
+
if src.is_dir():
|
|
78
|
+
shutil.copytree(src, dst, dirs_exist_ok=True)
|
|
79
|
+
elif src.is_file():
|
|
80
|
+
shutil.copy(src, dst)
|
|
81
|
+
print_success(f"File or directory updated: `{dst.name}`.")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# pod build
|
|
85
|
+
def build_image() -> None:
|
|
86
|
+
"""Build the Podman image from the current pod-build directory.
|
|
87
|
+
|
|
88
|
+
The current working directory must look like a valid build context:
|
|
89
|
+
it must contain an ``on_build.bash`` file and a ``home-dir/``
|
|
90
|
+
subdirectory. Use ``pod init`` to create a conforming directory first.
|
|
91
|
+
|
|
92
|
+
The username baked into the image is taken from ``config.toml``
|
|
93
|
+
(``user`` key) and must match ``^[a-zA-Z_][a-zA-Z0-9_]*$``.
|
|
94
|
+
"""
|
|
95
|
+
cwd = Path.cwd()
|
|
96
|
+
invalid_dir = False
|
|
97
|
+
# Test that the current directory looks like a correct build context.
|
|
98
|
+
if not (script := cwd / "on_build.bash").is_file():
|
|
99
|
+
invalid_dir = True
|
|
100
|
+
print_error(f"File not found: '{script}'.")
|
|
101
|
+
if not (home_dir := cwd / "home-dir").is_dir():
|
|
102
|
+
print_error(f"Directory not found: '{home_dir}'.")
|
|
103
|
+
if invalid_dir:
|
|
104
|
+
print_info("Hint: use `pod init` to initialize a pod directory.")
|
|
105
|
+
sys.exit(1)
|
|
106
|
+
user = config().user
|
|
107
|
+
if not re.fullmatch("^[a-zA-Z_][a-zA-Z0-9_]*$", user):
|
|
108
|
+
print_error(f"Invalid user name: {user!r}.")
|
|
109
|
+
sys.exit(1)
|
|
110
|
+
image_name = config().image_name
|
|
111
|
+
podman_args = [
|
|
112
|
+
"build",
|
|
113
|
+
"-t",
|
|
114
|
+
image_name,
|
|
115
|
+
"--build-arg",
|
|
116
|
+
f"USER={user}",
|
|
117
|
+
str(cwd),
|
|
118
|
+
]
|
|
119
|
+
if podman(*podman_args):
|
|
120
|
+
print_success(f"Image {image_name} built.")
|
|
121
|
+
else:
|
|
122
|
+
print_error("Build process failed. (See details above).")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _run_container(name: str, host_port: int) -> bool:
|
|
126
|
+
"""Ensure the named container is running, creating it if necessary.
|
|
127
|
+
|
|
128
|
+
Behaviour by current container state:
|
|
129
|
+
|
|
130
|
+
- **UP** — nothing to do, returns ``True`` immediately.
|
|
131
|
+
- **EXITED / CREATED** — attempts ``podman start``. If that fails (e.g.
|
|
132
|
+
the port stored in the container's config is already in use), the stale
|
|
133
|
+
container is removed and the function recurses once to recreate it via
|
|
134
|
+
the NOT_FOUND branch.
|
|
135
|
+
- **NOT_FOUND** — creates a new detached container with ``podman run``,
|
|
136
|
+
forwarding ``host_port`` on the host to the guest port defined in
|
|
137
|
+
``config.toml``.
|
|
138
|
+
|
|
139
|
+
Returns ``True`` on success, ``False`` if the underlying podman command
|
|
140
|
+
failed.
|
|
141
|
+
"""
|
|
142
|
+
guest_port = config().port
|
|
143
|
+
match get_state(name):
|
|
144
|
+
case State.UP:
|
|
145
|
+
return True
|
|
146
|
+
case State.EXITED | State.CREATED:
|
|
147
|
+
if podman("start", name):
|
|
148
|
+
return True
|
|
149
|
+
print_warning(
|
|
150
|
+
f"Could not restart {name!r}; recreating with current port mapping."
|
|
151
|
+
)
|
|
152
|
+
podman("rm", "-f", name)
|
|
153
|
+
return _run_container(name, host_port)
|
|
154
|
+
case State.NOT_FOUND:
|
|
155
|
+
print(f"Port forwarding: {host_port}->{guest_port}")
|
|
156
|
+
return podman(
|
|
157
|
+
"run",
|
|
158
|
+
"-d",
|
|
159
|
+
"-t",
|
|
160
|
+
"--name",
|
|
161
|
+
name,
|
|
162
|
+
"--env=DISPLAY",
|
|
163
|
+
"-v",
|
|
164
|
+
"/tmp/.X11-unix:/tmp/.X11-unix",
|
|
165
|
+
"--hostname",
|
|
166
|
+
name,
|
|
167
|
+
"--env",
|
|
168
|
+
"TERM=xterm-256color",
|
|
169
|
+
"--publish",
|
|
170
|
+
f"{host_port}:{guest_port}",
|
|
171
|
+
config().image_name,
|
|
172
|
+
)
|
|
173
|
+
case _:
|
|
174
|
+
raise NotImplementedError
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def run_container(
|
|
178
|
+
name: str,
|
|
179
|
+
host_port: int | None = None,
|
|
180
|
+
copy: str | Path | None = None,
|
|
181
|
+
script: str | Path | None = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Start a container, optionally copying files or running a script inside it.
|
|
184
|
+
|
|
185
|
+
If the container does not exist it is created. If it already exists but
|
|
186
|
+
is stopped it is restarted (with automatic recreation if the port binding
|
|
187
|
+
is stale — see :func:`_run_container`).
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
name: Container name.
|
|
191
|
+
host_port: Port to forward on the host side. Derived deterministically
|
|
192
|
+
from ``name`` via :func:`port_from_name` when omitted.
|
|
193
|
+
copy: Local directory whose *contents* are copied into the container's
|
|
194
|
+
home directory (equivalent to ``podman cp <copy>/. <name>:<home>``).
|
|
195
|
+
script: Local script file to copy into the container's home directory
|
|
196
|
+
and execute there in a detached bash session.
|
|
197
|
+
"""
|
|
198
|
+
if host_port is None:
|
|
199
|
+
host_port = port_from_name(name)
|
|
200
|
+
_run_container(name, host_port)
|
|
201
|
+
home = Path(f"/home/{config().user}/")
|
|
202
|
+
if copy is not None:
|
|
203
|
+
# Docker documentation specifies to add "/." at the end of the source
|
|
204
|
+
# path, so as to copy the folder content (and not the folder itself).
|
|
205
|
+
podman("cp", f"{copy}/.", f"{name}:{home}")
|
|
206
|
+
if script is not None:
|
|
207
|
+
script = Path(script)
|
|
208
|
+
podman("cp", f"{script}", f"{name}:{home / script.name}")
|
|
209
|
+
podman(
|
|
210
|
+
"exec",
|
|
211
|
+
"-d",
|
|
212
|
+
name,
|
|
213
|
+
"bash",
|
|
214
|
+
f"{home / script.name}",
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# pod test
|
|
219
|
+
def test_image() -> None:
|
|
220
|
+
"""Start and attach to the ephemeral test container.
|
|
221
|
+
|
|
222
|
+
The test container is named ``<prefix>-<TEST>`` (e.g. ``POD-test-0.0``).
|
|
223
|
+
It is created from the current image if it does not exist yet. Use this
|
|
224
|
+
command to verify that a freshly built image behaves as expected before
|
|
225
|
+
deploying containers to students.
|
|
226
|
+
"""
|
|
227
|
+
attach_container(f"{config().prefix}-{TEST}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# pod go
|
|
231
|
+
def attach_container(name: str) -> None:
|
|
232
|
+
"""Attach to a container, starting or creating it first if needed.
|
|
233
|
+
|
|
234
|
+
Delegates start/create logic to :func:`_run_container` so that stale port
|
|
235
|
+
bindings are handled consistently: if ``podman start`` fails the container
|
|
236
|
+
is automatically recreated with the current port mapping.
|
|
237
|
+
"""
|
|
238
|
+
_run_container(name, port_from_name(name))
|
|
239
|
+
podman("attach", name)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
# pod rm
|
|
243
|
+
def remove_container(name: str, force: bool = False) -> bool:
|
|
244
|
+
"""Remove a container permanently.
|
|
245
|
+
|
|
246
|
+
Pass ``--force`` to remove a running container without stopping it first.
|
|
247
|
+
Note: due to a limitation in python-fire, ``--force`` must come *after*
|
|
248
|
+
the container name on the command line.
|
|
249
|
+
"""
|
|
250
|
+
if not isinstance(force, bool):
|
|
251
|
+
print_error(f"Invalid argument for --force: {force!r}.")
|
|
252
|
+
sys.exit(1)
|
|
253
|
+
if force:
|
|
254
|
+
return podman("rm", "-f", name)
|
|
255
|
+
else:
|
|
256
|
+
return podman("rm", name)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# pod purge
|
|
260
|
+
def purge_containers(regex: str, force: bool = False) -> None:
|
|
261
|
+
"""Remove all containers whose full name matches ``regex``.
|
|
262
|
+
|
|
263
|
+
The regex is matched against the complete container name (including the
|
|
264
|
+
configured prefix), using :func:`re.fullmatch`. Running containers are
|
|
265
|
+
flagged with a warning; pass ``--force`` to remove them without stopping
|
|
266
|
+
first.
|
|
267
|
+
"""
|
|
268
|
+
print("Removing containers:")
|
|
269
|
+
count = 0
|
|
270
|
+
for name in containers_states():
|
|
271
|
+
assert name.startswith(config().prefix)
|
|
272
|
+
if re.fullmatch(regex, name):
|
|
273
|
+
count += 1
|
|
274
|
+
if get_state(name) == State.UP:
|
|
275
|
+
print_warning(
|
|
276
|
+
f"Container {name} is still running. Use pod go {name} to show it."
|
|
277
|
+
)
|
|
278
|
+
remove_container(name, force=force)
|
|
279
|
+
if count == 0:
|
|
280
|
+
print_warning(f"No matching container found for '{regex}'.")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# pod purge-all
|
|
284
|
+
def purge_all_containers(force: bool = False) -> None:
|
|
285
|
+
"""Remove all containers, including the test container.
|
|
286
|
+
|
|
287
|
+
Running containers are flagged with a warning; pass ``--force`` to remove
|
|
288
|
+
them without stopping first.
|
|
289
|
+
"""
|
|
290
|
+
print("Removing containers:")
|
|
291
|
+
prefix = config().prefix
|
|
292
|
+
name = f"{prefix}-{TEST}"
|
|
293
|
+
if get_state(name) != State.NOT_FOUND:
|
|
294
|
+
remove_container(name, force=force)
|
|
295
|
+
containers = containers_states()
|
|
296
|
+
for name, state in containers.items():
|
|
297
|
+
assert name.startswith(prefix)
|
|
298
|
+
if state == State.UP:
|
|
299
|
+
print_warning(
|
|
300
|
+
f"Container {name} is still running. Use `pod go {name}` to show it."
|
|
301
|
+
)
|
|
302
|
+
remove_container(name, force=force)
|
|
303
|
+
if len(containers) == 0:
|
|
304
|
+
print_warning("No container found.")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def main() -> None:
|
|
308
|
+
fire.Fire(
|
|
309
|
+
{
|
|
310
|
+
"init": initialize_directory,
|
|
311
|
+
"build": build_image,
|
|
312
|
+
"test": test_image,
|
|
313
|
+
"go": attach_container,
|
|
314
|
+
"rm": remove_container,
|
|
315
|
+
"purge": purge_containers,
|
|
316
|
+
"purge-all": purge_all_containers,
|
|
317
|
+
"info": info,
|
|
318
|
+
"list": list_containers,
|
|
319
|
+
}
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
if __name__ == "__main__":
|
|
324
|
+
main()
|
podman_spawner/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Package-level constants and paths.
|
|
2
|
+
|
|
3
|
+
These are static values baked in at install time. Runtime configuration
|
|
4
|
+
(prefix, port, user) lives in ``config.toml`` and is accessed via
|
|
5
|
+
:func:`pod.tools.config`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
# Name shown in pod test containers and used as the TEST suffix.
|
|
11
|
+
TEST = "test-0.0"
|
|
12
|
+
|
|
13
|
+
# Absolute path to the installed package directory.
|
|
14
|
+
POD_DIR = Path(__file__).parent
|
|
15
|
+
|
|
16
|
+
# Absolute path to the bundled asset tree (Dockerfile skeleton, etc.).
|
|
17
|
+
ASSETS_DIR = POD_DIR / "assets"
|
|
18
|
+
|
|
19
|
+
# Expected name of the pod build context directory.
|
|
20
|
+
POD_BUILD_DIRNAME = "pod-build"
|
podman_spawner/port.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Deterministic port allocation.
|
|
2
|
+
|
|
3
|
+
Derives a stable host port from a container name so that the same container
|
|
4
|
+
always gets the same port across restarts, while avoiding collisions with
|
|
5
|
+
ports already in use on the host.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import hashlib
|
|
9
|
+
import socket
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def port_from_name(name: str, start: int = 1024, end: int = 65535) -> int:
|
|
13
|
+
"""Derive a free host port from ``name``.
|
|
14
|
+
|
|
15
|
+
Uses SHA-256 to map ``name`` to a deterministic starting point in
|
|
16
|
+
``[start, end)``, then scans forward (wrapping around) until a port that
|
|
17
|
+
is free on both TCP and UDP is found.
|
|
18
|
+
|
|
19
|
+
The determinism guarantee means the same container name always resolves to
|
|
20
|
+
the same base port, making port assignments stable and predictable for
|
|
21
|
+
operators and students alike.
|
|
22
|
+
|
|
23
|
+
Raises:
|
|
24
|
+
RuntimeError: If every port in the range is occupied.
|
|
25
|
+
"""
|
|
26
|
+
hash_int = int(hashlib.sha256(name.encode()).hexdigest(), 16)
|
|
27
|
+
base_port = start + (hash_int % (end - start) if end - start else 0)
|
|
28
|
+
|
|
29
|
+
for offset in range(end - start):
|
|
30
|
+
port = start + (base_port - start + offset) % (end - start)
|
|
31
|
+
if _is_port_free(port):
|
|
32
|
+
return port
|
|
33
|
+
|
|
34
|
+
raise RuntimeError("No free port found in range")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _is_port_free(port: int) -> bool:
|
|
38
|
+
"""Return ``True`` if ``port`` is available on both TCP and UDP."""
|
|
39
|
+
for kind in (socket.SOCK_STREAM, socket.SOCK_DGRAM):
|
|
40
|
+
with socket.socket(socket.AF_INET, kind) as s:
|
|
41
|
+
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
42
|
+
try:
|
|
43
|
+
s.bind(("", port))
|
|
44
|
+
except OSError:
|
|
45
|
+
return False
|
|
46
|
+
return True
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
for _name in ("my-service", "postgres", "redis"):
|
|
51
|
+
_port = port_from_name(_name)
|
|
52
|
+
print(f"{_name!r:20} → port {_port}")
|
podman_spawner/tools.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
import tomllib
|
|
4
|
+
from dataclasses import dataclass, fields
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from functools import cache
|
|
7
|
+
from subprocess import run
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from colored_messages import print_error, print_info
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class State(Enum):
|
|
14
|
+
"""Lifecycle state of a Podman container.
|
|
15
|
+
|
|
16
|
+
Mirrors the subset of states returned by ``podman ps --format json``:
|
|
17
|
+
|
|
18
|
+
- ``UP`` — container is running.
|
|
19
|
+
- ``CREATED`` — container was created but never started.
|
|
20
|
+
- ``EXITED`` — container ran and has stopped.
|
|
21
|
+
- ``NOT_FOUND`` — no container with that name exists (local addition,
|
|
22
|
+
not a Podman state).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
UP = 0
|
|
26
|
+
CREATED = 2
|
|
27
|
+
EXITED = 3
|
|
28
|
+
NOT_FOUND = 4
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Maps the lowercase state strings from `podman ps --format json` to State.
|
|
32
|
+
_PODMAN_STATE_MAP: dict[str, State] = {
|
|
33
|
+
"running": State.UP,
|
|
34
|
+
"created": State.CREATED,
|
|
35
|
+
"exited": State.EXITED,
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Config:
|
|
41
|
+
"""Runtime configuration loaded from ``config.toml``.
|
|
42
|
+
|
|
43
|
+
Attributes:
|
|
44
|
+
prefix: Prefix prepended to every container name (e.g. ``"POD"``).
|
|
45
|
+
port: Guest port exposed by every container.
|
|
46
|
+
user: Username created inside the container at build time.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
prefix: str
|
|
50
|
+
port: int
|
|
51
|
+
user: str
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def image_name(self) -> str:
|
|
55
|
+
"""Fully-qualified image tag used by ``podman build`` and ``podman run``."""
|
|
56
|
+
return f"{self.prefix}:latest".lower()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@cache
|
|
60
|
+
def config() -> Config:
|
|
61
|
+
"""Load and return the project configuration.
|
|
62
|
+
|
|
63
|
+
Reads ``config.toml`` from the *current working directory* and returns a
|
|
64
|
+
:class:`Config` instance. The result is cached so the file is read only
|
|
65
|
+
once per process; run ``pod`` from the directory that contains
|
|
66
|
+
``config.toml``.
|
|
67
|
+
"""
|
|
68
|
+
try:
|
|
69
|
+
with open("config.toml", "rb") as f:
|
|
70
|
+
data = tomllib.load(f)
|
|
71
|
+
except FileNotFoundError:
|
|
72
|
+
print_error("File `config.toml` was not found")
|
|
73
|
+
print_info("Hint: use `pod init` to initialize a pod directory.")
|
|
74
|
+
sys.exit(1)
|
|
75
|
+
try:
|
|
76
|
+
config_ = Config(**data)
|
|
77
|
+
except TypeError:
|
|
78
|
+
# Compare data keys and Config fields, to report a useful message.
|
|
79
|
+
expected = {f.name for f in fields(Config)}
|
|
80
|
+
actual = set(data.keys())
|
|
81
|
+
missing = expected - actual
|
|
82
|
+
unexpected = actual - expected
|
|
83
|
+
if missing:
|
|
84
|
+
print_error(f"Missing keys in `config.toml`: {', '.join(sorted(missing))}")
|
|
85
|
+
if unexpected:
|
|
86
|
+
print_error(
|
|
87
|
+
f"Unknown keys in `config.toml`: {', '.join(sorted(unexpected))}"
|
|
88
|
+
)
|
|
89
|
+
sys.exit(1)
|
|
90
|
+
return config_
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def containers_states() -> dict[str, State]:
|
|
94
|
+
"""Return the state of every container whose name matches the configured prefix.
|
|
95
|
+
|
|
96
|
+
Calls ``podman ps -a --format json`` and filters by :attr:`Config.prefix`.
|
|
97
|
+
Containers not belonging to this project are ignored.
|
|
98
|
+
|
|
99
|
+
Raises:
|
|
100
|
+
NotImplementedError: If Podman reports a state string not present in
|
|
101
|
+
``_PODMAN_STATE_MAP`` (i.e. a state this code does not yet handle).
|
|
102
|
+
"""
|
|
103
|
+
completed_process = run(
|
|
104
|
+
["podman", "ps", "-a", "--format", "json"],
|
|
105
|
+
encoding="utf8",
|
|
106
|
+
capture_output=True,
|
|
107
|
+
)
|
|
108
|
+
entries = json.loads(completed_process.stdout or "[]")
|
|
109
|
+
prefix = config().prefix
|
|
110
|
+
result = {}
|
|
111
|
+
for entry in entries:
|
|
112
|
+
names = entry.get("Names") or []
|
|
113
|
+
raw_state = entry.get("State", "").lower()
|
|
114
|
+
state = _PODMAN_STATE_MAP.get(raw_state)
|
|
115
|
+
if state is None:
|
|
116
|
+
raise NotImplementedError(f"Unrecognised container state: {raw_state!r}")
|
|
117
|
+
for name in names:
|
|
118
|
+
if name.startswith(prefix):
|
|
119
|
+
result[name] = state
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def get_state(name: str) -> State:
|
|
124
|
+
"""Return the :class:`State` of a single container.
|
|
125
|
+
|
|
126
|
+
Returns ``State.NOT_FOUND`` if no container with that name exists,
|
|
127
|
+
rather than raising an exception.
|
|
128
|
+
"""
|
|
129
|
+
return containers_states().get(name, State.NOT_FOUND)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def podman(*args: str, **kw: Any) -> bool:
|
|
133
|
+
"""Run a podman command and return ``True`` on success, ``False`` otherwise."""
|
|
134
|
+
print(" ".join(["podman", *args]))
|
|
135
|
+
return run(["podman", *args], **kw).returncode == 0
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: podman-spawner
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: a Podman interface for container management
|
|
5
|
+
Keywords: podman,containers,docker
|
|
6
|
+
Author: Nicolas Pourcelot
|
|
7
|
+
Author-email: Nicolas Pourcelot <nicolas.pourcelot@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Intended Audience :: Education
|
|
13
|
+
Classifier: Topic :: Software Development :: Testing
|
|
14
|
+
Classifier: Topic :: Education :: Testing
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
21
|
+
Requires-Dist: colored-messages>=0.1.0
|
|
22
|
+
Requires-Dist: fire>=0.6.0
|
|
23
|
+
Requires-Python: >=3.11
|
|
24
|
+
Project-URL: Repository, https://github.com/wxgeo/podman-spawner
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# Podman Spawner — a Podman interface for container management
|
|
28
|
+
|
|
29
|
+
`podman-spawner` automates the creation and lifecycle management of [Podman](https://podman.io/)
|
|
30
|
+
containers, easing the creation of temporary podman images.
|
|
31
|
+
|
|
32
|
+
It was originally developed to review students projects, enabling to create containers on the fly for each student group
|
|
33
|
+
and submission version.
|
|
34
|
+
|
|
35
|
+
## Table of contents
|
|
36
|
+
|
|
37
|
+
- [Prerequisites](#prerequisites)
|
|
38
|
+
- [Using `pod`](#using-pod)
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## Prerequisites
|
|
43
|
+
|
|
44
|
+
`pod` is a thin wrapper around Podman (a rootless Docker alternative).
|
|
45
|
+
Install it before anything else:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
sudo apt install podman containers-storage
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`containers-storage` is not strictly required, but it switches the storage
|
|
52
|
+
driver from `vfs` to `overlay`, which makes image builds significantly faster.
|
|
53
|
+
Verify it worked:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
podman info | grep graphDriverName # should print: overlay
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
To pull images from Docker Hub, register it as an unqualified search registry:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
mkdir -p ~/.config/containers/
|
|
63
|
+
echo 'unqualified-search-registries = ["docker.io"]' >> ~/.config/containers/registries.conf
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
For a small Podman introduction, see [podman-intro.md](podman-intro.md).
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Using `pod`
|
|
71
|
+
|
|
72
|
+
### Installation
|
|
73
|
+
|
|
74
|
+
Install [uv](https://docs.astral.sh/uv/) if you do not have it already:
|
|
75
|
+
|
|
76
|
+
```sh
|
|
77
|
+
curl -LsSf https://astral.sh/uv/install.sh | sh
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Then, from the root of the `pod` repository:
|
|
81
|
+
|
|
82
|
+
```sh
|
|
83
|
+
uv tool install -e .
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The `pod` command is now available for the current user.
|
|
87
|
+
|
|
88
|
+
### Configuration
|
|
89
|
+
|
|
90
|
+
Every `pod` command reads `config.toml` from the **current working directory**.
|
|
91
|
+
A default file is created by `pod init` (see below); its keys are:
|
|
92
|
+
|
|
93
|
+
| Key | Default | Description |
|
|
94
|
+
|----------|----------|------------------------------------------------------|
|
|
95
|
+
| `prefix` | `POD` | Prefix prepended to every container name. |
|
|
96
|
+
| `port` | `2026` | Guest port exposed by the container. |
|
|
97
|
+
| `user` | `tester` | Username created inside the container at build time. |
|
|
98
|
+
|
|
99
|
+
### Workflow overview
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
pod init # create the pod-build/ scaffold
|
|
103
|
+
↓ (edit Dockerfile, home-dir/, on_build.bash as needed)
|
|
104
|
+
pod build # build the Podman image
|
|
105
|
+
pod test # smoke-test the image interactively
|
|
106
|
+
↓
|
|
107
|
+
pod go <name> # open a container for a group / version
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Commands
|
|
111
|
+
|
|
112
|
+
#### `pod init`
|
|
113
|
+
|
|
114
|
+
Creates a `pod-build/` directory in the current directory (or uses the current
|
|
115
|
+
directory itself if it is already named `pod-build`), populated with a default
|
|
116
|
+
`Dockerfile`, `config.toml`, `on_build.bash`, and `home-dir/`.
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
pod init # create pod-build/ (fails if it already exists)
|
|
120
|
+
pod init --force # overwrite an existing pod-build/
|
|
121
|
+
pod init --update Dockerfile # refresh only one file, keeping local changes
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
#### `pod build`
|
|
125
|
+
|
|
126
|
+
Builds the Podman image from the `pod-build/` directory.
|
|
127
|
+
Must be run from inside `pod-build/` (or a directory that contains it).
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
pod build
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The image is tagged `<prefix>:latest` (lower-cased), e.g. `pod:latest`.
|
|
134
|
+
|
|
135
|
+
#### `pod test`
|
|
136
|
+
|
|
137
|
+
Starts the dedicated test container (`<prefix>-test-0.0`) and attaches to it
|
|
138
|
+
interactively. Use this to verify that a freshly built image behaves correctly
|
|
139
|
+
before deploying it to students. The container is created if it does not exist.
|
|
140
|
+
|
|
141
|
+
```sh
|
|
142
|
+
pod test
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
#### `pod go <name>`
|
|
146
|
+
|
|
147
|
+
Attaches to a container, creating or restarting it as needed.
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
pod go GROUP-1.0
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The host port is derived deterministically from the container name, so the
|
|
154
|
+
same container always gets the same port across restarts.
|
|
155
|
+
|
|
156
|
+
#### `pod list`
|
|
157
|
+
|
|
158
|
+
Lists all containers belonging to this project (filtered by prefix).
|
|
159
|
+
|
|
160
|
+
```sh
|
|
161
|
+
pod list
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
#### `pod info <name>`
|
|
165
|
+
|
|
166
|
+
Prints the state and port-forwarding details of a container.
|
|
167
|
+
|
|
168
|
+
```sh
|
|
169
|
+
pod info GROUP-1.0
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
#### `pod rm <name>`
|
|
173
|
+
|
|
174
|
+
Removes a container permanently.
|
|
175
|
+
|
|
176
|
+
```sh
|
|
177
|
+
pod rm GROUP-1.0 # fails if the container is still running
|
|
178
|
+
pod rm GROUP-1.0 --force # stops and removes unconditionally
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
> **Note:** due to a limitation in python-fire, `--force` must come *after*
|
|
182
|
+
> the container name.
|
|
183
|
+
|
|
184
|
+
#### `pod purge <regex>`
|
|
185
|
+
|
|
186
|
+
Removes all containers whose full name matches `regex`
|
|
187
|
+
(matched with `re.fullmatch`, i.e. the pattern must cover the entire name
|
|
188
|
+
including the prefix).
|
|
189
|
+
|
|
190
|
+
```sh
|
|
191
|
+
pod purge 'POD-GROUP-1.*' # remove all versions for GROUP-1
|
|
192
|
+
pod purge 'POD-GROUP-1.*' --force # also remove running containers
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
#### `pod purge-all`
|
|
196
|
+
|
|
197
|
+
Removes every container belonging to this project, including the test container.
|
|
198
|
+
|
|
199
|
+
```sh
|
|
200
|
+
pod purge-all
|
|
201
|
+
pod purge-all --force
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
podman_spawner/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
podman_spawner/assets/defaults/pod-build/Dockerfile,sha256=vNH5Zfj2kCJnU9uKXa6H1SzuWvFbMWPLzRTcK3mPsZ8,1636
|
|
3
|
+
podman_spawner/assets/defaults/pod-build/config.toml,sha256=WGjps5cNoqwh5YYpjtAAcUjpes6ED_0ToJ3pfFKgYhY,42
|
|
4
|
+
podman_spawner/assets/defaults/pod-build/home-dir/.bashrc,sha256=cIgk-JFqfG8fhGd6V8mK6SLnBF56WSb3g72pITz8jj0,950
|
|
5
|
+
podman_spawner/assets/defaults/pod-build/home-dir/.on_build.bash,sha256=gTX-l1OTEZesQT7v0ZJ7HPi_Rpm5nWCqRKRHBeQ6iyk,65
|
|
6
|
+
podman_spawner/cli.py,sha256=g5aemNQkbPBFWArD6PHK6Z8QlxNoGifgtn539xcQoXE,11200
|
|
7
|
+
podman_spawner/config.py,sha256=NjSvOC--SmyAHNmX5ZDyh_6EoEysl_eRDPwf6kFaGHA,592
|
|
8
|
+
podman_spawner/port.py,sha256=hNaBsaeKsx1lEjTysWJXGeLulC9LC0mCEfl5gNEv8kU,1781
|
|
9
|
+
podman_spawner/tools.py,sha256=zC_XzH9Euyw76ByJ0MQvN4_abhHwwcq_77w0vtr-QL4,4249
|
|
10
|
+
podman_spawner-0.1.0.dist-info/WHEEL,sha256=XV0cjMrO7zXhVAIyyc8aFf1VjZ33Fen4IiJk5zFlC3g,80
|
|
11
|
+
podman_spawner-0.1.0.dist-info/entry_points.txt,sha256=T1SNsOW5-b-3tojS-2Y8N9_9VbT8d6MzErFj6VCiiSo,49
|
|
12
|
+
podman_spawner-0.1.0.dist-info/METADATA,sha256=NKMv1BuAXMQI8WuRylLoax1L0Jn7Q3RlZKlCEABFkqA,5602
|
|
13
|
+
podman_spawner-0.1.0.dist-info/RECORD,,
|