Files
computer-playbook/tests/unit/python/plugins/lookup/test_container_extra_hosts.py
T
kevinveenbirkenbachandClaude Opus 5 8d1d45d246 fix(discourse): pin the OIDC issuer for the launcher-built container
Nextcloud#7 of run 33445831564 fails on three discourse specs, all on one
assertion, all for one reason. The received body is Discourse's own page:
"Sorry, there was an error while trying to authorize your account with oidc."

The browser leg works. What dies is Discourse's server-side back-channel:

    "GET /auth/oidc/callback?...code=..."  302  2.125 2.125
    "GET /auth/failure?message=openid_connect_request_failed&...&strategy=oidc"  200

Four of those, at 2.125 / 2.015 / 2.063 / 2.014 seconds. That is not the plugin's
10s HTTP timeout; it is Discourse's DNS budget, hard-coded at 2s in
lib/final_destination/resolver.rb (connect_timeout arrives nil, so the default
applies). openid_connect_request_failed has exactly one emitter --
omniauth_open_id_connect.rb:147-155, rescue Faraday::Error -- and the alternative,
SocketError -> LookupFailedError, is not a Faraday::Error and would have produced a
500 rather than the observed 302.

The correlation leaves no room: across 18 back-channel requests, all from
192.168.9.3 with UA Faraday v2.14.3, there are exactly two complete token 200 +
userinfo 200 pairs -- and exactly two specs passed. Thirteen truncated flows,
thirteen failures. No test that got a complete exchange failed; none that failed got
one.

Why discourse alone: discourse.inspect.json:72 reads "ExtraHosts": null. Grepping the
whole rescue bundle for the pin finds nextcloud, nextcloud-proxy, mailu-admin,
prometheus and prometheus-sso-proxy. Discourse could not have it: config.yml.j2's
docker_args: carried only --network= and --name=. It is built by discourse_docker's
launcher, not by a compose file, so the lookup's rendered block has nowhere to go --
hence entries_only, which returns the strings and lets the template write
--add-host= lines. The pin itself is NOT re-derived: _sso_pins() and
_gateway_address() stay the single place that knows compose from swarm.

Its existing allowed_internal_hosts cannot help, because ssrf_detector's
lookup_and_filter_ips resolves BEFORE host_bypasses_checks?; the setting only makes a
private answer acceptable once it arrives.

Rendered through a real Templar both ways: with SSO on, the two --add-host lines land
between the networks and --name; with SSO off the output is byte-identical to today,
so clearnet and variant-1 deployments render an unchanged app.yml and trigger no
launcher rebuild. 24 lookup tests green.

Not closed: the biber persona has never passed, here or in run 33317483326. Its
failure text and code path match administrator, which did pass, so it is explained --
but a persona-specific second defect is not positively excluded. Deploying discourse
variant 0 locally, which already has sso and tor enabled, settles it.

Separately worth queueing: discourse's own meta/variants.yml variant 1 sets
sso.enabled: false and only variant 1 is scheduled, so the role that owns these specs
never runs them. Only partner rounds like Nextcloud#7 catch this class.

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

210 lines
7.3 KiB
Python

import unittest
from unittest.mock import MagicMock, patch
from ansible.errors import AnsibleError
from plugins.lookup.container_extra_hosts import LookupModule
ONION = "auth." + "b" * 56 + ".onion"
class TestContainerExtraHostsLookup(unittest.TestCase):
def setUp(self):
self.lookup = LookupModule()
def _get(name, *args, **kwargs):
plugin = MagicMock()
def _run(terms, variables=None, **_kwargs):
vars_ = variables or {}
if name == "config":
return [vars_.get("_sso_enabled", False)]
if name == "tls":
return [vars_.get("_provider_domain", "")]
raise AssertionError(f"unexpected lookup '{name}'")
plugin.run.side_effect = _run
return plugin
patcher = patch(
"plugins.lookup.container_extra_hosts.lookup_loader.get", side_effect=_get
)
patcher.start()
self.addCleanup(patcher.stop)
def _run(self, extra_hosts=None, **overrides):
variables = {
"application_id": "web-app-espocrm",
"_sso_enabled": True,
"_provider_domain": ONION,
"DEPLOYMENT_MODE": "compose",
}
variables.update(overrides)
kwargs = {} if extra_hosts is None else {"extra_hosts": extra_hosts}
return self.lookup.run(None, variables=variables, **kwargs)[0]
def test_compose_pins_the_provider_to_the_host_gateway(self):
self.assertEqual(
self._run(),
'extra_hosts:\n - "host.docker.internal:host-gateway"\n'
f' - "{ONION}:host-gateway"',
)
def test_swarm_pins_the_provider_to_the_node_address(self):
block = self._run(
DEPLOYMENT_MODE="swarm",
ansible_facts={"default_ipv4": {"address": "10.0.0.7"}},
)
self.assertIn(f' - "{ONION}:10.0.0.7"', block)
self.assertNotIn(f"{ONION}:host-gateway", block)
def test_docker_internal_alias_is_always_present(self):
self.assertIn(' - "host.docker.internal:host-gateway"', self._run())
def test_sso_disabled_emits_nothing(self):
self.assertEqual(self._run(_sso_enabled=False), "")
def test_clearnet_provider_emits_nothing(self):
self.assertEqual(self._run(_provider_domain="auth.example.org"), "")
def test_onion_substring_that_is_not_the_suffix_emits_nothing(self):
self.assertEqual(self._run(_provider_domain="auth.onion.example.org"), "")
def test_empty_provider_domain_emits_nothing(self):
self.assertEqual(self._run(_provider_domain=""), "")
def test_compose_mode_force_overrides_the_cluster_mode(self):
block = self._run(compose_mode_force="compose", DEPLOYMENT_MODE="swarm")
self.assertIn(f' - "{ONION}:host-gateway"', block)
def test_deployment_mode_is_used_without_an_override(self):
block = self._run(
compose_mode_force="",
DEPLOYMENT_MODE="swarm",
ansible_facts={"default_ipv4": {"address": "10.0.0.8"}},
)
self.assertIn(f' - "{ONION}:10.0.0.8"', block)
def test_absent_deployment_mode_defaults_to_compose(self):
variables = {
"application_id": "web-app-espocrm",
"_sso_enabled": True,
"_provider_domain": ONION,
}
block = self.lookup.run(None, variables=variables)[0]
self.assertIn(f' - "{ONION}:host-gateway"', block)
def test_swarm_without_facts_raises(self):
with self.assertRaises(AnsibleError):
self._run(DEPLOYMENT_MODE="swarm")
def test_positional_terms_raise(self):
with self.assertRaises(AnsibleError):
self.lookup.run(["web-app-espocrm"], variables={})
def test_missing_application_id_raises(self):
with self.assertRaises(AnsibleError):
self.lookup.run(None, variables={})
def test_application_id_kwarg_overrides_the_play_var(self):
seen = []
def _get(name, *args, **kwargs):
plugin = MagicMock()
def _run(terms, variables=None, **_kwargs):
if name == "config":
seen.append(terms[0])
return [False]
return [""]
plugin.run.side_effect = _run
return plugin
with patch(
"plugins.lookup.container_extra_hosts.lookup_loader.get", side_effect=_get
):
self.lookup.run(
None,
variables={"application_id": "web-app-joomla"},
application_id="web-app-penpot",
)
self.assertEqual(seen, ["web-app-penpot"])
def test_caller_entries_alone_emit_a_block_without_sso(self):
self.assertEqual(
self._run(_sso_enabled=False, extra_hosts=["seaweedfs.example:1.2.3.4"]),
'extra_hosts:\n - "seaweedfs.example:1.2.3.4"',
)
def test_caller_entries_are_appended_after_the_sso_pins(self):
block = self._run(extra_hosts=["db.example:10.0.0.9"])
self.assertEqual(
block.splitlines(),
[
"extra_hosts:",
' - "host.docker.internal:host-gateway"',
f' - "{ONION}:host-gateway"',
' - "db.example:10.0.0.9"',
],
)
def test_a_bare_string_is_accepted(self):
self.assertIn(
' - "one.example:192.0.2.1"',
self._run(_sso_enabled=False, extra_hosts="one.example:192.0.2.1"),
)
def test_duplicates_collapse_in_first_seen_order(self):
block = self._run(
extra_hosts=["host.docker.internal:host-gateway", "db.example:10.0.0.9"]
)
self.assertEqual(block.count("host.docker.internal"), 1)
self.assertEqual(
block.splitlines()[1], ' - "host.docker.internal:host-gateway"'
)
def test_empty_entries_are_dropped(self):
self.assertEqual(
self._run(_sso_enabled=False, extra_hosts=["", None, " "]), ""
)
def test_entry_without_a_separator_raises(self):
with self.assertRaises(AnsibleError):
self._run(_sso_enabled=False, extra_hosts=["no-address-here"])
def test_no_sources_at_all_emit_nothing(self):
self.assertEqual(self._run(_sso_enabled=False, extra_hosts=[]), "")
def _run_entries(self, **overrides):
variables = {
"application_id": "web-app-discourse",
"_sso_enabled": True,
"_provider_domain": ONION,
"DEPLOYMENT_MODE": "compose",
}
variables.update(overrides)
return self.lookup.run(None, variables=variables, entries_only=True)
def test_entries_only_returns_the_raw_mappings(self):
self.assertEqual(
self._run_entries(),
["host.docker.internal:host-gateway", f"{ONION}:host-gateway"],
)
def test_entries_only_follows_the_swarm_address(self):
self.assertEqual(
self._run_entries(
DEPLOYMENT_MODE="swarm",
ansible_facts={"default_ipv4": {"address": "10.0.0.7"}},
),
["host.docker.internal:host-gateway", f"{ONION}:10.0.0.7"],
)
def test_entries_only_is_empty_without_sso(self):
self.assertEqual(self._run_entries(_sso_enabled=False), [])
if __name__ == "__main__":
unittest.main()