Files
computer-playbook/tests/integration/roles/meta/test_non_empty.py
kevinveenbirkenbachandClaude Opus 5 f1635e6a59 feat(variants): let a variant dictate the config of the providers it pulls in
A round deploys a provider once, so the role that pulls it in is the only place that can say how lean it has to be. A variant entry may now carry any application config topic under a service it depends on: a mapping deep-merges, so naming one key changes that key alone, and null empties the topic. Empty was the case that needed a marker of its own, because merging an empty mapping is a no-op and would leave exactly the services the round was trying to shed.

The topic vocabulary and the merge semantics live in utils.roles.applications.topics, which is also where resources.py now gets them rather than spelling the same null-or-merge out a second time. Collecting who demands what stays in the inventory tree, refuses two roles demanding different values for one provider in the same round, and skips a role's own entity entry so the existing services.<own>.users declarations are not read as overrides.

The CI set-cover learns the same fact. Its greedy cover retires a role's variant-0 row when an earlier row of another role embeds it, on the stated premise that embedding pulls the provider's variant 0. A variant that dictates that provider's config breaks the premise from the other side: whatever ran, it was not the provider as the provider declares it. Such a pair now covers nothing, so the retired row keeps its job, its artefact and its verdict.

Measured on the current tree: 20 override pairs, 41 covered rows before and after, so nothing is revoked today. The rule bites the moment an override lands in a row that covers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 21:08:08 +02:00

81 lines
2.6 KiB
Python

import os
import re
import unittest
import yaml
from utils.cache.files import iter_project_files
from utils.cache.yaml import load_yaml_any
from utils.roles.applications.topics import CONFIG_TOPICS
from utils.roles.mapping import ROLE_FILE_META_VARIANTS
from . import PROJECT_ROOT
_CLEARED_TOPIC = re.compile(
rf"^\[\d+\]\.services\.[^.]+\.(?:{'|'.join(sorted(CONFIG_TOPICS))})$"
)
def find_none_values(data, prefix=None):
"""
Recursively find keys with None values in a nested dict or list.
Returns a list of (path, value) tuples where value is None.
"""
errors = []
if prefix is None:
prefix = []
if isinstance(data, dict):
for key, value in data.items():
path = [*prefix, str(key)]
if value is None:
errors.append((".".join(path), value))
elif isinstance(value, (dict, list)):
errors.extend(find_none_values(value, path))
elif isinstance(data, list):
for idx, item in enumerate(data):
path = [*prefix, f"[{idx}]"]
if item is None:
errors.append((".".join(path), item))
elif isinstance(item, (dict, list)):
errors.extend(find_none_values(item, path))
return errors
class TestConfigurationNoNone(unittest.TestCase):
def test_configuration_files_have_no_none_values(self):
roles_root = str(PROJECT_ROOT / "roles")
roles_prefix = roles_root + os.sep
meta_segment = os.sep + "meta" + os.sep
files = [
p
for p in iter_project_files(extensions=(".yml",))
if p.startswith(roles_prefix)
and meta_segment in p[len(roles_prefix) :]
and p[len(roles_prefix) :].count(os.sep) == 2
]
self.assertTrue(files, f"No roles/*/meta/*.yml files found under {roles_root}")
all_errors = []
for filepath in files:
try:
data = load_yaml_any(filepath)
except yaml.YAMLError as e:
self.fail(f"Failed to parse YAML in {filepath}: {e}")
is_variants = filepath.endswith(ROLE_FILE_META_VARIANTS)
errors = find_none_values(data)
for path, _value in errors:
if is_variants and _CLEARED_TOPIC.match(path):
continue
all_errors.append(f"{filepath}: Key '{path}' is None")
if all_errors:
self.fail(
"None values found in configuration files:\n" + "\n".join(all_errors)
)
if __name__ == "__main__":
unittest.main()