mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-06 19:01:54 +00:00
With three swarm replicas the PHP session files on the shared NFS volume were read as empty under concurrent requests of one browser, so SuiteCRM answered 403 on /api/graphql and dropped the session. Sessions now live in Redis with session locking that waits like flock did; the phpredis version is pinned in services.yml with its own update source, and the engine lookup renders the save path. Refs #879 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
243 lines
7.9 KiB
Python
243 lines
7.9 KiB
Python
"""Lookup ``engine``: resolve a central/embedded engine connection for a consumer.
|
|
|
|
{{ lookup('engine', 'redis', application_id) }} # full dict
|
|
{{ lookup('engine', 'redis', application_id, 'url') }} # one field
|
|
{{ lookup('engine', 'redis', application_id, 'session_path') }} # phpredis session.save_path
|
|
|
|
Mirrors the ``database`` lookup but for the non-RDBMS engines registered in
|
|
:data:`utils.roles.applications.services.engines.ENGINES` (redis, memcached,
|
|
rabbitmq, elasticsearch, typesense, qdrant, unbound). When the consumer declares
|
|
``services.<engine>.shared: true`` the connection points at the central
|
|
``svc-db-*`` / ``svc-dns-*`` stack with the consumer's own provisioned credentials;
|
|
otherwise it points at the embedded sidecar inside the consumer's own stack.
|
|
|
|
See ``docs/architecture/central-engines.md``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from urllib.parse import quote
|
|
|
|
from ansible.errors import AnsibleError
|
|
from ansible.plugins.loader import lookup_loader
|
|
from ansible.plugins.lookup import LookupBase
|
|
|
|
from utils.manager.credential_key import OVERRIDE_SECTION
|
|
from utils.roles.applications.config import get
|
|
from utils.roles.applications.services.engines import (
|
|
ENGINES,
|
|
engine_service,
|
|
is_engine_enabled,
|
|
is_engine_shared,
|
|
)
|
|
from utils.roles.entity.name import get_entity_name
|
|
|
|
_FIELDS = (
|
|
"engine",
|
|
"enabled",
|
|
"shared",
|
|
"local",
|
|
"host",
|
|
"port",
|
|
"username",
|
|
"password",
|
|
"instance",
|
|
"prefix",
|
|
"url",
|
|
"container",
|
|
"session_path",
|
|
)
|
|
|
|
|
|
def redis_session_path(
|
|
host: str, port: int, prefix: str, username: str, password: str
|
|
) -> str:
|
|
"""Return the phpredis ``session.save_path`` of one consumer.
|
|
|
|
Args:
|
|
host: redis host the consumer reaches.
|
|
port: redis port.
|
|
prefix: consumer prefix; session keys become ``<prefix>:session:<id>``.
|
|
username: ACL user, sent only together with a password.
|
|
password: redis password; empty for an embedded sidecar.
|
|
"""
|
|
path = f"tcp://{host}:{port}?prefix={quote(prefix, safe='')}:session:"
|
|
if password:
|
|
path += (
|
|
f"&auth[user]={quote(username, safe='')}"
|
|
f"&auth[pass]={quote(password, safe='')}"
|
|
)
|
|
return path
|
|
|
|
|
|
def qdrant_consumer_key(applications: dict[str, Any], svc_id: str) -> str:
|
|
"""Return the engine-wide key a data-owning qdrant consumer presents.
|
|
|
|
Args:
|
|
applications: the resolved applications mapping.
|
|
svc_id: the qdrant service role id.
|
|
|
|
qdrant has no per-consumer principals, only two engine-wide keys, so a
|
|
consumer that owns data here holds the engine's own key. The read-only key
|
|
is deliberately never returned: it belongs to the MCP adapter, whose whole
|
|
read-only guarantee rests on being unable to present anything else.
|
|
"""
|
|
return str(
|
|
get(
|
|
applications,
|
|
svc_id,
|
|
f"{OVERRIDE_SECTION}.api_key",
|
|
strict=False,
|
|
default="",
|
|
skip_missing_app=True,
|
|
)
|
|
or ""
|
|
).strip()
|
|
|
|
|
|
class LookupModule(LookupBase):
|
|
def run(
|
|
self,
|
|
terms: list[Any],
|
|
variables: dict[str, Any] | None = None,
|
|
**kwargs: Any,
|
|
) -> list[Any]:
|
|
terms = terms or []
|
|
if len(terms) not in (2, 3):
|
|
raise AnsibleError(
|
|
"engine: requires engine_type, consumer_id [, want_path]"
|
|
)
|
|
engine = str(terms[0]).strip()
|
|
consumer_id = str(terms[1]).strip()
|
|
want = str(terms[2]).strip() if len(terms) == 3 else "all"
|
|
if engine not in ENGINES:
|
|
raise AnsibleError(
|
|
f"engine: unknown engine '{engine}'; known: {', '.join(ENGINES)}"
|
|
)
|
|
if not consumer_id:
|
|
raise AnsibleError("engine: consumer_id must not be empty")
|
|
|
|
vars_ = variables or self._templar.available_variables
|
|
applications = lookup_loader.get(
|
|
"applications", loader=self._loader, templar=getattr(self, "_templar", None)
|
|
).run([], variables=vars_)[0]
|
|
|
|
descriptor = ENGINES[engine]
|
|
svc_id = descriptor["svc"]
|
|
port = int(descriptor["port"])
|
|
entity = get_entity_name(consumer_id)
|
|
|
|
enabled = is_engine_enabled(applications, consumer_id, engine)
|
|
shared = is_engine_shared(applications, consumer_id, engine)
|
|
svc_cfg = engine_service(applications, consumer_id, engine)
|
|
|
|
central_name = str(
|
|
get(
|
|
applications,
|
|
svc_id,
|
|
f"services.{engine}.name",
|
|
strict=False,
|
|
default=engine,
|
|
skip_missing_app=True,
|
|
)
|
|
or engine
|
|
).strip()
|
|
|
|
username = entity
|
|
prefix = entity
|
|
password = str(
|
|
svc_cfg.get("password")
|
|
or get(
|
|
applications,
|
|
consumer_id,
|
|
f"{OVERRIDE_SECTION}.{engine}_password",
|
|
strict=False,
|
|
default="",
|
|
)
|
|
or ""
|
|
).strip()
|
|
host = central_name if shared else engine
|
|
instance = central_name if shared else entity
|
|
|
|
# Exception: shared redis authenticates as the central `default` user, not
|
|
# a per-consumer ACL user — ACL users live only in memory and are lost when
|
|
# a deploy recreates the central redis after the consumer was provisioned.
|
|
if engine == "redis" and shared:
|
|
username = "default"
|
|
password = str(
|
|
get(
|
|
applications,
|
|
svc_id,
|
|
f"{OVERRIDE_SECTION}.REDIS_PASSWORD",
|
|
strict=False,
|
|
default="",
|
|
skip_missing_app=True,
|
|
)
|
|
or ""
|
|
).strip()
|
|
|
|
if engine == "qdrant" and shared:
|
|
password = qdrant_consumer_key(applications, svc_id)
|
|
|
|
if engine == "rabbitmq":
|
|
url = (
|
|
f"amqp://{username}:{password}@{host}:{port}/{entity}"
|
|
if shared
|
|
else f"amqp://{host}:{port}/"
|
|
)
|
|
elif engine in ("redis", "memcached"):
|
|
scheme = "redis" if engine == "redis" else "memcached"
|
|
auth = f"{username}:{password}@" if (shared and password) else ""
|
|
url = f"{scheme}://{auth}{host}:{port}"
|
|
elif engine in ("elasticsearch", "typesense", "qdrant"):
|
|
url = f"http://{host}:{port}"
|
|
else:
|
|
url = host
|
|
|
|
consumer_name = str(
|
|
get(
|
|
applications,
|
|
consumer_id,
|
|
f"services.{engine}.name",
|
|
strict=False,
|
|
default="",
|
|
skip_missing_app=True,
|
|
)
|
|
or ""
|
|
).strip()
|
|
container = central_name if shared else (consumer_name or f"{entity}-{engine}")
|
|
|
|
resolved = {
|
|
"engine": engine,
|
|
"enabled": enabled,
|
|
"shared": shared,
|
|
"local": bool(enabled and not shared),
|
|
"host": host,
|
|
"port": port,
|
|
"username": username if shared else "",
|
|
"password": password if shared else "",
|
|
"instance": instance,
|
|
"prefix": prefix,
|
|
"url": url,
|
|
"container": container,
|
|
"session_path": (
|
|
redis_session_path(
|
|
host,
|
|
port,
|
|
prefix,
|
|
username if shared else "",
|
|
password if shared else "",
|
|
)
|
|
if engine == "redis"
|
|
else ""
|
|
),
|
|
}
|
|
if want == "all":
|
|
return [resolved]
|
|
if want not in _FIELDS:
|
|
raise AnsibleError(
|
|
f"engine: unknown want '{want}'; known: {', '.join(_FIELDS)}"
|
|
)
|
|
return [resolved.get(want, "")]
|