From 9ba0efc1a17a35402bc174c2aef605d6355e06b3 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 24 Sep 2025 09:58:30 +0200 Subject: [PATCH] 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 --- filter_plugins/resource_filter.py | 45 +++++++++ .../all/{05_nginx.yml => 05_webserver.yml} | 27 ++++++ .../templates/resource.yml.j2 | 8 +- roles/svc-prx-openresty/config/main.yml | 5 +- .../sys-svc-webserver/templates/nginx.conf.j2 | 4 +- roles/web-app-magento/templates/nginx.conf.j2 | 4 +- .../templates/nginx/docker.conf.j2 | 4 +- .../filter_plugins/test_resource_filter.py | 94 +++++++++++++++++++ 8 files changed, 180 insertions(+), 11 deletions(-) create mode 100644 filter_plugins/resource_filter.py rename group_vars/all/{05_nginx.yml => 05_webserver.yml} (68%) create mode 100644 tests/unit/filter_plugins/test_resource_filter.py diff --git a/filter_plugins/resource_filter.py b/filter_plugins/resource_filter.py new file mode 100644 index 00000000..5549b425 --- /dev/null +++ b/filter_plugins/resource_filter.py @@ -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.. + 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, + } diff --git a/group_vars/all/05_nginx.yml b/group_vars/all/05_webserver.yml similarity index 68% rename from group_vars/all/05_nginx.yml rename to group_vars/all/05_webserver.yml index cd235535..ae711fd0 100644 --- a/group_vars/all/05_nginx.yml +++ b/group_vars/all/05_webserver.yml @@ -29,4 +29,31 @@ NGINX: IMAGE: "/tmp/cache_nginx_image/" # Directory which nginx uses to cache images 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 diff --git a/roles/docker-container/templates/resource.yml.j2 b/roles/docker-container/templates/resource.yml.j2 index 40acdc9d..9f8ad3b7 100644 --- a/roles/docker-container/templates/resource.yml.j2 +++ b/roles/docker-container/templates/resource.yml.j2 @@ -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) }} -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_limit: {{ applications | get_app_conf( application_id, [ 'docker', 'services', service_name | default(application_id | get_entity_name ), 'mem_limit' ] |join('.'), False, 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) }} \ No newline at end of file +cpus: {{ applications | resource_filter(application_id, 'cpus', service_name | default(''), RESOURCE_CPUS) }} +mem_reservation: {{ applications | resource_filter(application_id, 'mem_reservation', service_name | default(''), RESOURCE_MEM_RESERVATION) }} +mem_limit: {{ applications | resource_filter(application_id, 'mem_limit', service_name | default(''), RESOURCE_MEM_LIMIT) }} +pids_limit: {{ applications | resource_filter(application_id, 'pids_limit', service_name | default(''), RESOURCE_PIDS_LIMIT) }} \ No newline at end of file diff --git a/roles/svc-prx-openresty/config/main.yml b/roles/svc-prx-openresty/config/main.yml index 15fb726c..882acb79 100644 --- a/roles/svc-prx-openresty/config/main.yml +++ b/roles/svc-prx-openresty/config/main.yml @@ -1,7 +1,10 @@ docker: services: openresty: - name: "openresty" + name: "openresty" + cpus: 0.5 + mem_reservation: 1g + mem_limit: 2g volumes: www: "/var/www/" nginx: "/etc/nginx/" \ No newline at end of file diff --git a/roles/sys-svc-webserver/templates/nginx.conf.j2 b/roles/sys-svc-webserver/templates/nginx.conf.j2 index 1a2626ba..fbe70b36 100644 --- a/roles/sys-svc-webserver/templates/nginx.conf.j2 +++ b/roles/sys-svc-webserver/templates/nginx.conf.j2 @@ -1,8 +1,8 @@ -worker_processes auto; +worker_processes {{ WEBSERVER_WORKER_PROCESSES }}; events { - worker_connections 1024; + worker_connections {{ WEBSERVER_WORKER_CONNECTIONS }}; } http diff --git a/roles/web-app-magento/templates/nginx.conf.j2 b/roles/web-app-magento/templates/nginx.conf.j2 index b1df2590..80ce79b7 100644 --- a/roles/web-app-magento/templates/nginx.conf.j2 +++ b/roles/web-app-magento/templates/nginx.conf.j2 @@ -1,6 +1,6 @@ -worker_processes auto; +worker_processes {{ WEBSERVER_WORKER_PROCESSES }}; -events { worker_connections 1024; } +events { worker_connections {{ WEBSERVER_WORKER_CONNECTIONS }}; } http { include /etc/nginx/mime.types; diff --git a/roles/web-app-nextcloud/templates/nginx/docker.conf.j2 b/roles/web-app-nextcloud/templates/nginx/docker.conf.j2 index 1677fb61..b630cc8a 100644 --- a/roles/web-app-nextcloud/templates/nginx/docker.conf.j2 +++ b/roles/web-app-nextcloud/templates/nginx/docker.conf.j2 @@ -2,7 +2,7 @@ # Verify time by time, that this rules are valid: # 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 error_log /proc/self/fd/2 {% if MODE_DEBUG | bool %}debug{% else %}warn{% endif %}; @@ -10,7 +10,7 @@ pid /var/run/nginx.pid; events { - worker_connections 1024; + worker_connections {{ WEBSERVER_WORKER_CONNECTIONS }}; } diff --git a/tests/unit/filter_plugins/test_resource_filter.py b/tests/unit/filter_plugins/test_resource_filter.py new file mode 100644 index 00000000..8791718c --- /dev/null +++ b/tests/unit/filter_plugins/test_resource_filter.py @@ -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()