mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-09-24 11:06:24 +02:00
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:
79
filter_plugins/active_docker.py
Normal file
79
filter_plugins/active_docker.py
Normal 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,
|
||||
}
|
41
group_vars/all/18_resource.yml
Normal file
41
group_vars/all/18_resource.yml
Normal 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
|
@@ -7,5 +7,5 @@
|
||||
{% endif %}
|
||||
logging:
|
||||
driver: journald
|
||||
|
||||
{{ lookup('template', 'roles/docker-container/templates/resource.yml.j2') | indent(4) }}
|
||||
{{ "\n" }}
|
4
roles/docker-container/templates/resource.yml.j2
Normal file
4
roles/docker-container/templates/resource.yml.j2
Normal 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) }}
|
@@ -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
|
||||
|
158
tests/unit/filter_plugins/test_active_docker.py
Normal file
158
tests/unit/filter_plugins/test_active_docker.py
Normal 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()
|
Reference in New Issue
Block a user