Skip to content
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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ description-file = "README.md"
classifiers = ["License :: OSI Approved :: MIT License"]
requires-python = ">=3.6"
requires = [
"argcomplete",
"jinja2",
"requests",
"PyYAML",
Expand Down
72 changes: 60 additions & 12 deletions test/unit/test_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -198,28 +198,76 @@ def test_run_errors(mocker, tmp_path):
pass
popen.assert_called_once()

# Test duplicated source bindings
# Test duplicated destination bindings (duplicated sources are allowed)
popen = mocker.patch("subprocess.Popen", side_effect=FileNotFoundError)

runtime = Runtime.select("podman")(tmp_path)
runtime.name("name")
runtime.image("image")
runtime.bind("/hello", "/world")
runtime.bind("/hello", "/world2")
runtime.bind("/hello2", "/world")
with pytest.raises(Exception) as exc:
with runtime.run(["hello", "world"]):
pass
assert exc.match("Duplicated mount source '/hello'")
assert exc.match("Duplicated mount destination '/world'")
popen.assert_not_called()

# Test duplicated destination bindings

def test_use_host_network(tmp_path):
runtime = Runtime.select("podman")(tmp_path)
runtime.name("name")
runtime.image("image")
runtime.bind("/hello", "/world")
runtime.bind("/hello2", "/world")
with pytest.raises(Exception) as exc:
with runtime.run(["hello", "world"]):
pass
assert exc.match("Duplicated mount destination '/world'")
popen.assert_not_called()
runtime.use_host_network()

cmd = runtime.cmd(["hello", "world"])
found_host_network = False
for i, arg in enumerate(cmd):
if arg == "--network" and i + 1 < len(cmd) and cmd[i + 1] == "host":
found_host_network = True
break
assert found_host_network, f"--network host not found in {cmd}"


def test_skip_http_server_sets_entrypoint(tmp_path):
runtime = Runtime.select("podman")(tmp_path)
runtime.name("name")
runtime.image("image")
runtime.skip_http_server()

cmd = runtime.cmd(["hello", "world"])
assert "--entrypoint" in cmd
entrypoint_idx = cmd.index("--entrypoint")
assert cmd[entrypoint_idx + 1] == "/usr/bin/lava-run"


def test_skip_http_server_strips_lava_run_from_args(tmp_path):
runtime = Runtime.select("podman")(tmp_path)
runtime.name("name")
runtime.image("image")
runtime.skip_http_server()

cmd = runtime.cmd(["lava-run", "--device", "foo", "definition.yaml"])
assert cmd[-3:] == ["--device", "foo", "definition.yaml"]
assert "--entrypoint" in cmd


def test_skip_http_server_preserves_other_args(tmp_path):
runtime = Runtime.select("podman")(tmp_path)
runtime.name("name")
runtime.image("image")
runtime.skip_http_server()

cmd = runtime.cmd(["other-command", "--option", "value"])
assert cmd[-3:] == ["other-command", "--option", "value"]


def test_host_network_and_skip_http_server_combined(tmp_path):
runtime = Runtime.select("podman")(tmp_path)
runtime.name("name")
runtime.image("image")
runtime.use_host_network()
runtime.skip_http_server()

cmd = runtime.cmd(["lava-run", "--device", "foo"])
assert "--network" in cmd
assert "--entrypoint" in cmd
assert cmd[-2:] == ["--device", "foo"]
75 changes: 64 additions & 11 deletions tuxrun/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
#
# SPDX-License-Identifier: MIT

import argcomplete
import contextlib
import ipaddress
import json
import logging
import re
Expand Down Expand Up @@ -37,6 +39,7 @@
from tuxrun.writer import Writer # noqa: E402
from tuxrun.yaml import yaml_load # noqa: E402

from tuxlava.devices import Device # type: ignore # noqa: E402
from tuxlava.jobs import Job # type: ignore # noqa: E402
from tuxlava.exceptions import TuxLavaException # type: ignore # noqa: E402

Expand Down Expand Up @@ -188,6 +191,7 @@ def run(options, tmpdir: Path, cache_dir: Optional[Path], artefacts: dict) -> in
"fip": options.fip,
"job_definition": options.job_definition,
"kernel": options.kernel,
"device_dict": options.device_dict,
"mcp_fw": options.mcp_fw,
"mcp_romfw": options.mcp_romfw,
"modules": options.modules,
Expand Down Expand Up @@ -250,20 +254,23 @@ def run(options, tmpdir: Path, cache_dir: Optional[Path], artefacts: dict) -> in
if options.fvp_ubl_license:
context["fvp_ubl_license"] = options.fvp_ubl_license

device_dict = job.device.device_dict(context)
device_dict = job.device.device_dict(context, d_dict_config=job.d_dict_config)
LOG.debug("device dictionary")
LOG.debug(device_dict)

(tmpdir / "definition.yaml").write_text(definition, encoding="utf-8")
(tmpdir / "device.yaml").write_text(device_dict, encoding="utf-8")

# Render the dispatcher.yaml
(tmpdir / "dispatcher").mkdir()
raw_ip = options.parameters.get("DISPATCHER_IP")
dispatcher_ip = normalize_ip(raw_ip) if raw_ip else None
dispatcher = (
templates.dispatchers()
.get_template("dispatcher.yaml.jinja2")
.render(
dispatcher_download_dir=options.dispatcher_download_dir, prefix=tmpdir.name
dispatcher_download_dir=options.dispatcher_download_dir,
prefix=tmpdir.name,
dispatcher_ip=dispatcher_ip,
)
)
LOG.debug("dispatcher config")
Expand Down Expand Up @@ -303,6 +310,30 @@ def run(options, tmpdir: Path, cache_dir: Optional[Path], artefacts: dict) -> in
if job.qemu_binary:
overlay_qemu(job.qemu_binary, tmpdir, runtime)

if options.device_dict:
runtime.use_host_network()
runtime.skip_http_server()
if job.d_dict_config:
extra_args = job.d_dict_config.get("docker_shell_extra_arguments", [])
for arg in extra_args:
if arg.startswith("--volume="):
volume_spec = arg[len("--volume=") :]
parts = volume_spec.split(":")
if len(parts) >= 2:
host_path = Path(parts[0])
container_path = Path(parts[1])
ro = len(parts) > 2 and parts[2] == "ro"
if host_path.exists():
runtime.bind(host_path, container_path, ro=ro)
control_binaries_param = options.parameters.get("DEVICE_CONTROL_BINARIES", "")
control_binaries = (
control_binaries_param.split(",") if control_binaries_param else []
)
for path in control_binaries:
path = path.strip()
if path and Path(path).exists():
runtime.bind(Path(path), Path(path), ro=True)

# Forward the signal to the runtime
def handler(*_):
LOG.debug("Signal received")
Expand All @@ -320,14 +351,17 @@ def handler(*_):
LOG.debug("Job timeout %ds", job_timeout)
signal.alarm(job_timeout)

# start the pre_run command
if job.device.flag_use_pre_run_cmd or job.qemu_image:
# Start the pre_run command
if job.device.flag_use_pre_run_cmd or job.qemu_image or options.device_dict:
LOG.debug("Pre run command")
runtime.bind(
tmpdir / "dispatcher" / "tmp",
options.dispatcher_download_dir,
)
(tmpdir / "dispatcher" / "tmp").mkdir()
if options.device_dict:
runtime.bind(options.dispatcher_download_dir, Path("/srv/tftp"))
runtime.bind(
options.dispatcher_download_dir, options.dispatcher_download_dir
)
else:
runtime.bind(tmpdir / "dispatcher" / "tmp", options.dispatcher_download_dir)
(tmpdir / "dispatcher" / "tmp").mkdir()
runtime.pre_run(tmpdir)

# Build the lava-run arguments list
Expand Down Expand Up @@ -392,9 +426,16 @@ def handler(*_):
)


def normalize_ip(value):
try:
return str(ipaddress.ip_address(value))
except (ValueError, TypeError):
raise ValueError(f"Invalid IP address: {value!r}")


def main() -> int:
# Parse command line
parser = setup_parser()
argcomplete.autocomplete(parser)
options = parser.parse_args()

# Setup logging
Expand All @@ -407,6 +448,18 @@ def main() -> int:
if not (options.tuxmake or options.tuxbuild):
parser.error("argument --device is required")

if options.device and not options.device_dict:
device_choices = [d.name for d in Device.list(virtual_device=True)]
device_choices = sorted(set(device_choices))
if options.device not in device_choices:
parser.error(
f"argument --device: invalid choice: '{options.device}' "
f"(choose from {', '.join(device_choices)})"
)

if options.device_dict and not options.parameters.get("DISPATCHER_IP"):
parser.error("argument missing --parameters DISPATCHER_IP='...'")

if "hacking-session" in options.tests:
options.enable_network = True
if not options.parameters.get("PUB_KEY"):
Expand Down
30 changes: 24 additions & 6 deletions tuxrun/argparse.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,18 @@
from tuxlava.tests import Test # type: ignore


##############
# Completers #
##############
def device_completer(**kwargs):
devices = [d.name for d in Device.list(virtual_device=True)]
return sorted(set(devices))


def test_completer(**kwargs):
return Test.list(virtual_device=True)


###########
# Helpers #
###########
Expand Down Expand Up @@ -50,10 +62,9 @@ def __init__(
super().__init__(option_strings, dest=dest, default=default, nargs=0, help=help)

def __call__(self, parser, namespace, values, option_string=None):
parser._print_message(
"\n".join([d.name for d in Device.list(virtual_device=True)]) + "\n",
sys.stdout,
)
devices = [d.name for d in Device.list(virtual_device=True)]
devices = sorted(set(devices))
parser._print_message("\n".join(devices) + "\n", sys.stdout)
parser.exit()


Expand Down Expand Up @@ -328,12 +339,19 @@ def artefact(name):
)

group = parser.add_argument_group("run options")
group.add_argument(
device_arg = group.add_argument(
"--device",
default=None,
metavar="NAME",
help="Device type",
choices=[d.name for d in Device.list(virtual_device=True)],
)
device_arg.completer = device_completer # type: ignore[attr-defined]
group.add_argument(
"--device-dict",
default=None,
type=Path,
metavar="PATH",
help="Path to device dictionary file for device-dict mode",
)
group.add_argument(
"--boot-args", default=None, metavar="ARGS", help="extend boot arguments"
Expand Down
25 changes: 20 additions & 5 deletions tuxrun/runtimes.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ def pre_run(self, tmpdir):
def post_run(self):
pass

def use_host_network(self):
pass

def cmd(self, args):
raise NotImplementedError() # pragma: no cover

Expand Down Expand Up @@ -106,6 +109,8 @@ def ret(self):
class ContainerRuntime(Runtime):
bind_guestfs = True
container = True
_use_host_network = False
_skip_http_server = False

def __init__(self, dispatcher_download_dir):
super().__init__(dispatcher_download_dir)
Expand All @@ -120,19 +125,29 @@ def __init__(self, dispatcher_download_dir):
guestfs.mkdir(exist_ok=True)
self.bind(guestfs, "/var/tmp/.guestfs-0")

def use_host_network(self):
self._use_host_network = True

def skip_http_server(self):
self._skip_http_server = True

def cmd(self, args):
prefix = self.prefix.copy()
srcs = set()
if self._use_host_network:
prefix.extend(["--network", "host"])
if self._skip_http_server:
# Override entrypoint to skip the HTTP server that conflicts with
# host services when using --network=host
prefix.extend(["--entrypoint", "/usr/bin/lava-run"])
# When entrypoint is lava-run, args should be its arguments, not include the command
if args and args[0] == "lava-run":
args = args[1:]
dsts = set()
for binding in self.__bindings__:
src, dst, ro, device = binding
if src in srcs:
LOG.error("Duplicated mount source %r", src)
raise Exception("Duplicated mount source %r" % src)
if dst in dsts:
LOG.error("Duplicated mount destination %r", dst)
raise Exception("Duplicated mount destination %r" % dst)
srcs.add(src)
dsts.add(dst)
ro = "ro" if ro else "rw"
prefix.extend(["--device" if device else "-v", f"{src}:{dst}:{ro}"])
Expand Down
4 changes: 4 additions & 0 deletions tuxrun/templates/dispatchers/dispatcher.yaml.jinja2
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ dispatcher_download_dir: "{{ dispatcher_download_dir }}"
# <dispatcher_download_dir>/<job_id>
prefix: "{{ prefix }}-"

{% if dispatcher_ip %}
dispatcher_ip: {{ dispatcher_ip }}
{% endif %}

# Set this variable when using http caching service based on url substitution
# like KissCache
# When downloading resources, lava dispatcher will use this formatting string
Expand Down