diff --git a/README.md b/README.md
index bf59323..6b0e051 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/nix_estimator/cli.py b/nix_estimator/cli.py
index b8645c3..4a89f73 100644
--- a/nix_estimator/cli.py
+++ b/nix_estimator/cli.py
@@ -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,
diff --git a/nix_estimator/provision.py b/nix_estimator/provision.py
index 530d8c5..3a3395e 100644
--- a/nix_estimator/provision.py
+++ b/nix_estimator/provision.py
@@ -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`` (``-``),
+ 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)
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 8f5efeb..914f56a 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -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):
diff --git a/tests/test_provision.py b/tests/test_provision.py
index fbb1c59..5452c44 100644
--- a/tests/test_provision.py
+++ b/tests/test_provision.py
@@ -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")