feat(lookup): container_hostname — kernel-safe container hostname

`lookup('container_hostname', app)` returns the app's canonical primary
domain when it fits sethostname(2)'s 63-byte cap, else the short entity
name. Onion subdomains (sub.<56-char>.onion) overflow that limit and make
`docker run` fail with "sethostname: invalid argument", killing container
init on onion nodes. Replaces the seven roles that hostname'd their
container on the raw domain (bookwyrm, bridgy-fed, confluence, jira,
postmarks, pretix, xwiki) with the encapsulated lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 4.8 committed 2026-07-13 02:34:00 +02:00
1 parent 10a11fc570
commit c8a4320478
9 files changed
+133 -7

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
from __future__ import annotations
from typing import Any
from ansible.errors import AnsibleError
from ansible.plugins.lookup import LookupBase
from utils.cache.domains import get_merged_domains
from utils.domains.primary_domain import get_domain
from utils.roles.entity_name import get_entity_name
# sethostname(2) caps a hostname at 64 bytes; onion subdomains
# (sub.<56-char-onion>.onion) regularly exceed that and make the container
# fail to start with "sethostname: invalid argument".
_HOST_NAME_MAX = 63
class LookupModule(LookupBase):
"""
Usage:
{{ lookup('container_hostname', application_id) }}
The Docker/OCI hostname for an application's container: its canonical
primary domain when that fits the kernel's 63-byte limit, otherwise the
short entity name (e.g. ``xwiki``). Centralises the domain-or-entity
choice so onion deployments (long ``.onion`` subdomains that overflow
sethostname(2)) never break container init.
"""
def run(self, terms, variables: dict[str, Any] | None = None, **kwargs):
if not terms or len(terms) != 1:
raise AnsibleError(
"lookup('container_hostname', application_id) expects exactly 1 term"
)
application_id = terms[0]
if not isinstance(application_id, str) or not application_id.strip():
raise AnsibleError(
"lookup('container_hostname'): application_id must be a "
f"non-empty string, got {application_id!r}"
)
application_id = application_id.strip()
variables = variables or getattr(self._templar, "available_variables", {}) or {}
domains = get_merged_domains(
variables=variables,
roles_dir=kwargs.get("roles_dir"),
templar=getattr(self, "_templar", None),
)
try:
domain = get_domain(domains, application_id)
except Exception as e:
raise AnsibleError(
f"lookup('container_hostname'): failed to resolve domain for "
f"'{application_id}': {e}"
) from e
name = str(domain or "")
if 0 < len(name) <= _HOST_NAME_MAX:
return [name]
return [get_entity_name(application_id)]
+1 -1
View File
@@ -5,7 +5,7 @@ entity_name: "{{ application_id | get_entity_name }}"
# Container
container_port: 8000
container_hostname: "{{ lookup('domain',application_id) }}"
container_hostname: "{{ lookup('container_hostname', application_id) }}"
# BookWyrm
+1 -1
View File
@@ -6,7 +6,7 @@ application_id: "web-app-bridgy-fed"
container_port: 8080
domain: "{{ container_hostname }}"
http_port: "{{ lookup('config', application_id, 'services.bridgy-fed.ports.local.http') }}"
container_hostname: "{{ lookup('domain',application_id) }}"
container_hostname: "{{ lookup('container_hostname', application_id) }}"
# App container
BRIDGY_CONTAINER: "{{ lookup('config', application_id, 'services.application.name') }}"
+1 -1
View File
@@ -5,7 +5,7 @@ entity_name: "{{ application_id | get_entity_name }}"
# Container
container_port: 8090
container_hostname: "{{ lookup('domain',application_id) }}"
container_hostname: "{{ lookup('container_hostname', application_id) }}"
# Confluence
+1 -1
View File
@@ -5,7 +5,7 @@ entity_name: "{{ application_id | get_entity_name }}"
# Container
container_port: 8080 # Standardport Jira
container_hostname: "{{ lookup('domain',application_id) }}"
container_hostname: "{{ lookup('container_hostname', application_id) }}"
# Jira
+1 -1
View File
@@ -6,7 +6,7 @@ application_id: "web-app-postmarks"
container_port: 3000
domain: "{{ container_hostname }}"
http_port: "{{ lookup('config', application_id, 'services.postmarks.ports.local.http') }}"
container_hostname: "{{ lookup('domain', application_id) }}"
container_hostname: "{{ lookup('container_hostname', application_id) }}"
# App container
POSTMARKS_CONTAINER: "{{ lookup('config', application_id, 'services.application.name') }}"
+1 -1
View File
@@ -2,7 +2,7 @@
# General
application_id: "web-app-pretix"
container_port: 80
container_hostname: "{{ lookup('domain',application_id) }}"
container_hostname: "{{ lookup('container_hostname', application_id) }}"
# Pretix
+1 -1
View File
@@ -4,7 +4,7 @@ application_id: "web-app-xwiki"
domain: "{{ lookup('domain',application_id) }}"
container_port: 8080
container_hostname: "{{ domain }}"
container_hostname: "{{ lookup('container_hostname', application_id) }}"
# XWiki
@@ -0,0 +1,63 @@
import unittest
from unittest.mock import patch
from ansible.errors import AnsibleError
from plugins.lookup.container_hostname import LookupModule
class TestContainerHostnameLookup(unittest.TestCase):
def setUp(self):
self.lookup = LookupModule()
def _domains_from_vars(*, variables=None, **_kwargs):
return (variables or {}).get("domains", {})
self._patchers = [
patch(
"plugins.lookup.container_hostname.get_merged_domains",
side_effect=_domains_from_vars,
),
patch(
"plugins.lookup.container_hostname.get_domain",
side_effect=lambda domains, app: domains[app],
),
patch(
"plugins.lookup.container_hostname.get_entity_name",
side_effect=lambda app: app.rsplit("-", 1)[-1],
),
]
for p in self._patchers:
p.start()
self.addCleanup(lambda: [p.stop() for p in self._patchers])
def _run(self, app, domain):
return self.lookup.run([app], variables={"domains": {app: domain}})[0]
def test_short_domain_is_kept(self):
self.assertEqual(
self._run("web-app-xwiki", "wiki.example.org"), "wiki.example.org"
)
def test_long_onion_domain_falls_back_to_entity(self):
onion = "x.wiki." + "a" * 56 + ".onion"
self.assertEqual(self._run("web-app-xwiki", onion), "xwiki")
def test_63_char_domain_is_kept(self):
name = "a" * 63
self.assertEqual(self._run("web-app-xwiki", name), name)
def test_empty_domain_falls_back(self):
self.assertEqual(self._run("web-app-xwiki", ""), "xwiki")
def test_bad_arity_raises(self):
with self.assertRaises(AnsibleError):
self.lookup.run([], variables={})
def test_empty_app_raises(self):
with self.assertRaises(AnsibleError):
self.lookup.run([" "], variables={})
if __name__ == "__main__":
unittest.main()