-
Notifications
You must be signed in to change notification settings - Fork 2
fix: Podman compatibility and testing #142
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
base: main
Are you sure you want to change the base?
Changes from all commits
79bd559
4ab50b3
6e0eb83
265ed01
cbf1484
e43a573
eec02a7
18adba4
ee16afe
04fa690
479d9e1
507f6ec
5648db6
6136e43
4bd8249
73910ba
fc03af6
a9eabc3
3d4024d
b497733
dc0496c
dd43c07
605d82e
28697f1
d002512
f5d4000
5564ef5
a449c25
d899c23
3fdd90e
365cba6
451e705
c822fba
a6cf5f4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -164,24 +164,37 @@ jobs: | |
|
||
strategy: | ||
matrix: | ||
os: [ubuntu-22.04] | ||
os: [ubuntu-24.04] | ||
python-version: ["3.12"] | ||
|
||
arch: ["x64"] | ||
docker-engine: ["docker"] | ||
|
||
unit-tesseract: ${{ fromJson(needs.get-e2e-matrix.outputs.matrix) }} | ||
|
||
include: | ||
# Test on arm to ensure compatibility with Apple M1 chips | ||
# (OSX runners don't have access to Docker so we use Linux ARM runners instead) | ||
- os: "ubuntu-22.04" | ||
- os: "ubuntu-24.04" | ||
python-version: "3.12" | ||
arch: "arm" | ||
docker-engine: "docker" | ||
unit-tesseract: "base" | ||
# Run tests using Podman | ||
- os: "ubuntu-24.04" | ||
python-version: "3.12" | ||
arch: "x64" | ||
docker-engine: "podman" | ||
unit-tesseract: "base" | ||
- os: "ubuntu-24.04" | ||
python-version: "3.12" | ||
arch: "x64" | ||
docker-engine: "podman" | ||
unit-tesseract: "vectoradd_jax" | ||
|
||
fail-fast: false | ||
|
||
runs-on: ${{ matrix.arch == 'x64' && matrix.os == 'ubuntu-22.04' && 'ubuntu-22.04' || matrix.arch == 'arm' && matrix.os == 'ubuntu-22.04' && 'linux-arm64-ubuntu2204' }} | ||
runs-on: ${{ matrix.arch == 'x64' && matrix.os == 'ubuntu-24.04' && 'ubuntu-24.04' || matrix.arch == 'arm' && matrix.os == 'ubuntu-24.04' && 'linux-arm64-ubuntu2404' || matrix.arch == 'x64' && matrix.os == 'ubuntu-24.04' && 'ubuntu-24.04'}} | ||
|
||
defaults: | ||
run: | ||
|
@@ -209,8 +222,27 @@ jobs: | |
run: | | ||
uv sync --extra dev --frozen | ||
|
||
- name: Set up Podman | ||
if: matrix.docker-engine == 'podman' | ||
run: | | ||
systemctl start --user podman.socket | ||
systemctl status --user podman.socket | ||
|
||
podman --version | ||
|
||
export DOCKER_HOST=unix:///run/user/1001/podman/podman.sock | ||
echo "DOCKER_HOST=${DOCKER_HOST}" >> $GITHUB_ENV | ||
|
||
- name: Free some disk space | ||
run: | | ||
sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc \ | ||
/usr/local/share/powershell \ | ||
/usr/share/swift /usr/local/.ghcup /usr/lib/jvm | ||
|
||
- name: Run test suite | ||
run: | | ||
env | grep DOCKER_HOST || true | ||
|
||
Comment on lines
+244
to
+245
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Debug artifact |
||
if [ "${{ matrix.unit-tesseract }}" == "base" ]; then | ||
uv run --no-sync pytest \ | ||
--always-run-endtoend \ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -42,10 +42,48 @@ def from_dict(cls, json_dict: dict) -> "Image": | |
class Images: | ||
"""Namespace for functions to interface with Tesseract docker images.""" | ||
|
||
@staticmethod | ||
def _tag_exists(image_name: str, tags: list_) -> bool: | ||
"""Helper function to check if image name exists in the list of tags. | ||
|
||
Specially handling has to be done to achieve unfuzzy substring matching, i.e. | ||
if image tag is foo/bar/image, we need to return true for foo/bar/image, bar/image, | ||
and image, but not ar/image. | ||
|
||
There is no equivalent in docker-py. | ||
|
||
Params: | ||
image_name: The image name to check. | ||
tags: The list of tags to check against. | ||
|
||
Returns: | ||
True if the image name exists in the list of tags, False otherwise. | ||
""" | ||
if ":" not in image_name: | ||
image_name += ":latest" | ||
|
||
image_name_parts = image_name.strip("/").split("/") | ||
for tag in tags: | ||
tag_parts = tag.strip("/").split("/") | ||
# Check if the last parts of the tag matches the subportion that image name specifies | ||
if tag_parts[-len(image_name_parts) :] == image_name_parts: | ||
return True | ||
return False | ||
|
||
@staticmethod | ||
def get(image_id_or_name: str | bytes, tesseract_only: bool = True) -> Image: | ||
"""Returns the metadata for a specific image. | ||
|
||
In docker-py, there is no substring matching and the image name is the | ||
last tag in the list of tags, so if an image has multiple tags, only | ||
one of the tags would be able to find the image. | ||
|
||
However, in podman, this is not the case. Podman has substring matching | ||
by "/" segments to handle repository urls and returns images even if | ||
partial name is specified, or if image has multiple tags. | ||
|
||
We chose to support podman's largest string matching functionality here. | ||
|
||
Params: | ||
image_id_or_name: The image name or id to get. | ||
tesseract_only: If True, only retrieves Tesseract images. | ||
|
@@ -77,11 +115,7 @@ def is_image_id(s: str) -> bool: | |
image_obj.id == image_id_or_name | ||
or image_obj.short_id == image_id_or_name | ||
or image_id_or_name in image_obj.tags | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove? (should be covered by |
||
or ( | ||
any( | ||
tag.split("/")[-1] == image_id_or_name for tag in image_obj.tags | ||
) | ||
) | ||
or Images._tag_exists(image_id_or_name, image_obj.tags) | ||
): | ||
return image_obj | ||
|
||
|
@@ -711,7 +745,7 @@ class DockerException(Exception): | |
class BuildError(DockerException): | ||
"""Raised when a build fails.""" | ||
|
||
def __init__(self, build_log: str) -> None: | ||
def __init__(self, build_log: list_[str]) -> None: | ||
self.build_log = build_log | ||
|
||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -48,7 +48,7 @@ def print_debug_info(result): | |
|
||
|
||
def build_tesseract( | ||
sourcedir, image_name, config_override=None, tag=None, build_retries=3 | ||
client, sourcedir, image_name, config_override=None, tag=None, build_retries=3 | ||
): | ||
cli_runner = CliRunner(mix_stderr=False) | ||
|
||
|
@@ -87,6 +87,7 @@ def build_tesseract( | |
assert result.exit_code == 0, result.exception | ||
|
||
image_tags = json.loads(result.stdout.strip()) | ||
assert image_name in image_tags | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why remove this? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It was replaced by |
||
|
||
assert client.images._tag_exists(image_name, image_tags) | ||
|
||
return image_tags[0] |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -3,6 +3,7 @@ | |
|
||
"""End to end tests for docker cli wrapper.""" | ||
|
||
import os | ||
import subprocess | ||
import textwrap | ||
from contextlib import closing | ||
|
@@ -52,7 +53,7 @@ def test_get_image(docker_client, docker_client_built_image_name, docker_py_clie | |
# Get the image | ||
image = docker_client.images.get(docker_client_built_image_name) | ||
assert image is not None | ||
assert docker_client_built_image_name in image.tags | ||
assert docker_client.images._tag_exists(docker_client_built_image_name, image.tags) | ||
|
||
docker_py_image = docker_py_client.images.get(docker_client_built_image_name) | ||
assert docker_py_image is not None | ||
|
@@ -100,7 +101,7 @@ def test_get_image(docker_client, docker_client_built_image_name, docker_py_clie | |
assert docker_py_image_list is not None | ||
# Check that every image in image_list is also in docker_py_image_list | ||
for image in image_list: | ||
assert image.id in [img.id for img in docker_py_image_list] | ||
assert image.id in [img.id for img in docker_py_image_list if img] | ||
|
||
|
||
def test_create_image( | ||
|
@@ -120,7 +121,9 @@ def test_create_image( | |
try: | ||
image = docker_client.images.get(docker_client_built_image_name) | ||
assert image is not None | ||
assert docker_client_built_image_name in image.tags | ||
assert docker_client.images._tag_exists( | ||
docker_client_built_image_name, image.tags | ||
) | ||
|
||
image_id_obj = docker_client.images.get(image.id) | ||
image_short_id_obj = docker_client.images.get(image.short_id) | ||
|
@@ -138,7 +141,7 @@ def test_create_image( | |
image1 = docker_client.images.get(image1_name) | ||
image1_name = image1_name + ":latest" | ||
assert image1 is not None | ||
assert image1_name in image1.tags | ||
assert docker_client.images._tag_exists(image1_name, image1.tags) | ||
assert image_exists(docker_client, image1_name) | ||
assert image_exists(docker_py_client, image1_name) | ||
|
||
|
@@ -155,14 +158,33 @@ def test_create_image( | |
# Our docker client should be able to retrieve images with just the name | ||
image2 = docker_client.images.get(repo_url + image2_name) | ||
image2_no_url = docker_client.images.get(image2_name) | ||
assert docker_client.images._tag_exists(image2_name, image2.tags) | ||
|
||
assert image2 is not None | ||
assert image2_no_url is not None | ||
assert image2.id == image2_py.id | ||
assert image2_no_url.id == image2_py.id | ||
|
||
assert image_exists(docker_client, image2_name) | ||
assert image_exists(docker_client, repo_url + image2_name) | ||
assert image_exists(docker_py_client, repo_url + image2_name) | ||
# Check we are not overmatching but we are getting all possible cases | ||
docker_host = os.environ.get("DOCKER_HOST", "") | ||
|
||
podman = False | ||
if "podman" in docker_host: | ||
podman = True | ||
|
||
for client in [docker_client, docker_py_client]: | ||
assert not image_exists(client, "create_image") | ||
|
||
if podman and client == docker_py_client: | ||
# Docker-py does not support partial string matching | ||
assert image_exists(client, image2_name) | ||
assert image_exists(client, f"/{image2_name}") | ||
assert image_exists(client, f"bar/{image2_name}") | ||
assert image_exists(client, f"bar/{image2_name}:latest") | ||
assert not image_exists(client, f"ar/{image2_name}") | ||
assert image_exists(client, f"foo/bar/{image2_name}") | ||
Comment on lines
+178
to
+185
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't get what's going on here. Why are we testing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is just to test if we have the same behavior as docker-py, aka images that docker-py marks as existing vs images we mark as existing vs images docker-py marks as not existing are all the same There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was acutally something I had to do manually a lot as well to validate the behavior of what docker-py marks as existing and what it doesn't and actually docker-py/podman have slightly different behavior which is why we check if podman is in the docker_host. docker-py marks less images as existing than podman and we match podman's behavior. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It's also like some documentation as to what counts as existing and what doesn't for all systems. |
||
|
||
assert image_exists(client, repo_url + image2_name) | ||
|
||
finally: | ||
# Clean up the images | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,6 +4,7 @@ | |
"""End-to-end tests for Tesseract workflows.""" | ||
|
||
import json | ||
import os | ||
from pathlib import Path | ||
|
||
import pytest | ||
|
@@ -20,7 +21,9 @@ def built_image_name( | |
docker_client, docker_cleanup, shared_dummy_image_name, dummy_tesseract_location | ||
): | ||
"""Build the dummy Tesseract image for the tests.""" | ||
image_name = build_tesseract(dummy_tesseract_location, shared_dummy_image_name) | ||
image_name = build_tesseract( | ||
docker_client, dummy_tesseract_location, shared_dummy_image_name | ||
) | ||
assert image_exists(docker_client, image_name) | ||
docker_cleanup["images"].append(image_name) | ||
yield image_name | ||
|
@@ -59,7 +62,11 @@ def test_build_from_init_endtoend( | |
config_override["build_config.base_image"] = base_image | ||
|
||
image_name = build_tesseract( | ||
tmp_path, dummy_image_name, config_override=config_override, tag=img_tag | ||
docker_client, | ||
tmp_path, | ||
dummy_image_name, | ||
config_override=config_override, | ||
tag=img_tag, | ||
) | ||
assert image_exists(docker_client, image_name) | ||
docker_cleanup["images"].append(image_name) | ||
|
@@ -318,6 +325,14 @@ def test_tesseract_serve_ports(built_image_name, port, docker_cleanup): | |
cli_runner = CliRunner(mix_stderr=False) | ||
project_id = None | ||
|
||
docker_host = os.environ.get("DOCKER_HOST", "") | ||
|
||
if "-" in port and "podman" in docker_host: | ||
pytest.skip( | ||
"Podman does not support port ranges in compose." | ||
"See https://github.com/containers/podman/issues/15111" | ||
) | ||
Comment on lines
+328
to
+334
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How will this manifest when actual users try to use this feature? Big scary error I presume? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, with a big scary error :($ tesseract serve -p 8080-8090 vectoradd
[i] Waiting for Tesseract containers to start ...
[-] Command '['docker', 'compose', '-f', '/tmp/docker-compose-5q6c6fow2334.yml067irwdg', '-p', 'tesseract-oztyf31595ji', 'up', '-d', '--wait']' returned non-zero exit status 1.
[-] time="2025-05-07T11:32:50-03:00" level=warning msg="a network with name multi-tesseract-network exists but was not created for project \"tesseract-oztyf31595ji\".\nSet `external: true` to use an existing network"
Container tesseract-oztyf31595ji-sha256-1u8fa9j3tlsb-1 Creating
Error response from daemon: make cli opts(): strconv.Atoi: parsing "8080-8090": invalid syntax
[x] Uncaught error: ('Failed to start Tesseract containers.', b'time="2025-05-07T11:32:50-03:00" level=warning msg="a network with name multi-tesseract-network exists but was not created for project \\"tesseract-oztyf31595ji\\".\\nSet `external: true` to use an existing network"\n Container tesseract-oztyf31595ji-sha256-1u8fa9j3tlsb-1 Creating\nError response from daemon: make cli opts(): strconv.Atoi: parsing "8080-8090": invalid syntax\n')
╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮
│ /home/h/projects/pasteur/tesseract-core/tesseract_core/sdk/docker_client.py:653 in up │
│ │
│ 650 │ │ """ │
│ 651 │ │ logger.info("Waiting for Tesseract containers to start ...") │
│ 652 │ │ try: │
│ ❱ 653 │ │ │ _ = subprocess.run( │
│ 654 │ │ │ │ [ │
│ 655 │ │ │ │ │ "docker", │
│ 656 │ │ │ │ │ "compose", │
│ │
│ /home/h/.local/share/uv/python/cpython-3.13.3-linux-x86_64-gnu/lib/python3.13/subprocess.py:577 │
│ in run │
│ │
│ 574 │ │ │ raise │
│ 575 │ │ retcode = process.poll() │
│ 576 │ │ if check and retcode: │
│ ❱ 577 │ │ │ raise CalledProcessError(retcode, process.args, │
│ 578 │ │ │ │ │ │ │ │ │ output=stdout, stderr=stderr) │
│ 579 │ return CompletedProcess(process.args, retcode, stdout, stderr) │
│ 580 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
CalledProcessError: Command '['docker', 'compose', '-f',
'/tmp/docker-compose-5q6c6fow2334.yml067irwdg', '-p', 'tesseract-oztyf31595ji', 'up', '-d', '--wait']'
returned non-zero exit status 1.
The above exception was the direct cause of the following exception:
╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮
│ /home/h/projects/pasteur/tesseract-core/tesseract_core/sdk/engine.py:114 in wrapper_needs_docker │
│ │
│ 111 │ │ │ raise UserError( │
│ 112 │ │ │ │ "Could not reach Docker daemon, check if it is running." │
│ 113 │ │ │ ) from ex │
│ ❱ 114 │ │ return func(*args, **kwargs) │
│ 115 │ │
│ 116 │ return wrapper_needs_docker │
│ 117 │
│ │
│ /home/h/projects/pasteur/tesseract-core/tesseract_core/sdk/cli.py:453 in serve │
│ │
│ 450 │ │ ports = None │
│ 451 │ │
│ 452 │ try: │
│ ❱ 453 │ │ project_id = engine.serve(image_names, ports, volume, gpus) │
│ 454 │ │ container_ports = _display_project_meta(project_id) │
│ 455 │ │ logger.info( │
│ 456 │ │ │ f"Docker Compose Project ID, use it with 'tesseract teardown' command: {proj │
│ │
│ /home/h/projects/pasteur/tesseract-core/tesseract_core/sdk/engine.py:545 in serve │
│ │
│ 542 │ │ compose_file.flush() │
│ 543 │ │ │
│ 544 │ │ project_name = _create_compose_proj_id() │
│ ❱ 545 │ │ if not docker_client.compose.up(compose_file.name, project_name): │
│ 546 │ │ │ raise RuntimeError("Cannot serve Tesseracts") │
│ 547 │ │ return project_name │
│ 548 │
│ │
│ /home/h/projects/pasteur/tesseract-core/tesseract_core/sdk/docker_client.py:683 in up │
│ │
│ 680 │ │ │ │ ) from ex │
│ 681 │ │ │ logger.error(str(ex)) │
│ 682 │ │ │ logger.error(ex.stderr.decode()) │
│ ❱ 683 │ │ │ raise ContainerError( │
│ 684 │ │ │ │ "Failed to start Tesseract containers.", ex.stderr │
│ 685 │ │ │ ) from ex │
│ 686 │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
ContainerError: ('Failed to start Tesseract containers.', b'time="2025-05-07T11:32:50-03:00"
level=warning msg="a network with name multi-tesseract-network exists but was not created for project
\\"tesseract-oztyf31595ji\\".\\nSet `external: true` to use an existing network"\n Container
tesseract-oztyf31595ji-sha256-1u8fa9j3tlsb-1 Creating\nError response from daemon: make cli opts():
strconv.Atoi: parsing "8080-8090": invalid syntax\n')
[x] Aborting Should we handle this here or on a separate PR? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If we can detect Podman reliably ahead of time (ideally not by parsing There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The only way I'm aware of to check if |
||
|
||
# Serve tesseract on specified ports. | ||
run_res = cli_runner.invoke( | ||
app, | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Feels icky to me (but ok if we have no better solution).
Are we sure that cleanup works as expected? Why do we suddenly run out of disk space?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why?
It does. I didn't check how much space it clears, but it is a few GB. This solves the issue of one of the tests erroring with "no space left on device" when unpacking an image.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I mean the Docker cleanup we run after each test.
Feels icky because we'll have the same situation again after we add 5 more tests :)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly. I just noticed I have a
unit_vectoradd:latest
after running the E2E tests locally, so maybe there's some cleanup missing?