diff --git a/filter_plugins/active_docker.py b/filter_plugins/active_docker.py new file mode 100644 index 00000000..83dd2a36 --- /dev/null +++ b/filter_plugins/active_docker.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +""" +Ansible filter to count active docker services for current host. + +Active means: +- application key is in group_names +- application key matches prefix regex (default: ^(web-|svc-).* ) +- under applications[app]['docker']['services'] each service is counted if: + - 'enabled' is True, OR + - 'enabled' is missing/undefined (treated as active) + +Returns an integer. If ensure_min_one=True, returns at least 1. +""" + +import re +from typing import Any, Dict, Mapping, Iterable + + +def _is_mapping(x: Any) -> bool: + # be liberal: Mapping covers dict-like; fallback to dict check + try: + return isinstance(x, Mapping) + except Exception: + return isinstance(x, dict) + + +def active_docker_container_count(applications: Mapping[str, Any], + group_names: Iterable[str], + prefix_regex: str = r'^(web-|svc-).*', + ensure_min_one: bool = False) -> int: + if not _is_mapping(applications): + return 1 if ensure_min_one else 0 + + group_set = set(group_names or []) + try: + pattern = re.compile(prefix_regex) + except re.error: + pattern = re.compile(r'^(web-|svc-).*') # fallback + + count = 0 + + for app_key, app_val in applications.items(): + # host selection + name prefix + if app_key not in group_set: + continue + if not pattern.match(str(app_key)): + continue + + docker = app_val.get('docker') if _is_mapping(app_val) else None + services = docker.get('services') if _is_mapping(docker) else None + if not _is_mapping(services): + # sometimes roles define a single service name string; ignore + continue + + for _svc_name, svc_cfg in services.items(): + if not _is_mapping(svc_cfg): + # allow shorthand like: service: {} or image string -> counts as enabled + count += 1 + continue + enabled = svc_cfg.get('enabled', True) + if isinstance(enabled, bool): + if enabled: + count += 1 + else: + # non-bool enabled -> treat "truthy" as enabled + if bool(enabled): + count += 1 + + if ensure_min_one and count < 1: + return 1 + return count + + +class FilterModule(object): + def filters(self): + return { + # usage: {{ applications | active_docker_container_count(group_names) }} + 'active_docker_container_count': active_docker_container_count, + } diff --git a/group_vars/all/18_resource.yml b/group_vars/all/18_resource.yml new file mode 100644 index 00000000..71f4f609 --- /dev/null +++ b/group_vars/all/18_resource.yml @@ -0,0 +1,41 @@ +# Host resources +RESOURCE_HOST_CPUS: "{{ ansible_processor_vcpus | int }}" +RESOURCE_HOST_MEM: "{{ (ansible_memtotal_mb | int) // 1024 }}" + +# Reserve for OS +RESOURCE_HOST_RESERVE_CPU: 2 +RESOURCE_HOST_RESERVE_MEM: 4 + +# Available for apps +RESOURCE_AVAIL_CPUS: "{{ (RESOURCE_HOST_CPUS | int) - (RESOURCE_HOST_RESERVE_CPU | int) }}" +RESOURCE_AVAIL_MEM: "{{ (RESOURCE_HOST_MEM | int) - (RESOURCE_HOST_RESERVE_MEM | int) }}" + +# Count active docker services (only roles starting with web- or svc-; service counts if enabled==true OR enabled is undefined) +RESOURCE_ACTIVE_DOCKER_CONTAINER_COUNT: >- + {{ + applications + | active_docker_container_count(group_names, '^(web-|svc-).*', ensure_min_one=True) + }} + +# Per-container fair share (numbers!), later we append 'g' only for the string fields in compose +RESOURCE_CPUS_NUM: >- + {{ + ((RESOURCE_AVAIL_CPUS | float) / (RESOURCE_ACTIVE_DOCKER_CONTAINER_COUNT | float)) + | round(2) + }} +RESOURCE_MEM_RESERVATION_NUM: >- + {{ + (((RESOURCE_AVAIL_MEM | float) / (RESOURCE_ACTIVE_DOCKER_CONTAINER_COUNT | float)) * 0.7) + | round(1) + }} +RESOURCE_MEM_LIMIT_NUM: >- + {{ + (((RESOURCE_AVAIL_MEM | float) / (RESOURCE_ACTIVE_DOCKER_CONTAINER_COUNT | float)) * 1.0) + | round(1) + }} + +# Final strings with units for compose defaults (keep numbers above for math elsewhere if needed) +RESOURCE_CPUS: "{{ RESOURCE_CPUS_NUM }}" +RESOURCE_MEM_RESERVATION: "{{ RESOURCE_MEM_RESERVATION_NUM }}g" +RESOURCE_MEM_LIMIT: "{{ RESOURCE_MEM_LIMIT_NUM }}g" +RESOURCE_PIDS_LIMIT: 512 diff --git a/roles/docker-container/templates/base.yml.j2 b/roles/docker-container/templates/base.yml.j2 index f4747469..47263b2c 100644 --- a/roles/docker-container/templates/base.yml.j2 +++ b/roles/docker-container/templates/base.yml.j2 @@ -7,5 +7,5 @@ {% endif %} logging: driver: journald - + {{ lookup('template', 'roles/docker-container/templates/resource.yml.j2') | indent(4) }} {{ "\n" }} \ No newline at end of file diff --git a/roles/docker-container/templates/resource.yml.j2 b/roles/docker-container/templates/resource.yml.j2 new file mode 100644 index 00000000..e6b63a8f --- /dev/null +++ b/roles/docker-container/templates/resource.yml.j2 @@ -0,0 +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 diff --git a/templates/roles/web-app/config/main.yml.j2 b/templates/roles/web-app/config/main.yml.j2 index 624b06c6..08b1d1b0 100644 --- a/templates/roles/web-app/config/main.yml.j2 +++ b/templates/roles/web-app/config/main.yml.j2 @@ -18,7 +18,7 @@ docker: features: matomo: true # Enable Matomo Tracking css: true # Enable Global CSS Styling - desktop: true # Enable loading of app in iframe + desktop: true # Enable loading of app in iframe ldap: false # Enable LDAP Network central_database: false # Enable Central Database Network recaptcha: false # Enable ReCaptcha diff --git a/tests/unit/filter_plugins/test_active_docker.py b/tests/unit/filter_plugins/test_active_docker.py new file mode 100644 index 00000000..09ed4ae0 --- /dev/null +++ b/tests/unit/filter_plugins/test_active_docker.py @@ -0,0 +1,158 @@ +# tests/unit/filter_plugins/test_active_docker.py +import os +import sys +import unittest +from pathlib import Path + +# Ensure repository root is on sys.path so "filter_plugins" can be imported +ROOT = Path(__file__).resolve().parents[3] # .../tests/unit/filter_plugins -> repo root +sys.path.insert(0, str(ROOT)) + +from filter_plugins.active_docker import ( + active_docker_container_count, + FilterModule, +) + + +class TestActiveDockerFilter(unittest.TestCase): + def setUp(self): + # default group_names simulating current host membership + self.group_names = [ + "web-app-jira", + "web-app-confluence", + "svc-db-postgres", + "svc-ai-ollama", + "web-svc-cdn", + "unrelated-group", + ] + + # a representative applications structure + self.apps = { + # counted (prefix web-/svc- AND in group_names) + "web-app-jira": { + "docker": { + "services": { + "jira": {"enabled": True}, + "proxy": {}, # enabled undefined -> counts + "debug": {"enabled": False}, # should NOT count + } + } + }, + "web-app-confluence": { + "docker": { + "services": { + "confluence": {"enabled": True}, + } + } + }, + "svc-db-postgres": { + "docker": { + "services": { + "postgres": {"enabled": True}, + "backup": {"enabled": False}, # no + } + } + }, + "svc-ai-ollama": { + "docker": { + "services": { + # non-dict service cfg (string) -> should count as "enabled" + "ollama": "ghcr.io/ollama/ollama:latest", + } + } + }, + "web-svc-cdn": { + "docker": { + "services": { + # weird truthy value -> treated as enabled + "cdn": {"enabled": "yes"}, + } + } + }, + + # NOT counted: wrong prefix + "db-core-mariadb": { + "docker": {"services": {"mariadb": {"enabled": True}}} + }, + # NOT counted: not in group_names + "web-app-gitlab": { + "docker": {"services": {"gitlab": {"enabled": True}}} + }, + # NOT counted: missing docker/services + "web-app-empty": {}, + } + + def test_basic_count(self): + # Expected counted services: + # web-app-jira: jira(True) + proxy(undefined) = 2 + # web-app-confluence: confluence(True) = 1 + # svc-db-postgres: postgres(True) = 1 + # svc-ai-ollama: ollama(string) = 1 + # web-svc-cdn: cdn("yes") -> truthy = 1 + # Total = 6 + cnt = active_docker_container_count(self.apps, self.group_names) + self.assertEqual(cnt, 6) + + def test_filter_module_registration(self): + fm = FilterModule().filters() + self.assertIn("active_docker_container_count", fm) + cnt = fm["active_docker_container_count"](self.apps, self.group_names) + self.assertEqual(cnt, 6) + + def test_prefix_regex_override(self): + # Only count svc-* prefixed apps in group_names + cnt = active_docker_container_count( + self.apps, self.group_names, prefix_regex=r"^svc-.*" + ) + # svc-db-postgres (1) + svc-ai-ollama (1) = 2 + self.assertEqual(cnt, 2) + + def test_not_in_group_names_excluded(self): + # Add a matching app but omit from group_names → should not count + apps = dict(self.apps) + apps["web-app-pixelfed"] = {"docker": {"services": {"pix": {"enabled": True}}}} + cnt = active_docker_container_count(apps, self.group_names) + # stays 6 + self.assertEqual(cnt, 6) + + def test_missing_services_and_non_mapping(self): + # If applications is not a mapping, returns 0/1 based on ensure_min_one + self.assertEqual(active_docker_container_count(None, self.group_names), 0) + self.assertEqual( + active_docker_container_count(None, self.group_names, ensure_min_one=True), + 1, + ) + + # App with no docker/services should be ignored (already in fixture) + cnt = active_docker_container_count(self.apps, self.group_names) + self.assertEqual(cnt, 6) + + def test_enabled_false_excluded(self): + # Ensure explicit false is excluded + apps = dict(self.apps) + apps["web-app-jira"]["docker"]["services"]["only_false"] = {"enabled": False} + cnt = active_docker_container_count(apps, self.group_names) + self.assertEqual(cnt, 6) # unchanged + + def test_enabled_truthy_string_included(self): + # Already covered by web-svc-cdn ("yes"), but verify explicitly + apps = dict(self.apps) + apps["web-app-confluence"]["docker"]["services"]["extra"] = {"enabled": "true"} + cnt = active_docker_container_count(apps, self.group_names) + self.assertEqual(cnt, 7) + + def test_ensure_min_one(self): + # Construct inputs that produce zero + apps = { + "web-app-foo": {"docker": {"services": {"s": {"enabled": False}}}}, + } + cnt0 = active_docker_container_count(apps, ["web-app-foo"]) + cnt1 = active_docker_container_count( + apps, ["web-app-foo"], ensure_min_one=True + ) + self.assertEqual(cnt0, 0) + self.assertEqual(cnt1, 1) + + +if __name__ == "__main__": + unittest.main()