"""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_warns_not_fails_on_schema_valid_unmapped_resource_fields(tmp_path, capsys): _write_yaml( tmp_path / "resources.yaml", { "resources": [ { "name": "gpus", "type": "numeric", "order": "increase", "consumable": True, "release": False, } ] }, ) 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 "'type'" in stderr assert "'order'" in stderr assert "'consumable'" in stderr assert "'release'" 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" # src/configs/hosts.yaml has 2 pre-existing undefined refs (resource # "mg", host "h3" -- see task 2.1's Implementation Notes) and every # resources.yaml entry has a schema-required "type" field with no # ClusterConfig destination -- 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"}