ae74db435a
--json now emits a stable sizing:{version:1,arch,nodes,cores,ram_gb,est_makespan_min}
block reusing provision.arch_for_system + estimate.peak_ram_gb_per_node. render_ephemeral_builders
now derives requirements.maxDuration + parking + policy from est_makespan_min for class ci.
209 lines
7.0 KiB
Python
209 lines
7.0 KiB
Python
"""Tests for the --provision EphemeralBuilder renderer (issue #14).
|
||
|
||
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.
|
||
"""
|
||
|
||
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():
|
||
assert arch_for_system("aarch64-linux") == "arm64"
|
||
assert arch_for_system("x86_64-linux") == "amd64"
|
||
assert arch_for_system("aarch64-darwin") == "arm64"
|
||
assert arch_for_system("x86_64-darwin") == "amd64"
|
||
# unknown / missing systems fall back to amd64
|
||
assert arch_for_system(None) == "amd64"
|
||
assert arch_for_system("riscv64-linux") == "amd64"
|
||
|
||
|
||
def test_real_schema_shape():
|
||
yaml = render_ephemeral_builders(
|
||
nodes=4, cores=16, system="aarch64-linux", est_makespan_min=12.34
|
||
)
|
||
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 "requirements:" in yaml
|
||
assert "arch: arm64" 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_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 "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_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-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_builders(**kwargs) == render_ephemeral_builders(**kwargs)
|
||
|
||
|
||
def test_invalid_counts_rejected():
|
||
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_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")
|
||
|
||
|
||
# --- on-demand CI window (issue #25) ---------------------------------------
|
||
|
||
|
||
def test_ci_window_byte_exact():
|
||
"""class ci + a known makespan emits a byte-identical windowed CR.
|
||
|
||
makespan 12.34 -> maxDuration ceil(12.34*1.5)=19m, maxRunningTTL
|
||
ceil(19*1.5)=29m, ttlHardDelete ceil(19*2)=38m. This fixture is the frozen
|
||
machine contract; update it deliberately if the derivation changes.
|
||
"""
|
||
yaml = render_ephemeral_builders(
|
||
nodes=1,
|
||
cores=32,
|
||
system="x86_64-linux",
|
||
builder_class="ci",
|
||
name="ci-slot",
|
||
ram_gb=64,
|
||
est_makespan_min=12.34,
|
||
)
|
||
expected = (
|
||
"# nix-estimator: estimated makespan 12.3 min (1 node(s) × 32 cores)\n"
|
||
"apiVersion: arbitrage.oleks.space/v1alpha1\n"
|
||
"kind: EphemeralBuilder\n"
|
||
"metadata:\n"
|
||
" name: ci-slot-1\n"
|
||
" namespace: builder-arbitrage\n"
|
||
" labels:\n"
|
||
" arbitrage.oleks.space/class: ci\n"
|
||
"spec:\n"
|
||
" class: ci\n"
|
||
" requirements:\n"
|
||
" arch: amd64\n"
|
||
" cores: 32\n"
|
||
" ramGb: 64\n"
|
||
" maxDuration: 19m\n"
|
||
" policy:\n"
|
||
" prefer: dependable\n"
|
||
" allowNixbuildFallback: false\n"
|
||
" parking:\n"
|
||
" policy: cost\n"
|
||
" idleGrace: 5m\n"
|
||
" maxRunningTTL: 29m\n"
|
||
" ttlHardDelete: 38m\n"
|
||
)
|
||
assert yaml == expected
|
||
|
||
|
||
def test_ci_window_derivation_math():
|
||
"""maxDuration/TTLs are ceil-derived multiples of the makespan."""
|
||
yaml = render_ephemeral_builders(
|
||
nodes=1,
|
||
cores=8,
|
||
system="x86_64-linux",
|
||
builder_class="ci",
|
||
est_makespan_min=10.0,
|
||
)
|
||
# 10 -> maxDuration ceil(15)=15m, maxRunningTTL ceil(22.5)=23m, ttl 30m
|
||
assert "maxDuration: 15m" in yaml
|
||
assert "maxRunningTTL: 23m" in yaml
|
||
assert "ttlHardDelete: 30m" in yaml
|
||
assert "policy: cost" in yaml
|
||
assert "idleGrace: 5m" in yaml
|
||
assert "prefer: dependable" in yaml
|
||
assert "allowNixbuildFallback: false" in yaml
|
||
|
||
|
||
def test_non_ci_class_has_no_window():
|
||
"""nix-builder/runner classes never carry the window fields (unchanged)."""
|
||
for cls in ("nix-builder", "runner"):
|
||
yaml = render_ephemeral_builders(
|
||
nodes=1,
|
||
cores=16,
|
||
system="aarch64-linux",
|
||
builder_class=cls,
|
||
est_makespan_min=12.34,
|
||
)
|
||
assert "maxDuration" not in yaml
|
||
assert "parking" not in yaml
|
||
assert "policy:" not in yaml
|
||
|
||
|
||
def test_ci_window_omitted_without_makespan():
|
||
"""class ci with no makespan can't derive a window; emit none."""
|
||
yaml = render_ephemeral_builders(
|
||
nodes=1,
|
||
cores=16,
|
||
system="x86_64-linux",
|
||
builder_class="ci",
|
||
)
|
||
assert "maxDuration" not in yaml
|
||
assert "parking" not in yaml
|
||
# base requirements still present
|
||
assert "class: ci" in yaml
|
||
assert "arch: amd64" in yaml
|