Files
computer-playbook/tests/unit/python/plugins/lookup/test_deployment.py
T
kevinveenbirkenbachandClaude Opus 5 5324eadd8b fix(ai): resolve the gateway backend from the inventory, not from one host
LITELLM_BACKEND_ROLES read lookup('deployment').groups, which deployment.py
fills from group_names. That variable is host-scoped: it lists only the groups
the host currently being processed belongs to.

On a swarm round the manager carries svc-ai-ollama, svc-ai-lmstudio and
svc-ai-litellm while the workers carry three groups and none of them is an AI
backend. Both branches of LITELLM_CHAT_MODEL therefore missed on a worker and,
with OPENROUTER_API_KEY empty, the model resolved to the empty string.

Only one task made that visible. Every stateful task in the gateway include
carries delegate_to STACK_HOST plus run_once, so it templates in the manager's
scope and wrote the correct model into the database. The assert carries
neither, so it re-templated in each worker's own scope and failed there while
the manager passed.

The manager-only membership is not a placement decision. The swarm test
inventory is provisioned with a single --host MGR, so every role of the round's
closure gets that one host, and extend_inventory then widens only
svc-swarm-node, svc-storage-nfs-client, svc-storage-nfs-server and the round's
own application. The three AI roles declare no placement at all; they are
manager-only because nothing widened them.

Add an inventory selector next to the host-scoped one, built from the play-wide
groups magic var, skipping groups without hosts and Ansible's implicit all and
ungrouped, sorted so the cache key is stable. The key takes it too, or two
hosts with equal group_names but different inventories would share an entry.

The blast radius is two readers, both in 16_ai.yml, and both ask only whether
svc-ai-ollama or svc-ai-lmstudio is present. On the manager the new value gains
exactly svc-storage-nfs-server and svc-bkp-nfs-2-local, neither of which either
reader tests, so the manager's answer does not move and only the workers change,
to match it.

This is the third step on the same line. 46a16127fc set .running; b149ad0d22
corrected that to .groups because a targeted apps= round leaves only the
command-line roles in the whitelist. .groups solves the targeted round and not
the multi-host one, because group_names stays host-scoped either way.

Measured in a two-host play with a real group_vars file and the real lookup:
the worker resolves ollama false through group_names and true through the new
selector, the manager is true both ways, and the assert that failed in CI
passes on both hosts.

Requirement 036 named the host-scoped backend selection as the thing that has
to change and deferred it. That residual is closed, so the paragraph now
describes the selector in use and why the two rejected ones are wrong.

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

250 lines
9.1 KiB
Python

import unittest
from unittest.mock import patch
from plugins.lookup import deployment
from plugins.lookup.deployment import LookupModule
class TestDeploymentLookup(unittest.TestCase):
def setUp(self):
deployment._reset_cache_for_tests()
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_returns_dict_with_all_keys(self, mock_list):
mock_list.return_value = ["web-app-friendica", "web-app-baserow"]
result = LookupModule().run(
[],
variables={
"APPLICATIONS_WHITELIST": ["web-app-friendica"],
"group_names": ["web-app-friendica", "web-app-baserow"],
},
)
self.assertEqual(len(result), 1)
self.assertEqual(
set(result[0].keys()),
{
"whitelist",
"running",
"groups",
"inventory",
"deployed",
"runtime",
"all",
},
)
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_running_falls_back_to_groups_when_whitelist_empty(self, mock_list):
mock_list.return_value = []
result = LookupModule().run(
[],
variables={
"APPLICATIONS_WHITELIST": [],
"group_names": ["a", "b"],
},
)
self.assertEqual(result[0]["running"], ["a", "b"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_running_uses_whitelist_when_non_empty(self, mock_list):
mock_list.return_value = []
result = LookupModule().run(
[],
variables={
"APPLICATIONS_WHITELIST": ["a"],
"group_names": ["a", "b"],
},
)
self.assertEqual(result[0]["running"], ["a"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_whitelist_reflects_raw_input(self, mock_list):
mock_list.return_value = []
result = LookupModule().run(
[],
variables={
"APPLICATIONS_WHITELIST": ["web-app-foo"],
"group_names": ["web-app-bar"],
},
)
self.assertEqual(result[0]["whitelist"], ["web-app-foo"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_groups_reflects_raw_input(self, mock_list):
mock_list.return_value = []
result = LookupModule().run(
[],
variables={
"APPLICATIONS_WHITELIST": [],
"group_names": ["a", "b", "c"],
},
)
self.assertEqual(result[0]["groups"], ["a", "b", "c"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_all_reflects_invokable_apps(self, mock_list):
mock_list.return_value = ["x", "y", "z"]
result = LookupModule().run([], variables={"group_names": []})
self.assertEqual(result[0]["all"], ["x", "y", "z"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_missing_vars_treated_as_empty(self, mock_list):
mock_list.return_value = ["a"]
result = LookupModule().run([], variables={})
self.assertEqual(result[0]["whitelist"], [])
self.assertEqual(result[0]["groups"], [])
self.assertEqual(result[0]["running"], [])
self.assertEqual(result[0]["all"], ["a"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_inventory_is_play_wide_not_host_scoped(self, mock_list):
mock_list.return_value = []
worker = {
"group_names": ["web-app-mattermost"],
"groups": {
"svc-ai-litellm": ["mgr-01"],
"svc-ai-ollama": ["mgr-01"],
"web-app-mattermost": ["mgr-01", "wrk-01"],
},
}
result = LookupModule().run([], variables=worker)
self.assertEqual(result[0]["groups"], ["web-app-mattermost"])
self.assertEqual(
result[0]["inventory"],
["svc-ai-litellm", "svc-ai-ollama", "web-app-mattermost"],
)
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_inventory_skips_groups_without_hosts(self, mock_list):
mock_list.return_value = []
result = LookupModule().run(
[],
variables={
"group_names": [],
"groups": {"has-hosts": ["h1"], "empty": [], "also-empty": None},
},
)
self.assertEqual(result[0]["inventory"], ["has-hosts"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_inventory_drops_the_implicit_groups(self, mock_list):
mock_list.return_value = []
result = LookupModule().run(
[],
variables={
"group_names": ["web-app-mattermost"],
"groups": {
"all": ["mgr-01", "wrk-01"],
"ungrouped": ["mgr-01"],
"web-app-mattermost": ["mgr-01", "wrk-01"],
},
},
)
self.assertEqual(result[0]["inventory"], ["web-app-mattermost"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_inventory_empty_without_the_magic_var(self, mock_list):
mock_list.return_value = []
result = LookupModule().run([], variables={"group_names": ["a"]})
self.assertEqual(result[0]["inventory"], [])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_cache_key_separates_hosts_with_equal_group_names(self, mock_list):
mock_list.return_value = []
first = LookupModule().run(
[],
variables={"group_names": ["a"], "groups": {"a": ["h1"]}},
)
second = LookupModule().run(
[],
variables={"group_names": ["a"], "groups": {"a": ["h1"], "b": ["h2"]}},
)
self.assertEqual(first[0]["inventory"], ["a"])
self.assertEqual(second[0]["inventory"], ["a", "b"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_caches_same_input(self, mock_list):
mock_list.return_value = ["a"]
LookupModule().run(
[], variables={"APPLICATIONS_WHITELIST": [], "group_names": ["a"]}
)
LookupModule().run(
[], variables={"APPLICATIONS_WHITELIST": [], "group_names": ["a"]}
)
self.assertEqual(mock_list.call_count, 1)
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_whitelist_csv_string_tokenized(self, mock_list):
mock_list.return_value = []
result = LookupModule().run(
[],
variables={
"APPLICATIONS_WHITELIST": "web-app-foo,web-app-bar",
"group_names": [],
},
)
self.assertEqual(result[0]["whitelist"], ["web-app-foo", "web-app-bar"])
self.assertEqual(result[0]["running"], ["web-app-foo", "web-app-bar"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_groups_csv_string_tokenized(self, mock_list):
mock_list.return_value = []
result = LookupModule().run(
[],
variables={
"APPLICATIONS_WHITELIST": [],
"group_names": "a,b,c",
},
)
self.assertEqual(result[0]["groups"], ["a", "b", "c"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_whitelist_csv_string_whitespace_trimmed(self, mock_list):
mock_list.return_value = []
result = LookupModule().run(
[],
variables={
"APPLICATIONS_WHITELIST": " app1 , app2 ,, ",
"group_names": [],
},
)
self.assertEqual(result[0]["whitelist"], ["app1", "app2"])
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_deployed_uses_running_in_ephemeral_runtime(self, mock_list):
mock_list.return_value = []
for runtime in ("act", "github"):
deployment._reset_cache_for_tests()
result = LookupModule().run(
[],
variables={
"APPLICATIONS_WHITELIST": ["only-this"],
"group_names": ["only-this", "and-this", "and-that"],
"RUNTIME": runtime,
},
)
self.assertEqual(result[0]["deployed"], ["only-this"])
self.assertEqual(result[0]["runtime"], runtime)
@patch("plugins.lookup.deployment.list_invokable_app_ids")
def test_deployed_uses_groups_in_persistent_runtime(self, mock_list):
mock_list.return_value = []
for runtime in ("host", "dev", ""):
deployment._reset_cache_for_tests()
result = LookupModule().run(
[],
variables={
"APPLICATIONS_WHITELIST": ["partial"],
"group_names": ["partial", "still-running", "also-running"],
"RUNTIME": runtime,
},
)
self.assertEqual(
result[0]["deployed"],
["partial", "still-running", "also-running"],
)
if __name__ == "__main__":
unittest.main()