Skip to content

Commit 7420367

Browse files
authored
Merge pull request #3706 from ErikDanielsson/container-load-scripts
Add container load scripts for Docker and Podman (#3634 follow up)
2 parents d745975 + 1fa9c0d commit 7420367

File tree

12 files changed

+1281
-1012
lines changed

12 files changed

+1281
-1012
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
- Add support for downloading docker images into tar archives
3333
- Change long flag `--parallel-downloads` to `--parallel`. Short flag remains `-d`.
3434
- Add pipeline to test data to be compatible with `nextflow inspect`
35+
- Add container load scripts for Docker and Podman (#3634 follow up) ([#3706](https://github.com/nf-core/tools/pull/3706))
3536
- Replace arm profile with arm64 and emulate_amd64 profiles ([#3689](https://github.com/nf-core/tools/pull/3689))
3637
- Update test-datasets list subcommand to output plain text urls and paths for easy copying [#3720](https://github.com/nf-core/tools/pull/3720)
3738
- Remove workflow.trace from nf-test snapshot ([#3721](https://github.com/nf-core/tools/pull/3721))

nf_core/pipelines/download/docker.py

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@
1414

1515
import nf_core.utils
1616
from nf_core.pipelines.download.container_fetcher import ContainerFetcher, ContainerProgress
17+
from nf_core.pipelines.download.utils import copy_container_load_scripts
1718

1819
log = logging.getLogger(__name__)
1920
stderr = rich.console.Console(
2021
stderr=True,
21-
style="dim",
2222
highlight=False,
2323
force_terminal=nf_core.utils.rich_force_colors(),
2424
)
@@ -308,21 +308,22 @@ def write_docker_load_message(self) -> None:
308308
"""
309309
Write a message to the user about how to load the downloaded docker images into the offline docker daemon
310310
"""
311-
# There is not direct Nextflow support for loading docker images like we do for Singularity
312-
# Instead we give the user a `bash` command to load the downloaded docker images into the offline docker daemon
313-
# Courtesy of @vmkalbskopf in https://github.com/nextflow-io/nextflow/discussions/4708
314-
# TODO: Should we create a bash script instead?
315-
docker_load_command = "ls -1 *.tar | xargs --no-run-if-empty -L 1 docker load -i"
316-
indent_spaces = 4
311+
# Original command courtesy of @vmkalbskopf in https://github.com/nextflow-io/nextflow/discussions/4708
317312
docker_img_dir = self.get_container_output_dir()
313+
podman_load_script, _ = copy_container_load_scripts("podman", docker_img_dir)
314+
docker_load_script, _ = copy_container_load_scripts("docker", docker_img_dir)
315+
indent_spaces = 4
318316
stderr.print(
319317
"\n"
320-
+ (1 * indent_spaces * " " + f"Downloaded docker images written to [blue not bold]'{docker_img_dir}'[/]. ")
321-
+ (0 * indent_spaces * " " + "After copying the pipeline and images to the offline machine, run\n\n")
322-
+ (2 * indent_spaces * " " + f"[blue bold]{docker_load_command}[/]\n\n")
318+
+ (1 * indent_spaces * " " + f"Downloaded docker images written to [magenta]'{docker_img_dir}'[/].\n")
319+
+ (1 * indent_spaces * " " + "After copying the pipeline and images to the offline machine, run\n\n")
320+
+ (
321+
2 * indent_spaces * " "
322+
+ f"[green]./{docker_load_script}[/] (or [green]./{podman_load_script}[/] (experimental))\n\n"
323+
)
323324
+ (
324325
1 * indent_spaces * " "
325-
+ f"inside [blue not bold]'{docker_img_dir}'[/] to load the images into the offline docker daemon."
326+
+ f"inside [magenta]'{docker_img_dir}'[/] to load the images into the offline Docker (Podman) daemon."
326327
)
327328
+ "\n"
328329
)

nf_core/pipelines/download/load_scripts/__init__.py

Whitespace-only changes.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail # Ensure that the script exits as early as possible
3+
4+
LOGFILE="podman-load.log"
5+
6+
# Clear log
7+
> "$LOGFILE"
8+
9+
if ! command -v docker &> /dev/null
10+
then
11+
echo "Error: Docker is not installed. Please install it to continue." >&2
12+
exit 1
13+
fi
14+
15+
if ! docker info &> /dev/null; then
16+
echo "Error: Docker daemon is not running." >&2
17+
exit 1
18+
fi
19+
20+
echo "Loading tar archives into docker"
21+
for tarfile in $(ls -1 *.tar); do
22+
if output=$(docker load -i $tarfile); then
23+
echo "SUCCESS: $tarfile"
24+
echo "SUCCESS: $tarfile" >> "$LOGFILE"
25+
echo $output >> "$LOGFILE"
26+
echo "----------------------------------------------------------------" >> "$LOGFILE"
27+
else
28+
echo "ERROR: $tarfile"
29+
echo "ERROR: $tarfile" >> "$LOGFILE"
30+
echo $output >> "$LOGFILE"
31+
echo "----------------------------------------------------------------" >> "$LOGFILE"
32+
fi
33+
done
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
#!/usr/bin/env bash
2+
set -euo pipefail # Ensure that the script exits as early as possible
3+
4+
LOGFILE="podman-load.log"
5+
6+
# Clear log
7+
> "$LOGFILE"
8+
9+
if ! command -v podman &> /dev/null
10+
then
11+
echo "Error: Podman is not installed. Please install it to continue." >&2
12+
exit 1
13+
fi
14+
15+
if ! podman info &> /dev/null; then
16+
echo "Error: No Podman machine is ready. Make sure it's installed and configured." >&2
17+
exit 1
18+
fi
19+
20+
PODMAN_LOAD_SINGLE_IMAGE() {
21+
local TARFILE="$1"
22+
23+
# Look for the full image name in the mainfest.json
24+
# inside the image tar archive. It is contained in
25+
# this RepoTags field
26+
REPO_TAG=$(tar -O -xf "$TARFILE" manifest.json | python -c 'import json, sys; print(json.load(sys.stdin)[0]["RepoTags"][0])')
27+
28+
if [[ -z "$REPO_TAG" || "$REPO_TAG" == "null" ]]; then
29+
echo "Error: Could not find RepoTags in $TARFILE" >&2
30+
exit 1
31+
fi
32+
33+
# Load the tar archive into podman -- this will typically
34+
# save it as localhost/..., which won't work if we want to
35+
# use the images in the nf-core pipeline
36+
PODMAN_LOAD_OUTPUT=$(podman load -i "$TARFILE" 2>&1)
37+
38+
# Extract the tag podman created for the image for later renaming
39+
LOCAL_TAG=$(echo "$PODMAN_LOAD_OUTPUT" | sed -n 's/^Loaded image(s): \(.*\)$/\1/p')
40+
41+
if [[ -z "$LOCAL_TAG" ]]; then
42+
echo "Error: Could not parse loaded image name from podman load output" >&2
43+
exit 1
44+
fi
45+
46+
# Tag the loaded image with the original remote name
47+
podman tag "$LOCAL_TAG" "$REPO_TAG"
48+
49+
echo "Success, loaded and tagged: $REPO_TAG"
50+
}
51+
52+
echo "Loading tar archives into podman"
53+
for tarfile in $(ls -1 *.tar); do
54+
if output=$(PODMAN_LOAD_SINGLE_IMAGE $tarfile); then
55+
echo "SUCCESS: $tarfile"
56+
echo "SUCCESS: $tarfile" >> "$LOGFILE"
57+
echo $output >> "$LOGFILE"
58+
echo "----------------------------------------------------------------" >> "$LOGFILE"
59+
else
60+
echo "ERROR: $tarfile"
61+
echo "ERROR: $tarfile" >> "$LOGFILE"
62+
echo $output >> "$LOGFILE"
63+
echo "----------------------------------------------------------------" >> "$LOGFILE"
64+
fi
65+
done

nf_core/pipelines/download/utils.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
import contextlib
2+
import importlib.resources
23
import logging
4+
import shutil
35
import tempfile
46
from collections.abc import Generator
57
from pathlib import Path
68

79
log = logging.getLogger(__name__)
810

911

12+
def copy_container_load_scripts(container_system: str, dest_dir: Path, make_exec: bool = True) -> tuple[str, Path]:
13+
container_load_scripts_subpackage = "nf_core.pipelines.download.load_scripts"
14+
script_name = f"{container_system}-load.sh"
15+
dest_path = dest_dir / script_name
16+
with importlib.resources.open_text(container_load_scripts_subpackage, script_name) as src:
17+
with open(dest_path, "w") as dest:
18+
shutil.copyfileobj(src, dest)
19+
if make_exec:
20+
dest_path.chmod(0o775)
21+
return script_name, dest_path
22+
23+
1024
class DownloadError(RuntimeError):
1125
"""A custom exception that is raised when nf-core pipelines download encounters a problem that we already took into consideration.
1226
In this case, we do not want to print the traceback, but give the user some concise, helpful feedback instead.

tests/pipelines/download/__init__.py

Whitespace-only changes.
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
"""Tests for the download subcommand of nf-core tools"""
2+
3+
import unittest
4+
5+
import pytest
6+
import rich.progress_bar
7+
import rich.table
8+
import rich.text
9+
10+
from nf_core.pipelines.download.container_fetcher import ContainerProgress
11+
12+
13+
class ContainerProgressTest(unittest.TestCase):
14+
@pytest.fixture(autouse=True)
15+
def use_caplog(self, caplog):
16+
self._caplog = caplog
17+
18+
#
19+
# Test for 'utils..add/update_main_task'
20+
#
21+
def test_download_progress_main_task(self):
22+
with ContainerProgress() as progress:
23+
# No task initially
24+
assert progress.tasks == []
25+
26+
# Add a task, it should be there
27+
task_id = progress.add_main_task(total=42)
28+
assert task_id == 0
29+
assert len(progress.tasks) == 1
30+
assert progress.task_ids[0] == task_id
31+
assert progress.tasks[0].total == 42
32+
33+
# Add another task, there should now be two
34+
other_task_id = progress.add_task("Another task", total=28)
35+
assert other_task_id == 1
36+
assert len(progress.tasks) == 2
37+
assert progress.task_ids[1] == other_task_id
38+
assert progress.tasks[1].total == 28
39+
40+
progress.update_main_task(total=35)
41+
assert progress.tasks[0].total == 35
42+
assert progress.tasks[1].total == 28
43+
44+
#
45+
# Test for 'utils.DownloadProgress.sub_task'
46+
#
47+
def test_download_progress_sub_task(self):
48+
with ContainerProgress() as progress:
49+
# No task initially
50+
assert progress.tasks == []
51+
52+
# Add a sub-task, it should be there
53+
with progress.sub_task("Sub-task", total=42) as sub_task_id:
54+
assert sub_task_id == 0
55+
assert len(progress.tasks) == 1
56+
assert progress.task_ids[0] == sub_task_id
57+
assert progress.tasks[0].total == 42
58+
59+
# The sub-task should be gone now
60+
assert progress.tasks == []
61+
62+
# Add another sub-task, this time that raises an exception
63+
with pytest.raises(ValueError):
64+
with progress.sub_task("Sub-task", total=28) as sub_task_id:
65+
assert sub_task_id == 1
66+
assert len(progress.tasks) == 1
67+
assert progress.task_ids[0] == sub_task_id
68+
assert progress.tasks[0].total == 28
69+
raise ValueError("This is a test error")
70+
71+
# The sub-task should also be gone now
72+
assert progress.tasks == []
73+
74+
#
75+
# Test for 'utils.DownloadProgress.get_renderables'
76+
#
77+
def test_download_progress_renderables(self):
78+
# Test the "summary" progress type
79+
with ContainerProgress() as progress:
80+
assert progress.tasks == []
81+
progress.add_task("Task 1", progress_type="summary", total=42, completed=11)
82+
assert len(progress.tasks) == 1
83+
84+
renderable = progress.get_renderable()
85+
assert isinstance(renderable, rich.console.Group), type(renderable)
86+
87+
assert len(renderable.renderables) == 1
88+
table = renderable.renderables[0]
89+
assert isinstance(table, rich.table.Table)
90+
91+
assert isinstance(table.columns[0]._cells[0], str)
92+
assert table.columns[0]._cells[0] == "[magenta]Task 1"
93+
94+
assert isinstance(table.columns[1]._cells[0], rich.progress_bar.ProgressBar)
95+
assert table.columns[1]._cells[0].completed == 11
96+
assert table.columns[1]._cells[0].total == 42
97+
98+
assert isinstance(table.columns[2]._cells[0], str)
99+
assert table.columns[2]._cells[0] == "[progress.percentage] 26%"
100+
101+
assert isinstance(table.columns[3]._cells[0], str)
102+
assert table.columns[3]._cells[0] == "•"
103+
104+
assert isinstance(table.columns[4]._cells[0], str)
105+
assert table.columns[4]._cells[0] == "[green]11/42 tasks completed"
106+
107+
108+
class ContainerTest(unittest.TestCase):
109+
@pytest.fixture(autouse=True)
110+
def use_caplog(self, caplog):
111+
self._caplog = caplog
112+
113+
@property
114+
def logged_levels(self) -> list[str]:
115+
return [record.levelname for record in self._caplog.records]
116+
117+
@property
118+
def logged_messages(self) -> list[str]:
119+
return [record.message for record in self._caplog.records]
120+
121+
def __contains__(self, item: str) -> bool:
122+
"""Allows to check for log messages easily using the in operator inside a test:
123+
assert 'my log message' in self
124+
"""
125+
return any(record.message == item for record in self._caplog.records if self._caplog)
126+
127+
pass

0 commit comments

Comments
 (0)