Refactor resource configuration:

- Introduce new resource_filter plugin (mandatory hard_default, auto entity_name fallback)
- Replace get_app_conf calls with resource_filter in resource.yml.j2
- Add WEBSERVER_CPUS_EFFECTIVE, WEBSERVER_WORKER_PROCESSES, WEBSERVER_WORKER_CONNECTIONS to 05_webserver.yml
- Update Nginx templates (sys-svc-webserver, web-app-magento, web-app-nextcloud) to use new vars
- Extend svc-prx-openresty config with cpus/mem limits
- Add unit tests for resource_filter

Details: https://chatgpt.com/share/68d3a493-9a5c-800f-8cd2-bd2e7a3e3fda
This commit is contained in:
2025-09-24 09:58:30 +02:00
parent 9bf77e1e35
commit 9ba0efc1a1
8 changed files with 180 additions and 11 deletions

View File

@@ -0,0 +1,45 @@
# filter_plugins/resource_filter.py
from __future__ import annotations
import sys, os
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from module_utils.config_utils import get_app_conf, AppConfigKeyError, ConfigEntryNotSetError # noqa: F401
from module_utils.entity_name_utils import get_entity_name
from ansible.errors import AnsibleFilterError
def resource_filter(
applications: dict,
application_id: str,
key: str,
service_name: str,
hard_default,
):
"""
Lookup order:
1) docker.services.<service_name or get_entity_name(application_id)>.<key>
2) hard_default (mandatory)
- service_name may be "" → will resolve to get_entity_name(application_id).
- hard_default is mandatory (no implicit None).
- required=False always.
"""
try:
primary_service = service_name if service_name != "" else get_entity_name(application_id)
val = get_app_conf(applications, f"docker.services.{primary_service}.{key}", False, None)
if val is not None:
return val
return hard_default
except (AppConfigKeyError, ConfigEntryNotSetError) as e:
raise AnsibleFilterError(str(e))
class FilterModule(object):
def filters(self):
return {
"resource_filter": resource_filter,
}

View File

@@ -29,4 +29,31 @@ NGINX:
IMAGE: "/tmp/cache_nginx_image/" # Directory which nginx uses to cache images IMAGE: "/tmp/cache_nginx_image/" # Directory which nginx uses to cache images
USER: "http" # Default nginx user in ArchLinux USER: "http" # Default nginx user in ArchLinux
# Effective CPUs (float) across proxy and the current app
WEBSERVER_CPUS_EFFECTIVE: >-
{{
[
(applications | resource_filter('svc-prx-openresty', 'cpus', service_name | default(''), RESOURCE_CPUS)) | float,
(applications | resource_filter(application_id, 'cpus', service_name | default(''), RESOURCE_CPUS)) | float
] | min
}}
# Nginx requires an integer for worker_processes:
# - if cpus < 1 → 1
# - else → floor to int
WEBSERVER_WORKER_PROCESSES: >-
{{
1 if WEBSERVER_CPUS_EFFECTIVE < 1
else (WEBSERVER_CPUS_EFFECTIVE | int)
}}
# worker_connections from pids_limit (use the smaller one), with correct key/defaults
WEBSERVER_WORKER_CONNECTIONS: >-
{{
[
(applications | resource_filter('svc-prx-openresty', 'pids_limit', service_name | default(''), RESOURCE_PIDS_LIMIT)) | int,
(applications | resource_filter(application_id, 'pids_limit', service_name | default(''), RESOURCE_PIDS_LIMIT)) | int
] | min
}}
# @todo It propably makes sense to distinguish between target and source mount path, so that the config files can be stored in the openresty volumes folder # @todo It propably makes sense to distinguish between target and source mount path, so that the config files can be stored in the openresty volumes folder

View File

@@ -1,4 +1,4 @@
cpus: {{ applications | get_app_conf( application_id, [ 'docker', 'services', service_name | default(application_id | get_entity_name ), 'cpus' ] |join('.'), False, RESOURCE_CPUS) }} cpus: {{ applications | resource_filter(application_id, 'cpus', service_name | default(''), RESOURCE_CPUS) }}
mem_reservation: {{ applications | get_app_conf( application_id, [ 'docker', 'services', service_name | default(application_id | get_entity_name ), 'mem_reservation' ] |join('.'), False, RESOURCE_MEM_RESERVATION) }} mem_reservation: {{ applications | resource_filter(application_id, 'mem_reservation', service_name | default(''), RESOURCE_MEM_RESERVATION) }}
mem_limit: {{ applications | get_app_conf( application_id, [ 'docker', 'services', service_name | default(application_id | get_entity_name ), 'mem_limit' ] |join('.'), False, RESOURCE_MEM_LIMIT) }} mem_limit: {{ applications | resource_filter(application_id, 'mem_limit', service_name | default(''), RESOURCE_MEM_LIMIT) }}
pids_limit: {{ applications | get_app_conf( application_id, [ 'docker', 'services', service_name | default(application_id | get_entity_name ), 'pids_limit' ] |join('.'), False, RESOURCE_PIDS_LIMIT) }} pids_limit: {{ applications | resource_filter(application_id, 'pids_limit', service_name | default(''), RESOURCE_PIDS_LIMIT) }}

View File

@@ -2,6 +2,9 @@ docker:
services: services:
openresty: openresty:
name: "openresty" name: "openresty"
cpus: 0.5
mem_reservation: 1g
mem_limit: 2g
volumes: volumes:
www: "/var/www/" www: "/var/www/"
nginx: "/etc/nginx/" nginx: "/etc/nginx/"

View File

@@ -1,8 +1,8 @@
worker_processes auto; worker_processes {{ WEBSERVER_WORKER_PROCESSES }};
events events
{ {
worker_connections 1024; worker_connections {{ WEBSERVER_WORKER_CONNECTIONS }};
} }
http http

View File

@@ -1,6 +1,6 @@
worker_processes auto; worker_processes {{ WEBSERVER_WORKER_PROCESSES }};
events { worker_connections 1024; } events { worker_connections {{ WEBSERVER_WORKER_CONNECTIONS }}; }
http { http {
include /etc/nginx/mime.types; include /etc/nginx/mime.types;

View File

@@ -2,7 +2,7 @@
# Verify time by time, that this rules are valid: # Verify time by time, that this rules are valid:
# https://docs.nextcloud.com/server/latest/admin_manual/installation/nginx.html # https://docs.nextcloud.com/server/latest/admin_manual/installation/nginx.html
worker_processes auto; worker_processes {{ WEBSERVER_WORKER_PROCESSES }};
# @see https://chatgpt.com/share/67aa3ce9-eea0-800f-85e8-ac54a3810b13 # @see https://chatgpt.com/share/67aa3ce9-eea0-800f-85e8-ac54a3810b13
error_log /proc/self/fd/2 {% if MODE_DEBUG | bool %}debug{% else %}warn{% endif %}; error_log /proc/self/fd/2 {% if MODE_DEBUG | bool %}debug{% else %}warn{% endif %};
@@ -10,7 +10,7 @@ pid /var/run/nginx.pid;
events { events {
worker_connections 1024; worker_connections {{ WEBSERVER_WORKER_CONNECTIONS }};
} }

View File

@@ -0,0 +1,94 @@
# tests/unit/filter_plugins/test_resource_filter.py
import unittest
from unittest.mock import patch
import importlib
plugin_module = importlib.import_module("filter_plugins.resource_filter")
class TestResourceFilter(unittest.TestCase):
def setUp(self):
importlib.reload(plugin_module)
self.applications = {"some": "dict"}
self.application_id = "web-app-foo"
self.key = "cpus"
# Patch get_app_conf und get_entity_name im Plugin
self.patcher_conf = patch.object(plugin_module, "get_app_conf")
self.patcher_entity = patch.object(plugin_module, "get_entity_name")
self.mock_get_app_conf = self.patcher_conf.start()
self.mock_get_entity_name = self.patcher_entity.start()
self.mock_get_entity_name.return_value = "foo" # abgeleiteter Service-Name
def tearDown(self):
self.patcher_conf.stop()
self.patcher_entity.stop()
def test_primary_service_value_found(self):
# Primary liefert direkt einen Wert
self.mock_get_app_conf.return_value = "0.75"
result = plugin_module.resource_filter(
self.applications,
self.application_id,
self.key,
service_name="openresty",
hard_default="0.5",
)
self.assertEqual(result, "0.75")
self.mock_get_app_conf.assert_called_once_with(
self.applications, "docker.services.openresty.cpus", False, None
)
def test_service_name_empty_uses_get_entity_name(self):
# service_name == "" → get_entity_name(application_id) -> "foo"
self.mock_get_app_conf.return_value = "1.0"
result = plugin_module.resource_filter(
self.applications,
self.application_id,
self.key,
service_name="",
hard_default="0.5",
)
self.assertEqual(result, "1.0")
self.mock_get_entity_name.assert_called_once_with(self.application_id)
self.mock_get_app_conf.assert_called_once_with(
self.applications, "docker.services.foo.cpus", False, None
)
def test_returns_hard_default_when_missing(self):
# Kein Wert im primary → verwende hard_default
self.mock_get_app_conf.return_value = None
result = plugin_module.resource_filter(
self.applications,
self.application_id,
key="mem_limit",
service_name="openresty",
hard_default="2g",
)
self.assertEqual(result, "2g")
self.mock_get_app_conf.assert_called_once_with(
self.applications, "docker.services.openresty.mem_limit", False, None
)
def test_raises_ansible_filter_error_on_config_errors(self):
self.mock_get_app_conf.side_effect = plugin_module.AppConfigKeyError("bad path")
with self.assertRaises(plugin_module.AnsibleFilterError):
plugin_module.resource_filter(
self.applications,
self.application_id,
key="pids_limit",
service_name="openresty",
hard_default=2048,
)
if __name__ == "__main__":
unittest.main()