feat(filters): add active_docker_container_count filter and use it for fair resource splits

Compute per-container CPU/RAM shares based on active services (web-/svc-*, enabled=true or undefined). Cast host facts to numbers, add safe min=1, and output compose-ready values. Include robust unit test.

Also: include resource.yml.j2 in base template and minor formatting tidy-up.

https://chatgpt.com/share/68d2d96c-9bf4-800f-bbec-d4f2c0051c06
This commit is contained in:
2025-09-23 21:35:12 +02:00
parent c523d8d8d4
commit ff7b7aeb2d
6 changed files with 284 additions and 2 deletions

View File

@@ -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,
}

View File

@@ -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

View File

@@ -7,5 +7,5 @@
{% endif %}
logging:
driver: journald
{{ lookup('template', 'roles/docker-container/templates/resource.yml.j2') | indent(4) }}
{{ "\n" }}

View File

@@ -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) }}

View File

@@ -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

View File

@@ -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()