feat(config, validation): enhance config validation with strict mode and update example checks

- Added a `--strict` option to `validate_config.py` to fail on "Unknown section" messages, aimed at CI validation of example configs.
- Updated GitHub Actions workflow to validate shipped example configs against the canonical section list using the new strict mode.
- Expanded the `CANONICAL_NON_COMMAND_SECTIONS` in `config_validation.py` to include additional sections for improved validation accuracy.
- Introduced a regression test to ensure example configs do not trigger unknown section warnings, maintaining consistency with the canonical list.
This commit is contained in:
agessaman
2026-07-08 08:56:05 -07:00
parent 95b0601980
commit 02ce8beaaf
5 changed files with 128 additions and 0 deletions
+10
View File
@@ -75,6 +75,16 @@ jobs:
- name: Log injection check — fail on new unsanitized logger calls
run: python scripts/check_log_injection.py
- name: Config validation — fail on unrecognized sections in shipped configs
run: |
set -e
for f in config.ini.example config.ini.minimal-example config.ini.quickstart config.min.ini config-pymc.ini; do
if [ -f "$f" ]; then
echo "Validating $f"
python validate_config.py --config "$f" --strict
fi
done
typecheck:
name: Type check (mypy)
runs-on: ubuntu-latest
+11
View File
@@ -39,6 +39,7 @@ def _channel_name_is_public(name: str) -> bool:
CANONICAL_NON_COMMAND_SECTIONS = frozenset({
"Connection",
"Bot",
"Admin",
"Channels",
"Banned_Users",
"Localization",
@@ -46,6 +47,7 @@ CANONICAL_NON_COMMAND_SECTIONS = frozenset({
"Plugin_Overrides",
"Companion_Purge",
"Keywords",
"RandomLine",
"Scheduled_Messages",
"Logging",
"Custom_Syntax",
@@ -53,12 +55,21 @@ CANONICAL_NON_COMMAND_SECTIONS = frozenset({
"Weather",
"Solar_Config",
"Channels_List",
"Data_Retention",
"Web_Viewer",
"Feed_Manager",
"PacketCapture",
"MapUploader",
"Weather_Service",
"MqttWeather",
"Earthquake_Service",
"Worldcup_Service",
"Rate_Limits",
"Webhook",
"RepeaterPrefixCollision_Service",
"DiscordBridge",
"TelegramBridge",
"DARC_MoWaS_Service",
})
# Sections required for the bot to start (accessed without has_section guards)
+24
View File
@@ -176,6 +176,30 @@ test = ack
)
class TestExampleConfigsHaveNoUnknownSections:
"""Regression test for #215: shipped example configs must not trigger
'Unknown section' info messages, i.e. CANONICAL_NON_COMMAND_SECTIONS must
stay in sync with the sections actually used in the example files."""
def test_example_configs_have_no_unknown_sections(self):
base = Path(__file__).resolve().parent.parent
example_files = [
"config.ini.example",
"config.ini.minimal-example",
"config.ini.quickstart",
]
for filename in example_files:
path = base / filename
if not path.exists():
continue
results = validate_config(str(path))
unknown = [
r for r in results
if r[0] == SEVERITY_INFO and "not in canonical list" in r[1]
]
assert unknown == [], f"{filename} has unrecognized sections: {unknown}"
class TestPathValidation:
"""Tests for path writability validation."""
+66
View File
@@ -0,0 +1,66 @@
"""Tests for the validate_config.py CLI entry point, in particular --strict."""
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
VALID_CONFIG = """[Connection]
connection_type = serial
serial_port = /dev/ttyUSB0
[Bot]
bot_name = TestBot
db_path = {db_path}
[Channels]
monitor_channels = general
respond_to_dms = true
[Keywords]
test = ack
"""
CONFIG_WITH_UNKNOWN_SECTION = VALID_CONFIG + """
[TotallyMadeUpSection]
foo = bar
"""
def _run_validate_config(config_path: Path, *extra_args: str) -> subprocess.CompletedProcess:
return subprocess.run(
[sys.executable, "validate_config.py", "--config", str(config_path), *extra_args],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=30,
)
class TestValidateConfigCli:
def test_unknown_section_does_not_fail_without_strict(self, tmp_path):
config = tmp_path / "config.ini"
config.write_text(CONFIG_WITH_UNKNOWN_SECTION.format(db_path=str(tmp_path / "meshcore_bot.db")))
result = _run_validate_config(config)
assert result.returncode == 0
assert "Unknown section [TotallyMadeUpSection]" in result.stderr
def test_unknown_section_fails_with_strict(self, tmp_path):
config = tmp_path / "config.ini"
config.write_text(CONFIG_WITH_UNKNOWN_SECTION.format(db_path=str(tmp_path / "meshcore_bot.db")))
result = _run_validate_config(config, "--strict")
assert result.returncode == 1
assert "Unknown section [TotallyMadeUpSection]" in result.stderr
def test_valid_config_passes_with_strict(self, tmp_path):
config = tmp_path / "config.ini"
config.write_text(VALID_CONFIG.format(db_path=str(tmp_path / "meshcore_bot.db")))
result = _run_validate_config(config, "--strict")
assert result.returncode == 0
def test_error_still_fails_regardless_of_strict(self, tmp_path):
config = tmp_path / "config.ini"
config.write_text("[Bot]\nbot_name = Test\n")
result = _run_validate_config(config)
assert result.returncode == 1
+17
View File
@@ -4,6 +4,12 @@ Validate MeshCore Bot config.ini section names.
Run standalone: python validate_config.py [--config config.ini]
Exits with 1 if any errors are found, 0 otherwise. Warnings and info are printed but do not affect exit code.
Use --strict to also fail on "Unknown section" info messages. This is intended for
CI checks against the shipped example configs (config.ini.example, etc.), which should
never contain a section outside the canonical list (see CANONICAL_NON_COMMAND_SECTIONS
in modules/config_validation.py). It is not recommended for validating end-user configs,
since those may legitimately contain sections for local/third-party plugins.
"""
import argparse
@@ -25,6 +31,15 @@ def main() -> int:
default="config.ini",
help="Path to configuration file (default: config.ini)",
)
parser.add_argument(
"--strict",
action="store_true",
help=(
"Also fail (exit 1) on 'Unknown section' info messages. Intended for "
"CI validation of the shipped example configs; not recommended for "
"end-user configs, which may have local/third-party plugin sections."
),
)
args = parser.parse_args()
results = validate_config(args.config)
@@ -37,6 +52,8 @@ def main() -> int:
print(f"Warning: {message}", file=sys.stderr)
else:
print(f"Info: {message}", file=sys.stderr)
if args.strict and "Unknown section" in message:
has_error = True
return 1 if has_error else 0