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
98 changes: 95 additions & 3 deletions _testlib/unikraft.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,44 @@
import shutil
import subprocess
from dataclasses import dataclass
from typing import Any, Sequence
from typing import Any, Mapping, Sequence

log = logging.getLogger(__name__)

UNIKRAFT_BIN = os.environ.get("UNIKRAFT_BIN", "unikraft")


def _as_tuple(value: str | Sequence[str] | None) -> tuple[str, ...]:
"""Accept a single value or a sequence for repeatable flags."""
if value is None:
return ()
if isinstance(value, str):
return (value,)
return tuple(value)


def _as_spec(value: str | Mapping[str, Any]) -> str:
"""Render a mapping as a comma-separated ``key=value`` spec string.

Accepts a ready-made string and returns it unchanged, so callers can
mix the two forms freely.
"""
if isinstance(value, Mapping):
return ",".join(f"{k}={v}" for k, v in value.items())
return value


def _as_spec_tuple(
value: str | Mapping[str, Any] | Sequence[str | Mapping[str, Any]] | None,
) -> tuple[str, ...]:
"""Like ``_as_tuple`` but each element may also be a mapping."""
if value is None:
return ()
if isinstance(value, (str, Mapping)):
return (_as_spec(value),)
return tuple(_as_spec(v) for v in value)


class UnikraftError(RuntimeError):
"""Raised when a `unikraft` CLI invocation fails."""

Expand Down Expand Up @@ -110,19 +141,80 @@ def run_instance(
publish: Sequence[str] = (),
memory: str | None = None,
name: str | None = None,
metro: str | None = None,
scale_to_zero: str | Mapping[str, Any] | None = None,

@dragosgheorghioiu dragosgheorghioiu Jul 6, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think Mapping[str, str] could work here but is not needed

template: str | None = None,
env: Sequence[str] | Mapping[str, Any] = (),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same for env

domain: str | Sequence[str] | None = None,
volume: str | Sequence[str] | None = None,
rom: str | Mapping[str, Any] | Sequence[str | Mapping[str, Any]] | None = None,
vcpus: int | str | None = None,
command: str | Sequence[str] | None = None,
extra_args: Sequence[str] = (),
) -> dict[str, Any]:
"""Start an instance and return its parsed JSON description."""
args: list[str] = ["run", "--metro", self.metro, "--output", "json"]
"""Start an instance and return its parsed JSON description.

Parameters mirror the ``unikraft run`` flags shown in the example
READMEs, so tests can express exactly what the docs tell users to
run without falling back to ``extra_args``:

* ``publish`` – ``-p`` port mappings, e.g. ``["443:8080/tls+http"]``.
* ``memory`` – ``-m``, e.g. ``"256M"``.
* ``name`` – ``-n``; tests usually let the fixture generate one.
* ``metro`` – per-call override of the CLI's default metro.
* ``scale_to_zero`` – ``--scale-to-zero`` spec; either a verbatim
string (``"policy=on,cooldown-time=1000,stateful=true"``) or a
mapping (``{"policy": "on", "cooldown-time": "1000", ...}``).
* ``template`` – ``--template`` name.
* ``env`` – ``--env`` entries; either ``"KEY=VALUE"`` strings or a
mapping (rendered in iteration order).
* ``domain`` – one or more ``--domain`` values.
* ``volume`` – one or more ``--volume`` mounts, ``"name:/path"``.
* ``rom`` – one or more ``--rom`` specs; each may be a verbatim
string (``"image=...,at=/rom"``) or a mapping
(``{"image": "...", "at": "/rom"}``).
* ``vcpus`` – ``--vcpus``.
* ``command`` – the command override placed after ``--``. A string
is passed as the single quoted argument shown in READMEs
(``-- "/usr/local/bin/python /src/server.py"``); a sequence is
passed as separate arguments. Always emitted last.
* ``extra_args`` – escape hatch for flags not modelled above.
"""
args: list[str] = ["run", "--metro", metro or self.metro,
"--output", "json"]
if scale_to_zero:
args += ["--scale-to-zero", _as_spec(scale_to_zero)]
for v in _as_tuple(volume):
args += ["--volume", v]
for p in publish:
args += ["-p", p]
if memory:
args += ["-m", memory]
if vcpus is not None:
args += ["--vcpus", str(vcpus)]
if name:
args += ["-n", name]
if image:
args += ["--image", image]
for d in _as_tuple(domain):
args += ["--domain", d]
if isinstance(env, Mapping):
env = [f"{k}={v}" for k, v in env.items()]
for e in env:
args += ["--env", e]
for r in _as_spec_tuple(rom):
args += ["--rom", r]
if template:
args += ["--template", template]
args += list(extra_args)
# `--` terminates option parsing; everything after it is the
# instance command, so it must come last — after extra_args too.
if command is not None:
args.append("--")
if isinstance(command, str):
args.append(command)
else:
args.extend(command)

log.info(
"running instance with image %s (publish=%s, memory=%s, name=%s)",
Expand Down
16 changes: 6 additions & 10 deletions build-environments/test_build-environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,11 +102,9 @@ def test_build_environments_rom1(
instance = unikraft.run_instance(
publish=["443:8080/tls+http"],
name=instance_name,
extra_args=[
"--template", template_name,
"--rom", f"image={rom1_tag},at=/rom",
"--scale-to-zero", "policy=on,cooldown-time=1000,stateful=true",
],
template=template_name,
rom={"image": rom1_tag, "at": "/rom"},
scale_to_zero={"policy": "on", "cooldown-time": "1000", "stateful": "true"},
)

url = extract_instance_url(instance)
Expand All @@ -129,11 +127,9 @@ def test_build_environments_rom2(
instance = unikraft.run_instance(
publish=["443:8080/tls+http"],
name=instance_name,
extra_args=[
"--template", template_name,
"--rom", f"image={rom2_tag},at=/rom",
"--scale-to-zero", "policy=on,cooldown-time=1000,stateful=true",
],
template=template_name,
rom={"image": rom2_tag, "at": "/rom"},
scale_to_zero={"policy": "on", "cooldown-time": "1000", "stateful": "true"},
)

url = extract_instance_url(instance)
Expand Down
33 changes: 31 additions & 2 deletions conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import os
import time
import uuid
from collections.abc import Callable, Sequence
from collections.abc import Callable, Mapping, Sequence
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -170,7 +170,8 @@ def run_instance(
executed by pytest unconditionally after the test completes, whether it
passed, failed, or errored.

Parameters passed through to the CLI:
Parameters passed through to the CLI (see
:meth:`_testlib.unikraft.UnikraftCLI.run_instance` for full details):

* ``image`` (positional) – image tag to run.
* ``publish`` – iterable of ``-p`` port mappings, e.g.
Expand All @@ -179,6 +180,16 @@ def run_instance(
the CLI default.
* ``name`` – explicit instance name. If omitted, a unique name is
generated so parallel runs don't collide.
* ``metro`` – per-call metro override.
* ``scale_to_zero`` – ``--scale-to-zero`` spec string, e.g.
``"policy=on,cooldown-time=1000"``.
* ``template`` – ``--template`` name.
* ``env`` – ``--env`` entries (``"K=V"`` strings or a mapping).
* ``domain`` / ``volume`` / ``rom`` – single value or sequence for the
corresponding repeatable flags.
* ``vcpus`` – ``--vcpus``.
* ``command`` – instance command placed after ``--`` (string or
sequence of arguments).
* ``extra_args`` – extra positional CLI flags for escape-hatch needs.
"""

Expand All @@ -188,6 +199,15 @@ def _run(
publish: Sequence[str] = (),
memory: str | None = None,
name: str | None = None,
metro: str | None = None,
scale_to_zero: str | Mapping[str, Any] | None = None,
template: str | None = None,
env: Sequence[str] | Mapping[str, Any] = (),
domain: str | Sequence[str] | None = None,
volume: str | Sequence[str] | None = None,
rom: str | Mapping[str, Any] | Sequence[str | Mapping[str, Any]] | None = None,
vcpus: int | str | None = None,
command: str | Sequence[str] | None = None,
extra_args: Sequence[str] = (),
) -> dict[str, Any]:
instance_name = name or f"examples-pytest-{test_run_id}-{uuid.uuid4().hex[:6]}"
Expand All @@ -212,6 +232,15 @@ def _cleanup() -> None:
publish=publish,
memory=memory,
name=instance_name,
metro=metro,
scale_to_zero=scale_to_zero,
template=template,
env=env,
domain=domain,
volume=volume,
rom=rom,
vcpus=vcpus,
command=command,
extra_args=extra_args,
)

Expand Down
6 changes: 2 additions & 4 deletions debian-ssh/test_debian-ssh.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,8 @@ def test_debian_ssh(build_image, run_instance, socat_tunnel, tmp_path):
image,
publish=["2222:2222/tls"],
memory="1G",
extra_args=[
"--scale-to-zero", "policy=off",
"-e", f"PUBKEY={public_key}",
],
scale_to_zero={"policy": "off"},
env={"PUBKEY": public_key},
)

host = extract_instance_fqdn(instance)
Expand Down
4 changes: 1 addition & 3 deletions github-webhook-node/test_github-webhook-node.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ def test_github_webhook_health(build_image, run_instance, http):
image,
publish=["443:3000/tls+http"],
memory="1G",
extra_args=[
"-e", "GITHUB_WEBHOOK_SECRET=test_secret",
],
env={"GITHUB_WEBHOOK_SECRET": "test_secret"},
)

url = extract_instance_url(instance)
Expand Down
11 changes: 3 additions & 8 deletions httpserver-flask-redis/test_httpserver-flask-redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,8 @@ def test_flask_redis_counter(build_image, run_instance, http, test_run_id):
run_instance(
redis_image,
memory="256M",
extra_args=[
"--domain", redis_domain,
"--scale-to-zero", "policy=idle,cooldown-time=1000,stateful=true",
],
domain=redis_domain,
scale_to_zero={"policy": "idle", "cooldown-time": "1000", "stateful": "true"},
)

# 2. Build and deploy the Flask app.
Expand All @@ -46,10 +44,7 @@ def test_flask_redis_counter(build_image, run_instance, http, test_run_id):
flask_image,
publish=["443:8000/tls+http"],
memory="512M",
extra_args=[
"--env", f"REDIS_HOST={redis_domain}",
"--env", "REDIS_PORT=6379",
],
env={"REDIS_HOST": redis_domain, "REDIS_PORT": "6379"},
)

url = extract_instance_url(flask_instance)
Expand Down
13 changes: 4 additions & 9 deletions httpserver-go1.22-redis/test_httpserver-go1.22-redis.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,9 @@ def test_go_redis_set_get(build_image, run_instance, http, http_post, test_run_i
run_instance(
redis_image,
memory="256M",
extra_args=[
"--domain", redis_domain,
"--scale-to-zero", "policy=idle,cooldown-time=1000,stateful=true",
"-e", f"REDIS_PASSWORD={REDIS_PASSWORD}",
],
domain=redis_domain,
scale_to_zero={"policy": "idle", "cooldown-time": "1000", "stateful": "true"},
env={"REDIS_PASSWORD": REDIS_PASSWORD},
)

# 2. Build and deploy the Go HTTP server.
Expand All @@ -39,10 +37,7 @@ def test_go_redis_set_get(build_image, run_instance, http, http_post, test_run_i
app_image,
publish=["443:8080/tls+http"],
memory="256M",
extra_args=[
"--env", f"REDIS_ADDR={redis_domain}:6379",
"--env", f"REDIS_PASS={REDIS_PASSWORD}",
],
env={"REDIS_ADDR": f"{redis_domain}:6379", "REDIS_PASS": REDIS_PASSWORD},
)

url = extract_instance_url(app_instance)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@ def test_node_vite_ssr_serves_page(build_image, run_instance, http):
image,
publish=["443:8080/tls+http"],
memory="1G",
extra_args=[
"-e", "PWD=/app",
"-e", "NODE_ENV=production",
],
env={"PWD": "/app", "NODE_ENV": "production"},
)

url = extract_instance_url(instance)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,7 @@ def test_node_vite_vanilla_serves_page(build_image, run_instance, http):
image,
publish=["443:8080/tls+http"],
memory="4G",
extra_args=[
"-e", "PWD=/app",
],
env={"PWD": "/app"},
)

url = extract_instance_url(instance)
Expand Down
2 changes: 1 addition & 1 deletion mariadb/test_mariadb.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def test_mariadb(build_image, run_instance, socat_tunnel):
image,
publish=["3306:3306/tls"],
memory="1G",
extra_args=["-e", f"MARIADB_ROOT_PASSWORD={MARIA_PASSWORD}"],
env={"MARIADB_ROOT_PASSWORD": MARIA_PASSWORD},
)

host = extract_instance_fqdn(instance)
Expand Down
12 changes: 4 additions & 8 deletions minecraft/test_minecraft.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,8 @@ def _cleanup_base():
base_tag,
memory="4096M",
name=template_name,
extra_args=[
"--vcpus", "4",
"--rom", f"dir={context / 'base'},at=/rom/base",
],
vcpus=4,
rom={"dir": context / "base", "at": "/rom/base"},
)

# 3. Wait for the template to be ready (Minecraft init can be slow).
Expand Down Expand Up @@ -176,10 +174,8 @@ def test_minecraft_server_responds(
instance = unikraft.run_instance(
publish=["25565:25565/tls", "2222:2222/tls"],
name=instance_name,
extra_args=[
"--template", template_name,
"--scale-to-zero", "policy=on,cooldown-time=5000,stateful=true",
],
template=template_name,
scale_to_zero={"policy": "on", "cooldown-time": "5000", "stateful": "true"},
)

host = extract_instance_fqdn(instance)
Expand Down
2 changes: 1 addition & 1 deletion mysql/test_mysql.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ def test_mysql(build_image, run_instance, socat_tunnel):
image,
publish=["3306:3306/tls"],
memory="1G",
extra_args=["-e", f"MYSQL_ROOT_PASSWORD={MYSQL_PASSWORD}"],
env={"MYSQL_ROOT_PASSWORD": MYSQL_PASSWORD},
)

host = extract_instance_fqdn(instance)
Expand Down
15 changes: 5 additions & 10 deletions nginx-flask-mongo/test_nginx-flask-mongo.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,9 @@ def _cleanup_volume():
run_instance(
mongo_image,
memory="1024M",
extra_args=[
"--domain", mongo_domain,
"--scale-to-zero", "policy=idle,cooldown-time=1000,stateful=true",
"--volume", f"{volume_name}:/data/db",
],
domain=mongo_domain,
scale_to_zero={"policy": "idle", "cooldown-time": "1000", "stateful": "true"},
volume=f"{volume_name}:/data/db",
)

# 3. Build and deploy Flask backend.
Expand All @@ -60,11 +58,8 @@ def _cleanup_volume():
run_instance(
flask_image,
memory="1024M",
extra_args=[
"--domain", "backend.internal",
"--env", "FLASK_SERVER_PORT=9091",
"--env", f"MONGO_SERVER_URL={mongo_domain}:27017",
],
domain="backend.internal",
env={"FLASK_SERVER_PORT": "9091", "MONGO_SERVER_URL": f"{mongo_domain}:27017"},
)

# 4. Build and deploy Nginx reverse proxy.
Expand Down
Loading
Loading