myxce/src/xceadmin/tests/test_config_build.py
Rongsong Shen 1705495a5e fix(admin-config-tool): resolve xceadmin config build warnings
Cross-reference warnings ("references undefined entry") were genuine
gaps in the example configs, not validator bugs: src/configs/hosts.yaml
had a typo'd resource ref (mg -> mem) and referenced a nonexistent
host3/hostgroup pair; queues.yaml referenced hgroup1/hgroup2/ugroup1
instead of the real hg1/ug1 names; resources.yaml was missing the
r1m/r15s/tmp built-in worker load-index resources
(worker/src/resource_sync.c's init_builtin_load_indices()). Added the
missing host3 host, hg2 hostgroup, ug2 usergroup, and three resource
entries; fixed the typo'd refs.

"schema-valid but has no ClusterConfig destination" warnings for
resources.yaml's type/order/consumable/release fields were a real gap
in xcecommon/mapping.py's _build_resource, not an inherent limitation
-- the encoding (XCE_VAL_*/xce_order_t/XCE_RESF_CONSUMABLE/
XCE_RESF_RELEASE) is documented in include/xce/types.h and actively
read by the scheduler; the old docstring's claim that it was
undocumented was wrong. Only resourcemap remains genuinely unmapped
(its capnp destination fields are commented out).

Updated test_mapping.py/test_config_build.py for the new mapping
behavior and regenerated the full golden fixture's expected.bin
(legitimate representation-drift from the mapping fix, not a bug).
2026-07-23 09:10:14 +00:00

357 lines
12 KiB
Python

"""Golden-file-style build tests for ``xceadmin.build`` (design.md,
``xceadmin.build``; Requirements 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8,
1.9, 1.10, 1.11, 1.12, 8.3, 8.4, 9.1, 9.2, 9.3).
The ``tests/fixtures/minimal`` and ``tests/fixtures/full`` directories
(task 5.4) each pair a real, schema-valid ``configs/`` input with a
checked-in ``expected.bin`` -- unlike the dynamic build-then-compare
tests elsewhere in this file, these catch *unintended representation
drift*: a future change that is semantically correct (decodes fine,
right field values) but produces different serialized bytes than
before would still be a determinism regression (Requirement 9) that
purely structural assertions would never notice.
"""
from __future__ import annotations
import hashlib
from pathlib import Path
import pytest
import yaml
from xcecommon.errors import DuplicateEntryError, ReservedNameError, ValidationError
from xcecommon.schema import load_schema
from xceadmin import build
REPO_CONFIGS_DIR = Path(__file__).resolve().parents[2] / "configs"
def _write_yaml(path: Path, data: dict) -> None:
path.write_text(yaml.safe_dump(data))
# ---------------------------------------------------------------------------
# 1.1 / 1.5: closed-set scan, missing file -> empty section
# ---------------------------------------------------------------------------
def test_build_missing_all_files_still_succeeds(tmp_path):
"""Observable (task 2.1's bullet, reused here for build): a configs
directory with none of the 7 known files present still builds --
every section is empty, not an error.
"""
data = build.build_cluster_config(str(tmp_path))
mod = load_schema()
with mod.ClusterConfig.from_bytes(data) as reader:
assert reader.clusterName == ""
assert len(reader.queues) == 0
def test_build_ignores_unrecognized_extra_files(tmp_path):
(tmp_path / "notes.txt").write_text("not a config file")
(tmp_path / "schema").mkdir() # an operator-supplied schema/ dir -- ignored (1.6)
(tmp_path / "schema" / "queues.schema.json").write_text("{}")
data = build.build_cluster_config(str(tmp_path))
assert isinstance(data, bytes)
def test_build_missing_one_file_matches_present_empty_section(tmp_path):
_write_yaml(tmp_path / "xce.yaml", {"cluster_name": "c1", "heartbeat_interval": 5,
"inactive_heartbeat_count": 2})
without_resources = build.build_cluster_config(str(tmp_path))
_write_yaml(tmp_path / "resources.yaml", {"resources": []})
with_empty_resources = build.build_cluster_config(str(tmp_path))
assert without_resources == with_empty_resources
# ---------------------------------------------------------------------------
# 1.9 / determinism: same input twice -> identical bytes
# ---------------------------------------------------------------------------
def test_build_same_input_twice_is_byte_identical(tmp_path):
_write_yaml(tmp_path / "xce.yaml", {"cluster_name": "eda1", "heartbeat_interval": 30,
"inactive_heartbeat_count": 3})
_write_yaml(tmp_path / "queues.yaml", {"queues": [{"name": "normal", "priority": 30}]})
first = build.build_cluster_config(str(tmp_path))
second = build.build_cluster_config(str(tmp_path))
assert first == second
# ---------------------------------------------------------------------------
# Hard failures propagate (schema violation, reserved name, duplicates)
# ---------------------------------------------------------------------------
def test_build_raises_validation_error_on_schema_violation(tmp_path):
_write_yaml(tmp_path / "queues.yaml", {"queues": [{"priority": 30}]}) # missing "name"
with pytest.raises(ValidationError):
build.build_cluster_config(str(tmp_path))
def test_build_raises_reserved_name_error(tmp_path):
_write_yaml(tmp_path / "queues.yaml", {"queues": [{"name": "lost_and_found", "priority": 1}]})
with pytest.raises(ReservedNameError):
build.build_cluster_config(str(tmp_path))
def test_build_raises_duplicate_entry_error(tmp_path):
_write_yaml(
tmp_path / "queues.yaml",
{"queues": [{"name": "dup", "priority": 1}, {"name": "dup", "priority": 2}]},
)
with pytest.raises(DuplicateEntryError):
build.build_cluster_config(str(tmp_path))
# ---------------------------------------------------------------------------
# 1.7: unrecognized-but-schema-valid fields warn, never fail
# ---------------------------------------------------------------------------
def test_build_maps_resource_type_order_consumable_release(tmp_path, capsys):
"""type/order/consumable/release map to valtype/ordtype/flags (see
xcecommon.mapping's _RESOURCE_VALTYPE/_RESOURCE_ORDTYPE/flag constants)
-- no longer an "unmapped field" warning.
"""
_write_yaml(
tmp_path / "resources.yaml",
{
"resources": [
{
"name": "gpus",
"type": "numeric",
"order": "increase",
"consumable": True,
"release": True,
}
]
},
)
data = build.build_cluster_config(str(tmp_path)) # must not raise
mod = load_schema()
with mod.ClusterConfig.from_bytes(data) as reader:
resource = reader.resources[0]
assert resource.name == "gpus"
assert resource.valtype == 1 # XCE_VAL_NUMERIC
assert resource.ordtype == 0 # XCE_ORD_INCR
assert resource.flags == 0x80 | 0x20 # XCE_RESF_CONSUMABLE | XCE_RESF_RELEASE
stderr = capsys.readouterr().err
assert "'type'" not in stderr
assert "'order'" not in stderr
assert "'consumable'" not in stderr
assert "'release'" not in stderr
def test_build_warns_not_fails_on_schema_valid_unmapped_resourcemap_field(tmp_path, capsys):
"""resourcemap has no destination -- controller.capnp's distributions/
hostBased fields are commented out (needs a schema change first).
"""
_write_yaml(
tmp_path / "resources.yaml",
{
"resources": [
{
"name": "gpus",
"type": "numeric",
"resourcemap": [{"location": ["host1"], "value": 1}],
}
]
},
)
data = build.build_cluster_config(str(tmp_path)) # must not raise
mod = load_schema()
with mod.ClusterConfig.from_bytes(data) as reader:
assert reader.resources[0].name == "gpus"
stderr = capsys.readouterr().err
assert "resources.yaml" in stderr
assert "'resourcemap'" in stderr
assert "gpus" in stderr
def test_build_warns_on_schema_valid_unmapped_queue_fields(tmp_path, capsys):
_write_yaml(
tmp_path / "queues.yaml",
{"queues": [{"name": "night", "priority": 30, "nice": 10, "fairshare": [{"user": "u1", "share": 5}]}]},
)
build.build_cluster_config(str(tmp_path))
stderr = capsys.readouterr().err
assert "'nice'" in stderr
assert "'fairshare'" in stderr
assert "night" in stderr
def test_build_warns_on_schema_valid_unmapped_host_description(tmp_path, capsys):
_write_yaml(
tmp_path / "hosts.yaml",
{"hosts": [{"hostname": "h1", "description": "a worker node"}], "hostgroups": []},
)
build.build_cluster_config(str(tmp_path))
stderr = capsys.readouterr().err
assert "'description'" in stderr
assert "h1" in stderr
def test_build_no_warnings_when_only_mapped_fields_present(tmp_path, capsys):
_write_yaml(tmp_path / "scaler.yaml", {"scalers": [{"name": "s1", "type": "script", "script_path": "/x.sh"}]})
build.build_cluster_config(str(tmp_path))
assert capsys.readouterr().err == ""
# ---------------------------------------------------------------------------
# 4.4 undefined-ref warnings surface without blocking the build
# ---------------------------------------------------------------------------
def test_build_surfaces_undefined_ref_warnings_without_failing(tmp_path, capsys):
_write_yaml(
tmp_path / "queues.yaml",
{"queues": [{"name": "q1", "priority": 1, "hosts": {"members": ["nonexistent_hg"]}}]},
)
_write_yaml(tmp_path / "hosts.yaml", {"hosts": [], "hostgroups": []})
data = build.build_cluster_config(str(tmp_path)) # must not raise
assert isinstance(data, bytes)
stderr = capsys.readouterr().err
assert "nonexistent_hg" in stderr
# ---------------------------------------------------------------------------
# Real src/configs/*.yaml fixtures
# ---------------------------------------------------------------------------
@pytest.mark.skipif(not REPO_CONFIGS_DIR.is_dir(), reason="src/configs/ fixtures not present")
def test_build_real_repo_configs_succeeds_and_warns_on_known_gaps(capsys):
data = build.build_cluster_config(str(REPO_CONFIGS_DIR))
mod = load_schema()
with mod.ClusterConfig.from_bytes(data) as reader:
assert reader.clusterName == "eda1"
# queues.yaml/hosts.yaml still have several schema-valid-but-unmapped
# fields (description, nice, fairshare, ... -- see build.py's
# _UNMAPPED_BUT_VALID_ITEM_KEYS) and limits.yaml/queues.yaml reference
# bare usernames (e.g. "rshen") that the undefined-ref check can never
# resolve since users.yaml has no individual-user roster, only
# usergroups (Requirement 4.4 scopes that check to groups only) --
# both are expected warnings, not errors.
stderr = capsys.readouterr().err
assert "warning:" in stderr
@pytest.mark.skipif(not REPO_CONFIGS_DIR.is_dir(), reason="src/configs/ fixtures not present")
def test_write_output_real_repo_configs_hash_matches_manual_sha256(tmp_path):
data = build.build_cluster_config(str(REPO_CONFIGS_DIR))
out_path = tmp_path / "cluster.bin"
digest = build.write_output(data, str(out_path))
assert out_path.read_bytes() == data
assert digest == hashlib.sha256(data).hexdigest()
# ---------------------------------------------------------------------------
# write_output(): file path, stdout, and hash correctness
# ---------------------------------------------------------------------------
def test_write_output_writes_file_and_returns_correct_hash(tmp_path):
data = b"some cluster.bin bytes"
out_path = tmp_path / "out.bin"
digest = build.write_output(data, str(out_path))
assert out_path.read_bytes() == data
assert digest == hashlib.sha256(data).hexdigest()
def test_write_output_dash_writes_to_stdout_buffer(capsys):
data = b"stdout-bound bytes"
digest = build.write_output(data, "-")
captured = capsys.readouterr()
assert captured.out.encode() == data
assert digest == hashlib.sha256(data).hexdigest()
def test_write_output_does_not_print_the_hash_itself(tmp_path, capsys):
"""1.4's "print SHA-256 to stderr" is the CLI layer's job (task 4.1) --
write_output only returns the digest, it never prints anything.
"""
data = b"quiet"
build.write_output(data, str(tmp_path / "out.bin"))
captured = capsys.readouterr()
assert captured.err == ""
# ---------------------------------------------------------------------------
# Golden-file determinism (task 5.4)
# ---------------------------------------------------------------------------
FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures"
@pytest.mark.parametrize("fixture_name", ["minimal", "full"])
def test_golden_fixture_matches_checked_in_expected_bin(fixture_name):
fixture_dir = FIXTURES_DIR / fixture_name
data = build.build_cluster_config(str(fixture_dir / "configs"))
expected = (fixture_dir / "expected.bin").read_bytes()
assert data == expected
@pytest.mark.parametrize("fixture_name", ["minimal", "full"])
def test_golden_fixture_builds_twice_identically(fixture_name):
fixture_dir = FIXTURES_DIR / fixture_name / "configs"
first = build.build_cluster_config(str(fixture_dir))
second = build.build_cluster_config(str(fixture_dir))
assert first == second
assert hashlib.sha256(first).hexdigest() == hashlib.sha256(second).hexdigest()
def test_full_golden_fixture_covers_all_seven_file_types():
fixture_dir = FIXTURES_DIR / "full" / "configs"
present = {p.name for p in fixture_dir.glob("*.yaml")}
assert present == {
"hosts.yaml",
"queues.yaml",
"resources.yaml",
"users.yaml",
"limits.yaml",
"xce.yaml",
"scaler.yaml",
}
def test_minimal_golden_fixture_covers_hosts_and_queues_only():
fixture_dir = FIXTURES_DIR / "minimal" / "configs"
present = {p.name for p in fixture_dir.glob("*.yaml")}
assert present == {"hosts.yaml", "queues.yaml"}