From d4f5542f93cded34fca0fec833a6b859ba1a84ad Mon Sep 17 00:00:00 2001 From: Esteban Aguililla Klein Date: Tue, 28 Oct 2025 18:20:27 +0100 Subject: [PATCH 1/2] Context manager for users Signed-off-by: Esteban Aguililla Klein --- src/pythainer/builders/__init__.py | 220 ++++++++++++++++---- src/pythainer/examples/builders/__init__.py | 9 +- 2 files changed, 189 insertions(+), 40 deletions(-) diff --git a/src/pythainer/builders/__init__.py b/src/pythainer/builders/__init__.py index e65bf2e..b012f3f 100644 --- a/src/pythainer/builders/__init__.py +++ b/src/pythainer/builders/__init__.py @@ -6,6 +6,8 @@ handling commands like package installation, environment variable setting, and user management, tailored specifically for Docker environments. """ + +from __future__ import annotations import os import tempfile from pathlib import Path @@ -50,6 +52,121 @@ def render_dockerfile_content( return file_content +class UserException(Exception): + pass + + +class UserContext: + """ + A class that implements a user context manager where within said context, all actions will be + done on behalf of a chosen user. + """ + + def __init__( + self, partial_docker_builder: PartialDockerBuilder, on_behalf: str, return_to: str + ) -> None: + self.return_to: str = return_to + self.on_behalf: str = on_behalf + self.partial_docker_builder: PartialDockerBuilder = partial_docker_builder + + def __enter__(self) -> PartialDockerBuilder: + """ + Sets the current user to `self.on_behalf` + """ + self.partial_docker_builder.user(name=self.on_behalf) + return self.partial_docker_builder + + def __exit__(self, exc_type, exc_value, traceback): + """ + Sets the current user to `self.return_to` and propagates errors + """ + self.partial_docker_builder.user(name=self.return_to) + return False # propagate error + + +class UserManager: + """ + A class to manage a container's users statefully + """ + + def __init__(self) -> None: + # root is always a user by default and USER_NAME is the fallback user + self.managed_users: List[str] = [ + "root", + "${USER_NAME}", + ] + self.current_user: str = "root" + + def manage_user(self, username: str) -> None: + """ + Manages the user `username` through the `UserManager` + """ + if username in self.managed_users: + raise UserException( + ( + f"UserException where incoming user is '{username}' " + f"and managed user are '{self.managed_users}' " + "User already exists" + ) + ) + + self.managed_users.append(username) + + def user( + self, partial_docker_builder: PartialDockerBuilder, name: str = "", check: bool = True + ) -> None: + """ + Sets the USER for subsequent commands in the Dockerfile. + + Parameters: + partial_docker_builder (PartialDockerBuilder): The parent docker builder + name (str): The username or UID. Defaults to the environment variable USER_NAME. + check (bool): Toggle to check or not if the user is managed for backwards compatibility + """ + if not name: + name = "${USER_NAME}" # fallback user + elif check and (name not in self.managed_users): + raise UserException( + ( + f"UserException where incoming user is '{name}' " + f"and managed users are '{self.managed_users}' " + "User is not managed" + ) + ) + + cmd = f"USER {name}" + + self.current_user = name + partial_docker_builder._build_commands.append(StrDockerBuildCommand(cmd)) + + def as_user( + self, partial_docker_builder: PartialDockerBuilder, username: str + ) -> UserContext | None: + """ + If the `username` user is managed, returns a `UserContext` + + Parameters: + partial_docker_builder (PartialDockerBuilder): The managed docker builder + username (str): The user for which to provide a `UserContext` + + Returns: + UserContext | None : The context manager for the user or None if an error occured + """ + if username not in self.managed_users: + raise UserException( + ( + f"UserException where incoming user is '{username}' " + f"and managed users are '{self.managed_users}' " + "User is not managed" + ) + ) + + return_to: str = self.current_user + self.current_user = username + + return UserContext(partial_docker_builder, on_behalf=self.current_user, return_to=return_to) + + class PartialDockerBuilder: """ A class to facilitate the building of partial Docker configurations that can be extended or @@ -61,6 +178,7 @@ def __init__(self) -> None: Initializes a PartialDockerBuilder with an empty list of build commands. """ self._build_commands: List[DockerBuildCommand] = [] + self.user_manager: UserManager | None = None def __or__(self, other: "PartialDockerBuilder") -> "PartialDockerBuilder": """ @@ -74,8 +192,10 @@ def __or__(self, other: "PartialDockerBuilder") -> "PartialDockerBuilder": PartialDockerBuilder: A new builder instance with combined commands. """ result_builder = PartialDockerBuilder() + user_manager = self.user_manager # prepare to move the users result_builder._extend(other=self) result_builder._extend(other=other) + result_builder.user_manager = user_manager return result_builder def __ior__(self, other: "PartialDockerBuilder") -> "PartialDockerBuilder": @@ -183,24 +303,6 @@ def run_multiple(self, commands: List[str]) -> None: command = " && \\\n ".join(commands) self.run(command=command) - def user(self, name: str = "") -> None: - """ - Sets the USER for subsequent commands in the Dockerfile. - - Parameters: - name (str): The username or UID. Defaults to the environment variable USER_NAME. - """ - if not name: - name = "${USER_NAME}" - cmd = f"USER {name}" - self._build_commands.append(StrDockerBuildCommand(cmd)) - - def root(self) -> None: - """ - Sets the USER to root for subsequent commands. - """ - self.user(name="root") - def workdir(self, path: PathType) -> None: """ Sets the working directory for subsequent commands in the Dockerfile. @@ -231,6 +333,68 @@ def copy(self, filename: PathType, destination: PathType) -> None: cmd = f"COPY {filename} {destination}" self._build_commands.append(StrDockerBuildCommand(cmd)) + def create_user(self, username: str) -> None: + """ + Creates a non-root user within the Docker environment with sudo privileges that is managed + by the `UserManager` + + Parameters: + username (str): The username of the new user. + """ + if not self.user_manager: + self.user_manager = UserManager() + self.user_manager.manage_user(username) + + self.arg(name="USER_NAME", value=username) + self.arg(name="UID") + self.arg(name="GID") + self.remove_group_if_gid_exists(gid="${GID}") + self.remove_user_if_uid_exists(uid="${UID}", gid="${GID}") + self.run(command="groupadd -g ${GID} ${USER_NAME}") + self.run( + command='adduser --disabled-password --uid $UID --gid $GID --gecos "" ${USER_NAME}' + ) + self.run(command="adduser ${USER_NAME} sudo") + self.run(command="echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers") + self.run(command='echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/10-docker') + + def root(self) -> None: + """ + Sets the USER to root for subsequent commands. + """ + self.user(name="root") + + def user(self, name: str = "", check: bool = True) -> None: + """ + Sets the USER for subsequent commands in the Dockerfile. + + Parameters: + name (str): The username or UID. Defaults to the environment variable USER_NAME. + check (bool): Toggle to check or not if the user is managed for backwards compatibility + """ + self.user_manager.user(self, name, check) + + def as_user(self, username: str) -> UserContext | None: + """ + If the `username` user is managed, returns a `UserContext` + + Parameters: + username (str): The user for which to provide a `UserContext` + + Returns: + UserContext | None : The context manager for the user or None if an error occured + """ + return self.user_manager.as_user(self, username) + + def as_root(self) -> UserContext: + """ + A shorthand to get a `UserContext` for the root user + + Returns: + UserContext: The context manager for the root user + """ + return self.user_manager.as_user(self, username="root") + class DockerBuilder(PartialDockerBuilder): """ @@ -536,26 +700,6 @@ def remove_user_if_uid_exists( ) self.run(command=command) - def create_user(self, username: str) -> None: - """ - Creates a non-root user within the Docker environment with sudo privileges. - - Parameters: - username (str): The username of the new user. - """ - self.arg(name="USER_NAME", value=username) - self.arg(name="UID") - self.arg(name="GID") - self.remove_group_if_gid_exists(gid="${GID}") - self.remove_user_if_uid_exists(uid="${UID}", gid="${GID}") - self.run(command="groupadd -g ${GID} ${USER_NAME}") - self.run( - command='adduser --disabled-password --uid $UID --gid $GID --gecos "" ${USER_NAME}' - ) - self.run(command="adduser ${USER_NAME} sudo") - self.run(command="echo '%sudo ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers") - self.run(command='echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/10-docker') - class DockerfileDockerBuilder(DockerBuilder): """ diff --git a/src/pythainer/examples/builders/__init__.py b/src/pythainer/examples/builders/__init__.py index c700cba..514a2e4 100644 --- a/src/pythainer/examples/builders/__init__.py +++ b/src/pythainer/examples/builders/__init__.py @@ -9,7 +9,7 @@ from typing import List, Tuple -from pythainer.builders import PartialDockerBuilder, UbuntuDockerBuilder +from pythainer.builders import PartialDockerBuilder, UbuntuDockerBuilder, UserManager from pythainer.builders.utils import cmake_build_install from pythainer.examples.installs import clspv_build_install @@ -268,6 +268,7 @@ def rust_builder( install_cargo_edit: bool = True, install_cargo_watch: bool = False, install_nightly: bool = False, + user_manager: UserManager | None = None, ) -> PartialDockerBuilder: """ Sets up a Docker builder for Rust development by installing Rust via rustup @@ -279,12 +280,16 @@ def rust_builder( install_cargo_edit (bool): Whether to install cargo-edit (adds `cargo add`, etc.). install_cargo_watch (bool): Whether to install cargo-watch for file change detection. install_nightly (bool): Whether to install the nightly version of rust or not. + user_manager(UserManager | None): The `UserManager` from the parent builder or None if + the user wants to use the legacy user API Returns: PartialDockerBuilder: Docker builder configured for Rust development. """ builder = PartialDockerBuilder() - builder.user() + builder.user_manager = user_manager + + builder.user(name="User", check=True if builder.user_manager else False) # Install Rust using rustup (non-interactive) cmd = "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y" From c32451dcf2f870c8150f66720280b33024dd800e Mon Sep 17 00:00:00 2001 From: Esteban Aguililla Klein Date: Tue, 28 Oct 2025 18:20:40 +0100 Subject: [PATCH 2/2] Scala container example with UserManager Signed-off-by: Esteban Aguililla Klein --- examples/scala_container.py | 129 ++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 examples/scala_container.py diff --git a/examples/scala_container.py b/examples/scala_container.py new file mode 100644 index 0000000..4de4268 --- /dev/null +++ b/examples/scala_container.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +# Copyright (C) 2025 Antonio Paolillo. All rights reserved. +# SPDX-License-Identifier: MIT + +""" +SCALA Docker Image Builder Script. + +This script uses the `pythainer` framework to build a Docker image containing +a specified version of SCALA installed from binaries. It creates a development +environment with a custom non-root user, installs necessary dependencies + +Example: + $ python3 scala_container.py + +Main Steps: + 1. Create a base image with required dependencies. + 2. Add a non-root user and configure the workspace. + 3. Download and install Java 11 + 4. Download and install Scala + 5. Build the Docker image + 6. Run the Docker image +""" + +from pythainer.builders import PartialDockerBuilder, UserManager +from pythainer.examples.builders import get_user_builder + + +def java_builder(version: str | None, user_manager: UserManager) -> PartialDockerBuilder: + """ + Builds a partial container to extend a base container with Java + + Parameters: + version (str): The Java version to install + user_manager (UserManager): The docker image user_manager + + Returns: + PartialDockerBuilder: A partial builder to extend a docker image with Java + """ + builder = PartialDockerBuilder() + builder.user_manager = user_manager + + # run the commands in the context as root + with builder.as_root() as root_builder: + root_builder.add_packages( + packages=[f"openjdk-{version}-jre" if version != None else "default-jre"] + ) + + version = "21" if version == None else version + root_builder.env(name="JAVA_HOME", value=f"/usr/lib/jvm/java-{version}-openjdk-amd64") + # go back to user before becoming root + + return builder + + +def scala_builder(user: str, user_manager: UserManager) -> PartialDockerBuilder: + """ + Scala container builder + + Parameters: + user (str): The user for whom to install Scala + user_manager (UserManager): The docker image user_manager + + Returns: + PartialDockerBuilder: A partial builder to extend a docker image with Java + """ + + builder = PartialDockerBuilder() + builder.user_manager = user_manager + + with builder.as_user(username=user): + # the java builder should be called outside but, this is a great example to show that the + # execution will be returned to the user space after exiting the user context + builder |= java_builder(version="11", user_manager=user_manager) + + archive_base = "cs-x86_64-pc-linux" + archive = f"{archive_base}.gz" + + # this is dependent on the home existing + builder.run( + "cd ~;" + f"curl -fL https://github.com/coursier/coursier/releases/latest/download/{archive} -o {archive};" + f"gunzip {archive};" + f"chmod +x {archive_base};" + f"./{archive_base} setup -y" + ) + + builder.env(name="PATH", value=f"$PATH:/home/{user}/.local/share/coursier/bin") + + return builder + + +def main(): + """ + Build and run a Docker image containing Java and Scala. + + Steps: + 1. Create a base image with required dependencies. + 2. Add a non-root user and configure the workspace. + 3. Download and install Java 11 + 4. Download and install Scala + 5. Build the Docker image + 6. Run the Docker image + """ + user_name = "user" + docker_workdir = f"/home/{user_name}/workspace" + + builder = get_user_builder( + image_name="scala_container", + base_ubuntu_image="ubuntu:24.04", + user_name=user_name, + ) + + builder.workdir(path=docker_workdir) + + builder |= scala_builder(user=user_name, user_manager=builder.user_manager) + + builder.build() + + runner = builder.get_runner() + + cmd = runner.get_command() + print(" ".join(cmd)) + runner.generate_script() + + runner.run() + + +if __name__ == "__main__": + main()