Skip to content

Add podman connector #1354

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 14 commits into from
Jun 23, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 116 additions & 38 deletions pyinfra/connectors/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,29 +33,6 @@ class ConnectorData(TypedDict):
}


def _find_start_docker_container(container_id) -> tuple[str, bool]:
docker_info = local.shell("docker container inspect {0}".format(container_id))
assert isinstance(docker_info, str)
docker_info = json.loads(docker_info)[0]
if docker_info["State"]["Running"] is False:
logger.info("Starting stopped container: {0}".format(container_id))
local.shell("docker container start {0}".format(container_id))
return container_id, False
return container_id, True


def _start_docker_image(image_name):
try:
return local.shell(
"docker run -d {0} tail -f /dev/null".format(image_name),
splitlines=True,
)[
-1
] # last line is the container ID
except PyinfraError as e:
raise ConnectError(e.args[0])


class DockerConnector(BaseConnector):
"""
The Docker connector allows you to use pyinfra to create new Docker images or modify running
Expand Down Expand Up @@ -89,6 +66,9 @@ class DockerConnector(BaseConnector):
writing deploys, operations or facts.
"""

# enable the use of other docker cli compatible tools like podman
docker_cmd = "docker"

handles_execution = True

data_cls = ConnectorData
Expand All @@ -111,50 +91,76 @@ def make_names_data(name=None):
raise InventoryError("No docker base ID provided!")

yield (
"@docker/{0}".format(name),
f"@docker/{name}",
{"docker_identifier": name},
["@docker"],
)

# 2 helper functions
def _find_start_docker_container(self, container_id) -> tuple[str, bool]:
docker_info = local.shell(f"{self.docker_cmd} container inspect {container_id}")
assert isinstance(docker_info, str)
docker_info = json.loads(docker_info)[0]
if docker_info["State"]["Running"] is False:
logger.info(f"Starting stopped container: {container_id}")
local.shell(f"{self.docker_cmd} container start {container_id}")
return container_id, False
return container_id, True

def _start_docker_image(self, image_name):
try:
return local.shell(
f"{self.docker_cmd} run -d {image_name} tail -f /dev/null",
splitlines=True,
)[
-1
] # last line is the container ID
except PyinfraError as e:
raise ConnectError(e.args[0])

@override
def connect(self) -> None:
self.local.connect()

docker_identifier = self.data["docker_identifier"]
with progress_spinner({"prepare docker container"}):
with progress_spinner({f"prepare {self.docker_cmd} container"}):
try:
self.container_id, was_running = _find_start_docker_container(docker_identifier)
self.container_id, was_running = self._find_start_docker_container(
docker_identifier
)
if was_running:
self.no_stop = True
except PyinfraError:
self.container_id = _start_docker_image(docker_identifier)
self.container_id = self._start_docker_image(docker_identifier)

@override
def disconnect(self) -> None:
container_id = self.container_id

if self.no_stop:
logger.info(
"{0}docker build complete, container left running: {1}".format(
"{0}{1} build complete, container left running: {2}".format(
self.host.print_prefix,
self.docker_cmd,
click.style(container_id, bold=True),
),
)
return

with progress_spinner({"docker commit"}):
image_id = local.shell("docker commit {0}".format(container_id), splitlines=True)[-1][
with progress_spinner({f"{self.docker_cmd} commit"}):
image_id = local.shell(f"{self.docker_cmd} commit {container_id}", splitlines=True)[-1][
7:19
] # last line is the image ID, get sha256:[XXXXXXXXXX]...

with progress_spinner({"docker rm"}):
with progress_spinner({f"{self.docker_cmd} rm"}):
local.shell(
"docker rm -f {0}".format(container_id),
f"{self.docker_cmd} rm -f {container_id}",
)

logger.info(
"{0}docker build complete, image ID: {1}".format(
"{0}{1} build complete, image ID: {2}".format(
self.host.print_prefix,
self.docker_cmd,
click.style(image_id, bold=True),
),
)
Expand All @@ -176,7 +182,7 @@ def run_shell_command(

docker_flags = "-it" if local_arguments.get("_get_pty") else "-i"
docker_command = StringCommand(
"docker",
self.docker_cmd,
"exec",
docker_flags,
container_id,
Expand All @@ -203,7 +209,7 @@ def put_file(
**kwargs, # ignored (sudo/etc)
) -> bool:
"""
Upload a file/IO object to the target Docker container by copying it to a
Upload a file/IO object to the target container by copying it to a
temporary location and then uploading it into the container using ``docker cp``.
"""

Expand All @@ -221,7 +227,7 @@ def put_file(
temp_f.write(data)

docker_command = StringCommand(
"docker",
self.docker_cmd,
"cp",
temp_filename,
f"{self.container_id}:{remote_filename}",
Expand Down Expand Up @@ -261,15 +267,15 @@ def get_file(
**kwargs, # ignored (sudo/etc)
) -> bool:
"""
Download a file from the target Docker container by copying it to a temporary
Download a file from the target container by copying it to a temporary
location and then reading that into our final file/IO object.
"""

fd, temp_filename = mkstemp()

try:
docker_command = StringCommand(
"docker",
self.docker_cmd,
"cp",
f"{self.container_id}:{remote_filename}",
temp_filename,
Expand Down Expand Up @@ -303,3 +309,75 @@ def get_file(
)

return status


class PodmanConnector(DockerConnector):
"""
The Podman connector allows you to use pyinfra to create new Podman images or modify running
Podman containers.

.. note::

The Podman connector allows pyinfra to target Podman containers as inventory and is
unrelated to the :doc:`../operations/docker` & :doc:`../facts/docker`.

You can pass either an image name or existing container ID:

+ Image - will create a new container from the image, execute operations against it, save into \
a new Podman image and remove the container
+ Existing container ID - will execute operations against the running container, leaving it \
running

.. code:: shell

# A Podman base image must be provided
pyinfra @podman/alpine:3.8 ...

# pyinfra can run on multiple Docker images in parallel
pyinfra @podman/alpine:3.8,@podman/ubuntu:bionic ...

# Execute against a running container
pyinfra @podman/2beb8c15a1b1 ...

The Podman connector is great for testing pyinfra operations locally, rather than connecting to
a remote host over SSH each time. This gives you a fast, local-first devloop to iterate on when
writing deploys, operations or facts.
"""

docker_cmd = "podman"

@override
@staticmethod
def make_names_data(name=None):
if not name:
raise InventoryError("No podman base ID provided!")

yield (
f"@podman/{name}",
{"docker_identifier": name},
["@podman"],
)

# Duplicate function definition to swap the docstring.
@override
def put_file(
self,
filename_or_io,
remote_filename,
remote_temp_filename=None, # ignored
print_output=False,
print_input=False,
**kwargs, # ignored (sudo/etc)
) -> bool:
"""
Upload a file/IO object to the target container by copying it to a
temporary location and then uploading it into the container using ``podman cp``.
"""
return super().put_file(
filename_or_io,
remote_filename,
remote_temp_filename, # ignored
print_output,
print_input,
**kwargs, # ignored (sudo/etc)
)
3 changes: 3 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
)

TEST_REQUIRES = (
# Must have click 8.2 since they changed CliRunner for tests
"click>=8.2",
# Unit testing
"pytest==8.3.5",
"coverage==7.7.1",
Expand Down Expand Up @@ -120,6 +122,7 @@ def get_readme_contents():
"pyinfra.connectors": [
"chroot = pyinfra.connectors.chroot:ChrootConnector",
"docker = pyinfra.connectors.docker:DockerConnector",
"podman = pyinfra.connectors.docker:PodmanConnector",
"local = pyinfra.connectors.local:LocalConnector",
"ssh = pyinfra.connectors.ssh:SSHConnector",
"dockerssh = pyinfra.connectors.dockerssh:DockerSSHConnector",
Expand Down
9 changes: 9 additions & 0 deletions tests/end-to-end/test_e2e_docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,12 @@ def test_int_docker_install_package_ubuntu(helpers):
"pyinfra -y --chdir examples @docker/ubuntu:22.04 apt.packages iftop update=true",
expected_lines=["docker build complete"],
)


@pytest.mark.end_to_end
@pytest.mark.end_to_end_docker
def test_int_podman_install_package_ubuntu(helpers):
helpers.run_check_output(
"pyinfra -y --chdir examples @podman/ubuntu:22.04 apt.packages iftop update=true",
expected_lines=["podman build complete"],
)
Loading