mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 10:59:59 +00:00
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>
81 lines
2.6 KiB
Python
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()
|