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")