podman-spawner 0.1.0__tar.gz

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.
@@ -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,178 @@
1
+ # Podman Spawner — a Podman interface for container management
2
+
3
+ `podman-spawner` automates the creation and lifecycle management of [Podman](https://podman.io/)
4
+ containers, easing the creation of temporary podman images.
5
+
6
+ It was originally developed to review students projects, enabling to create containers on the fly for each student group
7
+ and submission version.
8
+
9
+ ## Table of contents
10
+
11
+ - [Prerequisites](#prerequisites)
12
+ - [Using `pod`](#using-pod)
13
+
14
+ ---
15
+
16
+ ## Prerequisites
17
+
18
+ `pod` is a thin wrapper around Podman (a rootless Docker alternative).
19
+ Install it before anything else:
20
+
21
+ ```sh
22
+ sudo apt install podman containers-storage
23
+ ```
24
+
25
+ `containers-storage` is not strictly required, but it switches the storage
26
+ driver from `vfs` to `overlay`, which makes image builds significantly faster.
27
+ Verify it worked:
28
+
29
+ ```sh
30
+ podman info | grep graphDriverName # should print: overlay
31
+ ```
32
+
33
+ To pull images from Docker Hub, register it as an unqualified search registry:
34
+
35
+ ```sh
36
+ mkdir -p ~/.config/containers/
37
+ echo 'unqualified-search-registries = ["docker.io"]' >> ~/.config/containers/registries.conf
38
+ ```
39
+
40
+ For a small Podman introduction, see [podman-intro.md](podman-intro.md).
41
+
42
+ ---
43
+
44
+ ## Using `pod`
45
+
46
+ ### Installation
47
+
48
+ Install [uv](https://docs.astral.sh/uv/) if you do not have it already:
49
+
50
+ ```sh
51
+ curl -LsSf https://astral.sh/uv/install.sh | sh
52
+ ```
53
+
54
+ Then, from the root of the `pod` repository:
55
+
56
+ ```sh
57
+ uv tool install -e .
58
+ ```
59
+
60
+ The `pod` command is now available for the current user.
61
+
62
+ ### Configuration
63
+
64
+ Every `pod` command reads `config.toml` from the **current working directory**.
65
+ A default file is created by `pod init` (see below); its keys are:
66
+
67
+ | Key | Default | Description |
68
+ |----------|----------|------------------------------------------------------|
69
+ | `prefix` | `POD` | Prefix prepended to every container name. |
70
+ | `port` | `2026` | Guest port exposed by the container. |
71
+ | `user` | `tester` | Username created inside the container at build time. |
72
+
73
+ ### Workflow overview
74
+
75
+ ```
76
+ pod init # create the pod-build/ scaffold
77
+ ↓ (edit Dockerfile, home-dir/, on_build.bash as needed)
78
+ pod build # build the Podman image
79
+ pod test # smoke-test the image interactively
80
+
81
+ pod go <name> # open a container for a group / version
82
+ ```
83
+
84
+ ### Commands
85
+
86
+ #### `pod init`
87
+
88
+ Creates a `pod-build/` directory in the current directory (or uses the current
89
+ directory itself if it is already named `pod-build`), populated with a default
90
+ `Dockerfile`, `config.toml`, `on_build.bash`, and `home-dir/`.
91
+
92
+ ```sh
93
+ pod init # create pod-build/ (fails if it already exists)
94
+ pod init --force # overwrite an existing pod-build/
95
+ pod init --update Dockerfile # refresh only one file, keeping local changes
96
+ ```
97
+
98
+ #### `pod build`
99
+
100
+ Builds the Podman image from the `pod-build/` directory.
101
+ Must be run from inside `pod-build/` (or a directory that contains it).
102
+
103
+ ```sh
104
+ pod build
105
+ ```
106
+
107
+ The image is tagged `<prefix>:latest` (lower-cased), e.g. `pod:latest`.
108
+
109
+ #### `pod test`
110
+
111
+ Starts the dedicated test container (`<prefix>-test-0.0`) and attaches to it
112
+ interactively. Use this to verify that a freshly built image behaves correctly
113
+ before deploying it to students. The container is created if it does not exist.
114
+
115
+ ```sh
116
+ pod test
117
+ ```
118
+
119
+ #### `pod go <name>`
120
+
121
+ Attaches to a container, creating or restarting it as needed.
122
+
123
+ ```sh
124
+ pod go GROUP-1.0
125
+ ```
126
+
127
+ The host port is derived deterministically from the container name, so the
128
+ same container always gets the same port across restarts.
129
+
130
+ #### `pod list`
131
+
132
+ Lists all containers belonging to this project (filtered by prefix).
133
+
134
+ ```sh
135
+ pod list
136
+ ```
137
+
138
+ #### `pod info <name>`
139
+
140
+ Prints the state and port-forwarding details of a container.
141
+
142
+ ```sh
143
+ pod info GROUP-1.0
144
+ ```
145
+
146
+ #### `pod rm <name>`
147
+
148
+ Removes a container permanently.
149
+
150
+ ```sh
151
+ pod rm GROUP-1.0 # fails if the container is still running
152
+ pod rm GROUP-1.0 --force # stops and removes unconditionally
153
+ ```
154
+
155
+ > **Note:** due to a limitation in python-fire, `--force` must come *after*
156
+ > the container name.
157
+
158
+ #### `pod purge <regex>`
159
+
160
+ Removes all containers whose full name matches `regex`
161
+ (matched with `re.fullmatch`, i.e. the pattern must cover the entire name
162
+ including the prefix).
163
+
164
+ ```sh
165
+ pod purge 'POD-GROUP-1.*' # remove all versions for GROUP-1
166
+ pod purge 'POD-GROUP-1.*' --force # also remove running containers
167
+ ```
168
+
169
+ #### `pod purge-all`
170
+
171
+ Removes every container belonging to this project, including the test container.
172
+
173
+ ```sh
174
+ pod purge-all
175
+ pod purge-all --force
176
+ ```
177
+
178
+
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "podman-spawner"
3
+ version = "0.1.0"
4
+ description = "a Podman interface for container management"
5
+ authors = [{ name = "Nicolas Pourcelot", email = "nicolas.pourcelot@gmail.com" }]
6
+ keywords = ["podman", "containers", "docker"]
7
+ readme = "README.md"
8
+ license = "MIT"
9
+ classifiers = [
10
+ "Development Status :: 4 - Beta",
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "Intended Audience :: Education",
14
+ "Topic :: Software Development :: Testing",
15
+ "Topic :: Education :: Testing",
16
+ "Operating System :: POSIX :: Linux",
17
+ "Programming Language :: Python :: 3",
18
+ "Programming Language :: Python :: 3.11",
19
+ "Programming Language :: Python :: 3.12",
20
+ "Programming Language :: Python :: 3.13",
21
+ "License :: OSI Approved :: MIT License",
22
+ ]
23
+ requires-python = ">=3.11"
24
+ dependencies = [
25
+ "colored-messages>=0.1.0",
26
+ "fire >= 0.6.0",
27
+ ]
28
+
29
+ [project.urls]
30
+ Repository = "https://github.com/wxgeo/podman-spawner"
31
+
32
+
33
+ [dependency-groups]
34
+ dev = [
35
+ "mypy>=2.1.0",
36
+ "pytest>=9.0.3",
37
+ "python-semantic-release>=10.5.3",
38
+ "ruff>=0.15.15",
39
+ ]
40
+
41
+ [project.scripts]
42
+ pod = 'podman_spawner.cli:main'
43
+
44
+ [build-system]
45
+ requires = ["uv_build>=0.9.6,<0.11.0"]
46
+ build-backend = "uv_build"
47
+
48
+ [tool.uv.build-backend]
49
+ module-root = "src"
50
+ module-name = "podman_spawner"
51
+
52
+ [tool.semantic_release]
53
+ major_on_zero = false
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,3 @@
1
+ prefix = "POD"
2
+ port = 2026
3
+ user = "tester"
@@ -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
@@ -0,0 +1,2 @@
1
+ #!/bin/bash
2
+ # Actions to execute at the end of the build process.
@@ -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()
@@ -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"
@@ -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}")
@@ -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