Repository navigation
fix(provision): gate the setup-wizard ansible-group builder on declared roles (#14638) - #14649
Conversation
…ed roles (#14638) _ansible_groups_for_nodes derived privileged-group membership (slm_server, backend, main, frontend, aiml, npu, browser) straight from the NodeRole assignment table, with no gate mirroring _strip_undeclared_privileged_groups on the dynamic-inventory path. It now strips privileged groups a node did not declare via Node.roles, read the same way #14637 reads it for node_roles_declared, so the fix does not depend on NodeRole staying declared-only. _strip_undeclared_privileged_groups itself is not reused as-is: the wizard unions in the legacy ROLE_ANSIBLE_GROUPS vocabulary too (e.g. vnc -> backend, absent from the canonical map), so both the raw and declared sides now go through a shared _groups_for_role_tokens_both_vocabularies helper to keep "declared" and "raw" talking about the same thing.
Review — APPROVEThe enumeration is the deliverable I asked for and it is the reason this round should be the last one. Three previous rounds each closed the door someone happened to be looking at; ten paths audited with a verdict each, only #4 open, is what ends that pattern. The catch on The wizard's raw side unions both vocabularies; the canonical stripper consults only one. So reusing it as-is would have computed the declared side from the canonical map while the raw side included the legacy grants — and a vnc node's legitimately-granted That is an over-tight gate: a node silently dropped from a deploy it needs. Quieter and worse than the crash being fixed, and the exact failure direction I have been asking each round of this chain to check. Confirmed the fix routes both sides through Worth noting how close that came: And it was found by mutation against the existing parity suite, not by inspection. That is the difference between a test suite that documents behaviour and one that catches you. Reverting the gate takes 16/16 to 11 passed / 5 failed — exactly the new tests, so they fail for the reason they exist. The trace is properly specific: Reporting the previously-undocumented producer ( Separating the 5 pre-existing failures by isolated re-run before attributing them is also correct — I would rather see that check than a claim they are unrelated. Merging once the required contexts and all twelve shards confirm green. |
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
Closes #14638
Thinking Path
Read
_ansible_groups_for_nodes(the setup-wizard's own ansible-groupbuilder,
api/setup_wizard.py) and traced everything that feeds it, per#14638's ask. It builds group membership from the
NodeRoleassignmenttable with no gate at all — unlike
services/inventory_builder.py'sbuild_registry_inventory, which has called_strip_undeclared_privileged_groupson the group branch since #14513/#14552.This is the second half of the shape #14594/#14637 fixed for the hostvar
branch (
node_roles_declared): same defect class, different code path(
_ansible_groups_for_nodesvs_apply_role_host_vars), which is why it wasfiled and fixed separately.
Root cause:
_ansible_groups_for_nodes(node_roles, node_id_to_inv_name)inapi/setup_wizard.py:113(pre-fix) unions two group vocabularies(
services/inventory_builder.groups_for_role_tokensand the legacyservices/role_registry.ROLE_ANSIBLE_GROUPS) for everyNodeRolerow it isgiven, with nothing filtering the privileged groups
(
slm_server/backend/main/frontend/aiml/npu/browser,inventory_builder._DECLARED_ONLY_GROUPS) the way the dynamic-inventorybuilder's own group branch already does.
What Changed
api/setup_wizard.py::_ansible_groups_for_nodesnow takesdb_nodes(NodeORM rows) and strips privileged groups a node's
Node.rolesdoes notjustify — reading declared intent straight off
Node.roles(
inventory_builder._declared_roles), the same source fix(provision): close node_roles_declared's setup-wizard fallback dependency (#14594) #14637 uses fornode_roles_declared, rather than trusting theNodeRoletable to staydeclared-only.
_build_inventory_childrenand_generate_dynamic_inventorythread
db_nodesthrough._groups_for_role_tokens_both_vocabularies: both the "raw" and"declared" group sets are computed through the same union of the canonical
map and the legacy
ROLE_ANSIBLE_GROUPSmap._strip_undeclared_privileged_groupsitself is deliberately NOT reused as-is — it only consults the canonical map,
and the wizard's raw side also unions in
ROLE_ANSIBLE_GROUPS(e.g.vncreaches
backendonly through that legacy map). Reusing it unmodifiedwould have stripped a genuinely declared
vncnode'sbackendmembership —caught by mutation-testing my own first draft against the existing
test_no_group_this_path_already_emitted_disappearsparity test.NodeRolerow whosenode_idhas no matchingdb_nodesentry) fails closed: its privileged groups strip to nothingrather than passing through unfiltered.
tests/services/test_wizard_inventory_parity_14286.py(already thecolocated, correctly-real-loaded home for this builder's tests) with 8 new
tests covering the detected-only, declared, legacy-vocabulary, unresolved-node
and end-to-end-via-
_build_inventory_childrencases._children_fortest helper to mark every role asdeclared (matching the file's existing "vocabulary parity" scope), and
_build_inventory_children's only other test call site.Verification
Full enumeration of every path from a detected role to a privileged group
or privileged activation fact, across both the dynamic-inventory builder and
the setup wizard (the deliverable #14638 asked for):
services/inventory_builder.py:438build_registry_inventoryGROUP branch_strip_undeclared_privileged_groups(#14513/#14552)services/inventory_builder.py:327node_roleshostvar (raw union)services/inventory_builder.py:334node_roles_declaredhostvar_declared_roles(node)=Node.rolesonlyapi/setup_wizard.py_ansible_groups_for_nodesGROUP branchapi/setup_wizard.py:394node_roleshostvar (raw union fromNodeRoletable)api/setup_wizard.pynode_roles_declaredhostvar_declared_roles(node)=Node.rolesdirectlye8517c3e6)api/setup_wizard.py_inject_co_located_ai_stack(#3461)node_rolesandnode_roles_declared; never reaches the GROUP builder at all (only the in-memory hostvar dict)ansible/playbooks/vars/role_active_facts.ymlfive privileged factsnode_roles_declared) now gated on both buildersansible/inventory/slm-nodes.yml, CI fixtures)node_roles_declaredfallback intentionally preservedgroups_for_role_tokens/ROLE_ANSIBLE_GROUPSAlso audited every
NodeRole(...)/Node.roles =write site inapi/,services/,main.py(the thing both builders' "declared" sideultimately trusts): SLM-manager bootstrap and
node_seeder.sync_slm_node_rolesare bounded by a hardcoded/existing declared set;
compose_fleet.py's_ensure_compose_nodesderives from a hardcoded topology table (declared byconstruction, not agent-probed — a producer not previously named in #14594's
provenance table, documented here for completeness);
sync_orchestrator.py'srole-sync only touches an operator-selected role already on the node;
api/nodes.py,api/npu.pyare authenticated admin endpoints;deployment.py/reconciler.py/blue_green.pyonly ever subtract roles ormove already-declared roles between paired nodes. The one place
Node.detected_roles(the untrusted agent heartbeat probe) is written(
api/nodes.py:1872) never reachesNode.rolesor theNodeRoletable —detection stays correctly firewalled from declared intent everywhere.
Test run (local design-aid only — CI is authoritative):
pytest tests/services/test_wizard_inventory_parity_14286.py— 16/16 passedafter the fix.
Mutation: reverted the gate in
_ansible_groups_for_nodes(dropped the_DECLARED_ONLY_GROUPSstripping) → 11 passed / 5 failed, all 5 failures thenew detected-only/legacy-vocabulary/unresolved-node/end-to-end tests, exactly
as expected. Restored the fix → back to 16/16.
Also ran the full existing coverage for this area unmodified —
setup_wizard_privileged_node_roles_test.py,inventory_privileged_node_roles_test.py,inventory_detected_roles_test.py,node_roles_declared_producers_test.py,inventory_deploy_groups_test.py:43/43 passed. A broader
autobot-slm-backend/api autobot-slm-backend/services autobot-slm-backend/testsrun: 2189 passed, 3 skipped, 5 failed — the 5failures are
services/hf_token_validator_test.py, pre-existing andunrelated (fail identically in complete isolation, before touching any file
this PR changes — a local
httpx-import gap in this environment).Model Used
Claude Opus 5 (1M context)