feat(xceadmin): add 'config dump' command and size-string thresholds
Adds `xceadmin config dump CLUSTER_BIN_PATH`, the reverse of `config
build` -- decodes a serialized ClusterConfig back to human-readable text
so a built config can be inspected without a throwaway pycapnp script.
Motivated directly by how bug-1556 (hosts.yaml's max_slot: -1 auto-detect
sentinel silently becoming mxj=0) was actually found: by hand-decoding a
live cluster.bin. The dump renders the same sentinel as
"max_slot=-1 (auto-detect from CPU count)", not the raw uint32.
Also adds parse_size_or_number() to xcecommon.mapping so a queue/host's
sched_stop threshold on a byte-valued resource (maxmem/mem/tmp/maxswap/
maxtmp) can be written as a human-friendly size string ("500MB", "2GB")
instead of a raw byte count; a plain number still passes through
unchanged for non-byte resources like load averages. hosts.schema.json
and queues.schema.json's sched_stop schema gains a matching SizeOrNumber
type. dump_cluster_config() humanizes the same byte-valued thresholds
back on the way out (_format_bytes_human).
New ClusterBinFileError covers every local cluster.bin read/decode
failure mode without letting a raw OSError/capnp.KjException escape the
CLI. RESOURCE_VALTYPE/RESOURCE_ORDTYPE/RESOURCE_FLAG_* made public in
mapping.py so dump_cluster_config can reuse the same int<->name tables
build_cluster_config already uses, keeping both directions in sync by
construction.
256 xceadmin tests pass (incl. new test_config_dump.py and the
parse_size_or_number/SizeOrNumber cases in test_mapping.py/test_cli.py),
verified via `xce-build.sh xceadmin`'s full pinned out-of-tree install
+ pytest run before this commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
d479b70764
commit
3afe3d5549
9 changed files with 560 additions and 19 deletions
|
|
@ -51,6 +51,7 @@ def test_cli_config_help_shows_build_and_push() -> None:
|
|||
assert result.exit_code == 0
|
||||
assert "build" in result.output
|
||||
assert "push" in result.output
|
||||
assert "dump" in result.output
|
||||
|
||||
|
||||
def test_cli_group_invoked_with_no_args_does_not_traceback() -> None:
|
||||
|
|
@ -132,6 +133,39 @@ def test_config_build_missing_configs_dir_does_not_raw_traceback(tmp_path):
|
|||
assert result.exit_code == 0, "a nonexistent dir behaves like one with zero known files present (1.5)"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config dump
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_config_dump_prints_decoded_text(tmp_path):
|
||||
(tmp_path / "xce.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{"cluster_name": "eda1", "heartbeat_interval": 30, "inactive_heartbeat_count": 3}
|
||||
)
|
||||
)
|
||||
bin_path = tmp_path / "cluster.bin"
|
||||
runner = CliRunner()
|
||||
build_result = runner.invoke(cli, ["config", "build", str(tmp_path), "-o", str(bin_path)])
|
||||
assert build_result.exit_code == 0, build_result.output
|
||||
|
||||
result = runner.invoke(cli, ["config", "dump", str(bin_path)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "=== ClusterConfig ===" in result.output
|
||||
assert "clusterName: eda1" in result.output
|
||||
|
||||
|
||||
def test_config_dump_missing_file_exits_nonzero_with_message(tmp_path):
|
||||
missing = tmp_path / "does-not-exist.bin"
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(cli, ["config", "dump", str(missing)])
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert not isinstance(result.exception, KeyError | AttributeError | TypeError)
|
||||
assert result.output.strip() != ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# config push / config push --dry-run (mock controller)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
128
src/xceadmin/tests/test_config_dump.py
Normal file
128
src/xceadmin/tests/test_config_dump.py
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
"""Tests for ``xceadmin.build.dump_cluster_config`` -- the reverse of
|
||||
``build_cluster_config`` (decode a ``cluster.bin`` back to human-readable
|
||||
text). Uses the same ``tests/fixtures/full`` real-config fixture as
|
||||
test_config_build.py rather than a second golden text fixture: the
|
||||
assertions below check for specific substrings (mirrors
|
||||
test_scheduler.py's ``dump()`` test style), not a byte-exact text match,
|
||||
so formatting can evolve without a brittle golden file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from xcecommon.errors import ClusterBinFileError
|
||||
from xceadmin import build
|
||||
|
||||
FIXTURES_DIR = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def full_cluster_bin(tmp_path):
|
||||
data = build.build_cluster_config(str(FIXTURES_DIR / "full" / "configs"))
|
||||
path = tmp_path / "cluster.bin"
|
||||
path.write_bytes(data)
|
||||
return path
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section content
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dump_lists_cluster_scalars(full_cluster_bin):
|
||||
text = build.dump_cluster_config(str(full_cluster_bin))
|
||||
assert "=== ClusterConfig ===" in text
|
||||
assert "clusterName:" in text
|
||||
assert "heartbeatInterval:" in text
|
||||
|
||||
|
||||
def test_dump_lists_each_resource_with_decoded_type_and_order(full_cluster_bin):
|
||||
text = build.dump_cluster_config(str(full_cluster_bin))
|
||||
assert "=== resources (" in text
|
||||
# "mem" is numeric/decrease/not consumable -- valtype/ordtype/flags int
|
||||
# codes must render back to their YAML names, not raw ints.
|
||||
assert "name=mem type=numeric order=decrease" in text
|
||||
assert "consumable=False" in text
|
||||
|
||||
|
||||
def test_dump_renders_mxj_auto_detect_sentinel_not_raw_uint32(full_cluster_bin):
|
||||
"""The exact bug that motivated this feature: hosts.yaml's `default`
|
||||
host has `max_slot: -1`, which wire-encodes as HOSTCONFIG_MXJ_AUTO_DETECT
|
||||
(0xFFFFFFFF). The dump must show the human meaning, not the raw UInt32.
|
||||
"""
|
||||
text = build.dump_cluster_config(str(full_cluster_bin))
|
||||
assert "=== host: default ===" in text
|
||||
assert "max_slot=-1 (auto-detect from CPU count)" in text
|
||||
# worker2 has an explicit max_slot: 256 -- shown as a plain number.
|
||||
assert "=== host: worker2 ===" in text
|
||||
assert "max_slot=256" in text
|
||||
|
||||
|
||||
def test_dump_lists_queues_groups_scalers_and_limits(full_cluster_bin):
|
||||
text = build.dump_cluster_config(str(full_cluster_bin))
|
||||
assert "=== queues (" in text
|
||||
assert "=== ugroups (" in text
|
||||
assert "=== hgroups (" in text
|
||||
assert "=== hproviders (" in text
|
||||
assert "=== limits (" in text
|
||||
assert "=== limit: ug1_slots ===" in text
|
||||
|
||||
|
||||
def test_dump_output_ends_with_trailing_newline(full_cluster_bin):
|
||||
text = build.dump_cluster_config(str(full_cluster_bin))
|
||||
assert text.endswith("\n")
|
||||
assert not text.endswith("\n\n")
|
||||
|
||||
|
||||
def test_dump_humanizes_byte_valued_threshold(tmp_path):
|
||||
"""A host's sched_stop threshold on a byte-valued resource (maxmem) is
|
||||
shown as a human-readable size, not a raw float byte count -- mirrors
|
||||
the mem/tmp/maxswap/maxtmp/maxmem unit-consistency work this session.
|
||||
"""
|
||||
(tmp_path / "hosts.yaml").write_text(
|
||||
yaml.safe_dump(
|
||||
{
|
||||
"hosts": [
|
||||
{
|
||||
"hostname": "h1",
|
||||
"sched_stop": [{"resname": "maxmem", "sched": "500MB", "stop": "1GB"}],
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
data = build.build_cluster_config(str(tmp_path))
|
||||
bin_path = tmp_path / "cluster.bin"
|
||||
bin_path.write_bytes(data)
|
||||
|
||||
text = build.dump_cluster_config(str(bin_path))
|
||||
assert "threshold: name=maxmem sched=500.00 MB stop=1.00 GB" in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error handling
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_dump_missing_file_raises_cluster_bin_file_error(tmp_path):
|
||||
missing = tmp_path / "does-not-exist.bin"
|
||||
with pytest.raises(ClusterBinFileError):
|
||||
build.dump_cluster_config(str(missing))
|
||||
|
||||
|
||||
def test_dump_empty_file_raises_cluster_bin_file_error(tmp_path):
|
||||
empty = tmp_path / "empty.bin"
|
||||
empty.write_bytes(b"")
|
||||
with pytest.raises(ClusterBinFileError):
|
||||
build.dump_cluster_config(str(empty))
|
||||
|
||||
|
||||
def test_dump_garbage_bytes_raises_cluster_bin_file_error_not_crash(tmp_path):
|
||||
garbage = tmp_path / "garbage.bin"
|
||||
garbage.write_bytes(b"not a cluster config, just noise")
|
||||
with pytest.raises(ClusterBinFileError):
|
||||
build.dump_cluster_config(str(garbage))
|
||||
|
|
@ -293,6 +293,61 @@ def test_map_list_section_host_config_sets_mxj_auto_detect_sentinel_when_max_slo
|
|||
assert mapping.HOSTCONFIG_MXJ_AUTO_DETECT != 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
(3.5, 3.5),
|
||||
(2000, 2000.0),
|
||||
(0, 0.0),
|
||||
("500MB", 500 * 1024**2),
|
||||
("2GB", 2 * 1024**3),
|
||||
("1.5GB", 1.5 * 1024**3),
|
||||
("500 MB", 500 * 1024**2), # space between number and unit
|
||||
("500mb", 500 * 1024**2), # lowercase unit
|
||||
("1024B", 1024.0),
|
||||
("-1KB", -1024.0),
|
||||
],
|
||||
)
|
||||
def test_parse_size_or_number_accepts_plain_numbers_and_size_strings(value, expected):
|
||||
assert mapping.parse_size_or_number(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"not a number",
|
||||
"500XB", # unrecognized unit
|
||||
"GB", # missing number
|
||||
"500", # missing unit -- ambiguous, must be a plain number instead
|
||||
None,
|
||||
[1, 2],
|
||||
],
|
||||
)
|
||||
def test_parse_size_or_number_rejects_unparseable_values(value):
|
||||
with pytest.raises((TypeError, ValueError)):
|
||||
mapping.parse_size_or_number(value)
|
||||
|
||||
|
||||
def test_map_list_section_host_config_sched_stop_accepts_size_strings(cluster_config):
|
||||
"""The production case for parse_size_or_number: an operator writes a
|
||||
human-friendly size string for a byte-valued resource's threshold
|
||||
(e.g. maxmem/mem/tmp/maxswap/maxtmp -- see test.c's
|
||||
is_bytes_valued_resource) instead of computing the raw byte count by
|
||||
hand."""
|
||||
items = [
|
||||
{
|
||||
"hostname": "worker4",
|
||||
"sched_stop": [{"resname": "tmp", "sched": "500MB", "stop": "100MB"}],
|
||||
}
|
||||
]
|
||||
mapping.map_list_section(_spec_for("hostConfigs"), items, cluster_config)
|
||||
|
||||
threshold = cluster_config.hostConfigs[0].thresholds[0]
|
||||
assert threshold.name == "tmp"
|
||||
assert threshold.sched == 500 * 1024**2
|
||||
assert threshold.stop == 100 * 1024**2
|
||||
|
||||
|
||||
def test_time_window_day_prefixed_spec_parses_day_hour_minute(cluster_config):
|
||||
items = [
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue