Merge reconcile branch ab746b206d2f5cb67 (#14/#12 ground-truth)

This commit is contained in:
Oleks
2026-07-08 20:00:39 +03:00
5 changed files with 163 additions and 79 deletions
+2 -1
View File
@@ -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
+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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")