Skip to content
Open
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 docs/reference/machines.md
Original file line number Diff line number Diff line change
Expand Up @@ -862,6 +862,7 @@ that order; every environment on the runtime is reached through a target carryin
| `fallback` | `tuple[str, ...]` | `()` | As for an ssh host: see [Falling back](#falling-back). |
| `made` | `str` | `"typed"` | Always `typed`. |
| `harness` | `str` | `"env"` | As for an ssh host. |
| `resolve_image` | `bool` | `true` | Whether a service is created with its image's digest asked of the registry, as docker does; `false` passes `--no-resolve-image`: each node runs the image it holds under that name, pulling it only where it holds none. No registry round trip (about two seconds a service); for an image pinned by digest, or the same on every node. |

`daemon()` returns the manager's [`Endpoint`](#endpoints), as a docker daemon's does.
`store.node_of(via)` is how a `nodes` value is reached: `ssh:<name>` for a saved ssh runtime of
Expand Down
1 change: 1 addition & 0 deletions docs/reference/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ entry that does not validate is skipped. A runtime an earlier Humanize wrote wit
| `cpus` | `float` |
| `memory`, `max_tasks` | `int` |
| `nodes` | `{str: str}`: a node's host name to a saved ssh runtime's name or `[user@]host[:port]` |
| `resolve_image` | `bool` (default `true`; `false`: `--no-resolve-image`) |
| `made` | `"typed"` |

`apple-container`: `image`, `workdir`, `harness` (`str`), `run_args`, `fallback` (`[str]`),
Expand Down
17 changes: 16 additions & 1 deletion src/hmz/coganchor/machines/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,9 @@ class SwarmRuntime:
made: How it was made, which is :data:`TYPED`.
harness: Where the harness of an agent working on it runs, and nowhere else: see
:func:`affine`.
resolve_image: Whether a service is created with its image's digest asked of the
registry, docker's way; False passes `--no-resolve-image`: each node runs the image
it holds under that name (see :class:`~hmz.coganchor.machines.swarm.SwarmConfig`).
"""

backend: ClassVar[str] = SWARM
Expand All @@ -643,6 +646,7 @@ class SwarmRuntime:
fallback: tuple[str, ...] = ()
made: str = TYPED
harness: str = ENV
resolve_image: bool = True

def __post_init__(self) -> None:
_named(self.name)
Expand Down Expand Up @@ -939,11 +943,18 @@ def held(self) -> dict[str, Any]:
)


#: Fields written down only where they are not their default: a runtime that leaves them
#: so is written as a Humanize without them wrote it, and reads there.
_UNLESS_DEFAULT = frozenset({"resolve_image"})


def _fields(runtime: Runtime) -> dict[str, Any]:
"""Every field of one, as JSON holds it."""
"""Every field of one, as JSON holds it, but those of `_UNLESS_DEFAULT` at default."""
held: dict[str, Any] = {}
for one in dataclasses.fields(runtime):
value: object = getattr(runtime, one.name)
if one.name in _UNLESS_DEFAULT and value == one.default:
continue
if isinstance(value, tuple):
value = [str(each) for each in cast("tuple[object, ...]", value)]
elif one.name in _MAPPINGS:
Expand Down Expand Up @@ -1117,6 +1128,10 @@ def _typed(key: str, default: object, value: object, name: str) -> object:
if not isinstance(value, (list, tuple)):
raise wrong
return tuple(str(one) for one in cast("Iterable[Any]", value))
if isinstance(default, bool): # a switch: true or false, never 0 or 1
if not isinstance(value, bool):
raise wrong
return value
if isinstance(value, bool):
raise wrong
if isinstance(default, float):
Expand Down
7 changes: 7 additions & 0 deletions src/hmz/coganchor/machines/swarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ class SwarmConfig(MachineConfig):
given up on.
starting: How long, in seconds, it may take to be running once it has a node --
pulling its image among it.
resolve_image: Whether the manager asks the image's registry for its digest as it
creates the service, docker's way; False creates it with `--no-resolve-image`,
each node running the image it holds under that name and pulling it only where it
holds none -- no registry round trip, some two seconds each, for an image pinned
by digest or the same on every node.
"""

image: str = "python:3.12"
Expand All @@ -133,6 +138,7 @@ class SwarmConfig(MachineConfig):
labels: Mapping[str, str] = field(default_factory=dict[str, str], hash=False)
placing: float = 30.0
starting: float = 600.0
resolve_image: bool = True

def __post_init__(self) -> None:
"""Refuses what the swarm would refuse, where it is written rather than as it starts.
Expand Down Expand Up @@ -459,6 +465,7 @@ def submit(self) -> str:
"1",
"--restart-condition",
"none",
*(() if config.resolve_image else ("--no-resolve-image",)),
"--name",
name,
*self._labelled(),
Expand Down
1 change: 1 addition & 0 deletions src/hmz/runtime/flowing/environing_swarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,7 @@ def _brought_up(self) -> tuple[Swarm, Share]:
nodes=_nodes(stored),
traced=self.traced,
run_args=stored.run_args if stored is not None else (),
resolve_image=stored.resolve_image if stored is not None else True,
labels={
PROVIDER: self.provider,
ROLE: self.role,
Expand Down
3 changes: 3 additions & 0 deletions src/hmz/tui/pick.py
Original file line number Diff line number Diff line change
Expand Up @@ -8384,6 +8384,9 @@ def _more(self, typed: Mapping[str, str]) -> dict[str, Any]:
"max_tasks": _how_many(typed.get(_TASKS, ""), "max tasks"),
"gpu_resource": typed.get(_RESOURCE, ""),
"nodes": _pairs(typed.get(_NODES, "")),
# Not on the form, and so kept as it was written rather than reset by it: a swarm
# told not to resolve its image would otherwise be corrected into one that does.
"resolve_image": self._one.resolve_image if self._one is not None else True,
}


Expand Down
21 changes: 21 additions & 0 deletions tests/unit/coganchor/machines/test_runtimes.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,27 @@ def test_a_name_prefix_is_kept_as_it_was_written(backend: str, prefix: str) -> N
assert new(backend, "box", name_prefix="").held()["name_prefix"] == ""


def test_a_swarm_resolves_its_image_unless_told_not_to() -> None:
assert SwarmRuntime(name="sw").resolve_image is True
assert "resolve_image" not in SwarmRuntime(name="sw").held() # as written before
unresolved = new(SWARM, "sw", resolve_image=False)

assert isinstance(unresolved, SwarmRuntime)
assert unresolved.resolve_image is False
assert unresolved.held()["resolve_image"] is False
written = {
k: v for k, v in unresolved.held().items() if k not in ("backend", "name")
}
read_back = new(SWARM, "sw", **written)

assert isinstance(read_back, SwarmRuntime)
assert read_back.resolve_image is False # read back as written
with pytest.raises(ValueError, match="resolve_image cannot be 0"):
new(SWARM, "sw", resolve_image=0)
with pytest.raises(ValueError, match="resolve_image cannot be 'no'"):
new(SWARM, "sw", resolve_image="no")


def test_a_swarm_takes_constraints_and_nodes_it_can_reach() -> None:
runtime = SwarmRuntime(
name="sw",
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/coganchor/machines/test_swarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,27 @@ def test_a_service_of_one_task_is_created_and_reached_where_it_landed(
assert handshake.asked == [anchor]


def test_a_service_asks_the_registry_for_its_image_unless_told_not_to(
runs: Runs, handshake: Handshake
) -> None:
a_swarm(runs, task("running"), task("running"))

SwarmConfig(workspace="/w", user="5:5").create().start()
SwarmConfig(workspace="/w", user="5:5", resolve_image=False).create().start()

resolved, unresolved = runs.called("service", "create")
assert "--no-resolve-image" not in resolved
assert unresolved.count("--no-resolve-image") == 1
assert unresolved.index("--no-resolve-image") < unresolved.index("python:3.12")

def unnamed(argv: list[str]) -> list[str]: # each service has a name of its own
at = argv.index("--name")
return [*argv[:at], *argv[at + 2 :]]

without = [one for one in unresolved if one != "--no-resolve-image"]
assert unnamed(without) == unnamed(resolved)


def test_a_task_with_no_gpus_is_handed_none_by_the_runtime(
runs: Runs, handshake: Handshake
) -> None:
Expand Down
1 change: 1 addition & 0 deletions tests/unit/runtime/flowing/doubles_u11.py
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@ class Runtime:
exec_user: str = ""
exec_env: tuple[str, ...] = ()
exec_wrapper: tuple[str, ...] = ()
resolve_image: bool = True

def exec_options(self) -> tuple[tuple[str, str], ...]:
return (
Expand Down
21 changes: 21 additions & 0 deletions tests/unit/runtime/flowing/test_environing_swarm.py
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,26 @@ def daemon_of(via: str) -> Endpoint:
return Config


async def test_a_runtime_that_does_not_resolve_its_image_says_so_to_the_service(
manager: type[_Config],
) -> None:
machine = SwarmMachine(
"c",
PurePosixPath("/srv/x"),
stored=runtime(
Runtime(name="c", cpus=8, gpu_resource="NVIDIA-GPU", resolve_image=False)
),
role=_worker(),
)
driver = MachineEnvDriver(machine, PurePosixPath("/work"))

await driver.probe()

(made,) = manager.made
assert made["resolve_image"] is False
await driver.close()


async def test_a_task_is_created_with_its_share_and_reached_where_it_landed(
manager: type[_Config],
) -> None:
Expand Down Expand Up @@ -289,6 +309,7 @@ async def test_a_task_is_created_with_its_share_and_reached_where_it_landed(
assert made["constraints"] == ("node.labels.gpu==1",)
assert made["nodes"] == {"node-2": "tcp://node-2.lan:2376"}
assert (made["traced"], made["run_args"]) == (True, ("--init",))
assert made["resolve_image"] is True # docker's way, unless the runtime says not
assert (made["labels"][PROVIDER], made["labels"][ROLE]) == ("c", "worker")
assert machine.target.startswith("docker://task.1.abc@")
assert machine.resources(gpus=True) == Resources(4, 8 << 30, 1, 16384 << 20)
Expand Down
83 changes: 83 additions & 0 deletions tests/unit/tui/test_pick_swarm.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"""`hmz.tui.pick.Swarming`: a swarm corrected on its form, and what it keeps of what it was."""

from __future__ import annotations

from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast

import pytest
from textual.app import App

import hmz.daemon
from hmz.tui import pick

if TYPE_CHECKING:
from hmz.coganchor.machines.store import SwarmRuntime


class _Runtimes:
"""The store, as far as the form asks it: none taken, and one made as it was told."""

def all(self, backend: str) -> list[Any]:
return []

def new(self, backend: str, name: str, **fields: Any) -> Any:
return SimpleNamespace(backend=backend, name=name, **fields)


class _Host(App[None]):
"""Puts the form up and keeps what it was answered with."""

def __init__(self, form: pick.Swarming) -> None:
super().__init__()
self.form = form
self.answers: list[Any] = []

def on_mount(self) -> None:
self.push_screen(self.form, self.answers.append)


def _swarm(*, resolve_image: bool) -> SwarmRuntime:
return cast(
"SwarmRuntime",
SimpleNamespace(
name="gpus",
endpoint="local",
tls_dir="",
image="python:3.12",
run_args=(),
cpus=0.0,
memory=0,
workdir="",
fallback=(),
harness="env",
name_prefix="",
constraints=("node.labels.gpu==true",),
max_tasks=4,
gpu_resource="",
nodes={},
resolve_image=resolve_image,
),
)


@pytest.fixture(autouse=True)
def runtimes(monkeypatch: pytest.MonkeyPatch) -> None:
here = SimpleNamespace(runtimes=_Runtimes())
monkeypatch.setattr(hmz.daemon, "Hmz", lambda: here)


@pytest.mark.parametrize("resolve_image", [False, True])
async def test_a_swarm_corrected_keeps_whether_it_resolves_its_image(
resolve_image: bool,
) -> None:
host = _Host(pick.Swarming(_swarm(resolve_image=resolve_image)))
async with host.run_test() as pilot:
await pilot.pause()
host.form.action_done()
await pilot.pause()

[made] = host.answers
assert made.name == "gpus"
assert made.constraints == ["node.labels.gpu==true"]
assert made.resolve_image is resolve_image
Loading