Merge reconcile branch ab746b206d2f5cb67 (#14/#12 ground-truth)
This commit is contained in:
@@ -65,7 +65,8 @@ nix-estimate .#foo --max-jobs 4 --node-ram-gb 16
|
||||
# print a per-node Gantt of the recommended shape's schedule (issue #16)
|
||||
nix-estimate .#foo --timeline
|
||||
|
||||
# emit an EphemeralBuilder CR for the recommendation (issue #14) —
|
||||
# emit EphemeralBuilder CRs for the recommendation (issue #14) — one document
|
||||
# per recommended node (no replica field: one EphemeralBuilder == one VM).
|
||||
# YAML goes to stdout, the human report to stderr, so this pipes cleanly
|
||||
nix-estimate .#foo --provision | kubectl apply -f -
|
||||
nix-estimate .#foo --provision runner --provision-name my-builder
|
||||
|
||||
@@ -254,10 +254,11 @@ def _run_estimate(args, ap: argparse.ArgumentParser) -> int:
|
||||
# is context, pushed to stderr so `nix-estimate ... --provision | kubectl
|
||||
# apply -f -` works cleanly.
|
||||
if args.provision is not None:
|
||||
yaml = provision.render_ephemeral_builder(
|
||||
yaml = provision.render_ephemeral_builders(
|
||||
nodes=rec["nodes"],
|
||||
cores=rec["cores_per_node"],
|
||||
system=args.system,
|
||||
ram_gb=args.node_ram_gb,
|
||||
est_makespan_min=rec.get("est_makespan_min"),
|
||||
builder_class=args.provision,
|
||||
name=args.provision_name,
|
||||
|
||||
+81
-40
@@ -1,8 +1,10 @@
|
||||
"""Render a recommendation into an EphemeralBuilder CR for the arbitrage operator.
|
||||
"""Render a recommendation into EphemeralBuilder CRs for the arbitrage operator.
|
||||
|
||||
The CR shape here is *illustrative* — a plausible EphemeralBuilder custom
|
||||
resource for the builder-arbitrage operator (builder-arbitrage.oleks.space).
|
||||
It may need reconciling with the real operator CRD before it applies cleanly.
|
||||
Reconciled against the real ``arbitrage.oleks.space/v1alpha1`` EphemeralBuilder
|
||||
CRD (builder-arbitrage). The critical modeling fact: there is **no replica /
|
||||
node-count field** — one EphemeralBuilder == one builder VM. So a recommendation
|
||||
of N nodes is emitted as N documents in a multi-doc YAML stream, each carrying
|
||||
the per-node ``requirements`` (arch/cores/ramGb) and a unique ``metadata.name``.
|
||||
|
||||
Pure and deterministic: no timestamps, no RNG. The same inputs always yield
|
||||
byte-identical YAML, so callers can diff/calibrate against it.
|
||||
@@ -10,8 +12,17 @@ byte-identical YAML, so callers can diff/calibrate against it.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
API_VERSION = "builder-arbitrage.oleks.space/v1"
|
||||
API_VERSION = "arbitrage.oleks.space/v1alpha1"
|
||||
KIND = "EphemeralBuilder"
|
||||
NAMESPACE = "builder-arbitrage"
|
||||
CLASS_LABEL = "arbitrage.oleks.space/class"
|
||||
|
||||
# Valid enums, enforced with ValueError to fail fast before `kubectl apply`.
|
||||
_VALID_CLASSES = ("runner", "ci", "nix-builder")
|
||||
_VALID_ARCHES = ("amd64", "arm64")
|
||||
|
||||
# Default RAM sizing when no explicit node RAM is known: ~2 GiB per core.
|
||||
_RAM_GB_PER_CORE = 2
|
||||
|
||||
# --system -> Kubernetes/OCI GOARCH-style arch label.
|
||||
_ARCH_BY_SYSTEM = {
|
||||
@@ -32,7 +43,7 @@ def arch_for_system(system: str | None) -> str:
|
||||
return _ARCH_BY_SYSTEM.get(system, "amd64")
|
||||
|
||||
|
||||
def _default_name(*, cores: int, arch: str) -> str:
|
||||
def _default_base_name(*, cores: int, arch: str) -> str:
|
||||
return f"nix-estimator-{arch}-{cores}c"
|
||||
|
||||
|
||||
@@ -85,55 +96,85 @@ def _scalar(value) -> str:
|
||||
return s
|
||||
|
||||
|
||||
def render_ephemeral_builder(
|
||||
def _ram_gb_for(*, cores: int, ram_gb: int | float | None) -> int:
|
||||
"""Resolve the REQUIRED ramGb: explicit value, else ~2 GiB/core. Int >= 1."""
|
||||
raw = ram_gb if ram_gb is not None else _RAM_GB_PER_CORE * cores
|
||||
val = int(round(float(raw)))
|
||||
return max(1, val)
|
||||
|
||||
|
||||
def render_ephemeral_builders(
|
||||
*,
|
||||
nodes: int,
|
||||
cores: int,
|
||||
system: str | None,
|
||||
ram_gb: int | float | None = None,
|
||||
est_makespan_min: float | None = None,
|
||||
builder_class: str = "nix-builder",
|
||||
name: str | None = None,
|
||||
) -> str:
|
||||
"""Render a recommended (nodes×cores) shape as an EphemeralBuilder CR YAML.
|
||||
"""Render a recommended (nodes×cores) shape as EphemeralBuilder CR YAML.
|
||||
|
||||
Encodes replicas (nodes), cores-per-node, arch derived from ``system``, the
|
||||
builder class, and — for later calibration — the estimated makespan as an
|
||||
annotation. Returns a deterministic YAML string (trailing newline).
|
||||
One EphemeralBuilder == one builder VM, so ``nodes`` documents are emitted
|
||||
(separated by ``---``), each with a unique ``metadata.name`` (``<base>-<i>``),
|
||||
namespace ``builder-arbitrage``, the ``arbitrage.oleks.space/class`` label,
|
||||
and a ``requirements`` block carrying per-node ``arch``/``cores``/``ramGb``.
|
||||
|
||||
The schema is illustrative; see the module docstring.
|
||||
``ramGb`` is required by the CRD: taken from ``ram_gb`` if given, else
|
||||
defaulted to ~2 GiB/core. The estimated makespan (if any) is recorded in a
|
||||
YAML comment — never in an operator-managed annotation namespace.
|
||||
|
||||
Returns a deterministic multi-doc YAML string (trailing newline). Raises
|
||||
``ValueError`` on invalid class, arch, or non-positive counts.
|
||||
"""
|
||||
if nodes < 1:
|
||||
raise ValueError(f"nodes must be >= 1, got {nodes}")
|
||||
if cores < 1:
|
||||
raise ValueError(f"cores must be >= 1, got {cores}")
|
||||
|
||||
arch = arch_for_system(system)
|
||||
cr_name = name or _default_name(cores=cores, arch=arch)
|
||||
|
||||
annotations = {
|
||||
"builder-arbitrage.oleks.space/generated-by": "nix-estimator",
|
||||
}
|
||||
if est_makespan_min is not None:
|
||||
annotations["builder-arbitrage.oleks.space/estimated-makespan-min"] = str(
|
||||
round(float(est_makespan_min), 1)
|
||||
if builder_class not in _VALID_CLASSES:
|
||||
raise ValueError(
|
||||
f"class must be one of {_VALID_CLASSES}, got {builder_class!r}"
|
||||
)
|
||||
|
||||
spec = {
|
||||
"class": builder_class,
|
||||
"replicas": nodes,
|
||||
"cores": cores,
|
||||
"arch": arch,
|
||||
}
|
||||
if system:
|
||||
spec["system"] = system
|
||||
arch = arch_for_system(system)
|
||||
if arch not in _VALID_ARCHES:
|
||||
raise ValueError(f"arch must be one of {_VALID_ARCHES}, got {arch!r}")
|
||||
|
||||
doc = {
|
||||
"apiVersion": API_VERSION,
|
||||
"kind": KIND,
|
||||
"metadata": {
|
||||
"name": cr_name,
|
||||
"annotations": annotations,
|
||||
},
|
||||
"spec": spec,
|
||||
}
|
||||
return "\n".join(_emit_yaml(doc)) + "\n"
|
||||
ram = _ram_gb_for(cores=cores, ram_gb=ram_gb)
|
||||
if ram < 1:
|
||||
raise ValueError(f"ramGb must be >= 1, got {ram}")
|
||||
|
||||
base = name or _default_base_name(cores=cores, arch=arch)
|
||||
|
||||
docs: list[str] = []
|
||||
for i in range(1, nodes + 1):
|
||||
doc = {
|
||||
"apiVersion": API_VERSION,
|
||||
"kind": KIND,
|
||||
"metadata": {
|
||||
"name": f"{base}-{i}",
|
||||
"namespace": NAMESPACE,
|
||||
"labels": {CLASS_LABEL: builder_class},
|
||||
},
|
||||
"spec": {
|
||||
"class": builder_class,
|
||||
"requirements": {
|
||||
"arch": arch,
|
||||
"cores": cores,
|
||||
"ramGb": ram,
|
||||
},
|
||||
},
|
||||
}
|
||||
body = "\n".join(_emit_yaml(doc))
|
||||
if est_makespan_min is not None:
|
||||
# Keep the estimate as a NON-operator YAML comment — never in the
|
||||
# arbitrage.oleks.space/* annotation namespace the operator owns.
|
||||
comment = (
|
||||
f"# nix-estimator: estimated makespan "
|
||||
f"{round(float(est_makespan_min), 1)} min "
|
||||
f"({nodes} node(s) × {cores} cores)"
|
||||
)
|
||||
body = comment + "\n" + body
|
||||
docs.append(body)
|
||||
|
||||
return "---\n".join(d + "\n" for d in docs)
|
||||
|
||||
+4
-3
@@ -138,9 +138,10 @@ def test_provision_emits_yaml_to_stdout(stub_graph, capsys):
|
||||
assert cli.main([".#x", "--history", stub_graph, "--provision"]) == 0
|
||||
cap = capsys.readouterr()
|
||||
assert "kind: EphemeralBuilder" in cap.out
|
||||
assert "apiVersion: builder-arbitrage.oleks.space/v1" in cap.out
|
||||
assert "apiVersion: arbitrage.oleks.space/v1alpha1" in cap.out
|
||||
assert "class: nix-builder" in cap.out
|
||||
assert "replicas:" in cap.out and "cores:" in cap.out
|
||||
assert "requirements:" in cap.out and "cores:" in cap.out
|
||||
assert "namespace: builder-arbitrage" in cap.out
|
||||
# human report is diverted to stderr, not stdout
|
||||
assert "RECOMMENDATION" in cap.err
|
||||
assert "RECOMMENDATION" not in cap.out
|
||||
@@ -163,7 +164,7 @@ def test_provision_custom_class_and_name(stub_graph, capsys):
|
||||
)
|
||||
out = capsys.readouterr().out
|
||||
assert "class: runner" in out
|
||||
assert "name: my-builder" in out
|
||||
assert "name: my-builder-1" in out
|
||||
|
||||
|
||||
def test_mine_subcommand_writes_json(monkeypatch, capsys, tmp_path):
|
||||
|
||||
+74
-34
@@ -1,10 +1,17 @@
|
||||
"""Tests for the --provision EphemeralBuilder renderer (issue #14).
|
||||
|
||||
The CR schema is illustrative; these tests pin the *contract* nix-estimator
|
||||
emits (keys/values, arch mapping, determinism), not the real operator CRD.
|
||||
These pin the real ``arbitrage.oleks.space/v1alpha1`` EphemeralBuilder CRD
|
||||
contract: one document per node (no replica field), the ``requirements`` block,
|
||||
arch mapping, namespace/label conventions, and enum/count validation.
|
||||
"""
|
||||
|
||||
from nix_estimator.provision import arch_for_system, render_ephemeral_builder
|
||||
import pytest
|
||||
|
||||
from nix_estimator.provision import arch_for_system, render_ephemeral_builders
|
||||
|
||||
|
||||
def _docs(yaml: str) -> list[str]:
|
||||
return [d for d in yaml.split("---\n") if d.strip()]
|
||||
|
||||
|
||||
def test_arch_mapping():
|
||||
@@ -17,58 +24,91 @@ def test_arch_mapping():
|
||||
assert arch_for_system("riscv64-linux") == "amd64"
|
||||
|
||||
|
||||
def test_render_contains_expected_shape():
|
||||
yaml = render_ephemeral_builder(
|
||||
def test_real_schema_shape():
|
||||
yaml = render_ephemeral_builders(
|
||||
nodes=4, cores=16, system="aarch64-linux", est_makespan_min=12.34
|
||||
)
|
||||
assert "apiVersion: builder-arbitrage.oleks.space/v1" in yaml
|
||||
assert "apiVersion: arbitrage.oleks.space/v1alpha1" in yaml
|
||||
assert "kind: EphemeralBuilder" in yaml
|
||||
# top-level class + requirements block (not replicas/top-level cores/arch)
|
||||
assert "class: nix-builder" in yaml
|
||||
assert "replicas: 4" in yaml
|
||||
assert "cores: 16" in yaml
|
||||
assert "requirements:" in yaml
|
||||
assert "arch: arm64" in yaml
|
||||
assert "system: aarch64-linux" in yaml
|
||||
# makespan is rounded and stored as a calibration annotation
|
||||
assert "builder-arbitrage.oleks.space/estimated-makespan-min: 12.3" in yaml
|
||||
assert "cores: 16" in yaml
|
||||
assert "ramGb: 32" in yaml # default ~2 GiB/core
|
||||
assert "replicas" not in yaml
|
||||
# metadata conventions
|
||||
assert "namespace: builder-arbitrage" in yaml
|
||||
assert "arbitrage.oleks.space/class: nix-builder" in yaml
|
||||
# makespan lives in a non-operator comment, not an arbitrage.* annotation
|
||||
assert "# nix-estimator: estimated makespan 12.3 min" in yaml
|
||||
assert "arbitrage.oleks.space/estimated-makespan" not in yaml
|
||||
assert yaml.endswith("\n")
|
||||
|
||||
|
||||
def test_render_amd64_and_custom_class_and_name():
|
||||
yaml = render_ephemeral_builder(
|
||||
nodes=1,
|
||||
cores=32,
|
||||
system="x86_64-linux",
|
||||
builder_class="ci",
|
||||
name="my-builder",
|
||||
def test_one_document_per_node():
|
||||
yaml = render_ephemeral_builders(nodes=4, cores=16, system="aarch64-linux")
|
||||
docs = _docs(yaml)
|
||||
assert len(docs) == 4
|
||||
# each document has a unique metadata.name suffixed by its index
|
||||
names = [ln.strip() for d in docs for ln in d.splitlines() if "name:" in ln]
|
||||
assert names == [
|
||||
"name: nix-estimator-arm64-16c-1",
|
||||
"name: nix-estimator-arm64-16c-2",
|
||||
"name: nix-estimator-arm64-16c-3",
|
||||
"name: nix-estimator-arm64-16c-4",
|
||||
]
|
||||
|
||||
|
||||
def test_single_node_single_document():
|
||||
yaml = render_ephemeral_builders(
|
||||
nodes=1, cores=32, system="x86_64-linux", builder_class="ci", name="my-builder"
|
||||
)
|
||||
assert len(_docs(yaml)) == 1
|
||||
assert "arch: amd64" in yaml
|
||||
assert "class: ci" in yaml
|
||||
assert "name: my-builder" in yaml
|
||||
assert "replicas: 1" in yaml
|
||||
assert "arbitrage.oleks.space/class: ci" in yaml
|
||||
assert "name: my-builder-1" in yaml
|
||||
|
||||
|
||||
def test_explicit_ram_gb_wins():
|
||||
yaml = render_ephemeral_builders(
|
||||
nodes=1, cores=8, system="x86_64-linux", ram_gb=64
|
||||
)
|
||||
assert "ramGb: 64" in yaml
|
||||
# default would have been 16 (2 GiB * 8 cores)
|
||||
assert "ramGb: 16" not in yaml
|
||||
|
||||
|
||||
def test_makespan_omitted_when_none():
|
||||
yaml = render_ephemeral_builder(nodes=2, cores=8, system="x86_64-linux")
|
||||
assert "estimated-makespan-min" not in yaml
|
||||
yaml = render_ephemeral_builders(nodes=2, cores=8, system="x86_64-linux")
|
||||
assert "estimated makespan" not in yaml
|
||||
# a sensible default name is derived from arch + cores
|
||||
assert "name: nix-estimator-amd64-8c" in yaml
|
||||
|
||||
|
||||
def test_system_omitted_when_none():
|
||||
yaml = render_ephemeral_builder(nodes=2, cores=8, system=None)
|
||||
assert "arch: amd64" in yaml
|
||||
assert "system:" not in yaml
|
||||
assert "name: nix-estimator-amd64-8c-1" in yaml
|
||||
|
||||
|
||||
def test_output_is_deterministic():
|
||||
kwargs = dict(nodes=3, cores=16, system="aarch64-linux", est_makespan_min=5.0)
|
||||
assert render_ephemeral_builder(**kwargs) == render_ephemeral_builder(**kwargs)
|
||||
assert render_ephemeral_builders(**kwargs) == render_ephemeral_builders(**kwargs)
|
||||
|
||||
|
||||
def test_invalid_counts_rejected():
|
||||
import pytest
|
||||
with pytest.raises(ValueError):
|
||||
render_ephemeral_builders(nodes=0, cores=8, system="x86_64-linux")
|
||||
with pytest.raises(ValueError):
|
||||
render_ephemeral_builders(nodes=1, cores=0, system="x86_64-linux")
|
||||
|
||||
|
||||
def test_invalid_class_rejected():
|
||||
with pytest.raises(ValueError):
|
||||
render_ephemeral_builder(nodes=0, cores=8, system="x86_64-linux")
|
||||
with pytest.raises(ValueError):
|
||||
render_ephemeral_builder(nodes=1, cores=0, system="x86_64-linux")
|
||||
render_ephemeral_builders(
|
||||
nodes=1, cores=8, system="x86_64-linux", builder_class="bogus"
|
||||
)
|
||||
|
||||
|
||||
def test_invalid_arch_rejected():
|
||||
# arch_for_system never yields an invalid arch, so exercise the guard by
|
||||
# confirming only the two GOARCH labels are ever emitted.
|
||||
for system in ("aarch64-linux", "x86_64-linux", None, "riscv64-linux"):
|
||||
arch = arch_for_system(system)
|
||||
assert arch in ("amd64", "arm64")
|
||||
|
||||
Reference in New Issue
Block a user