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 %}
|
{% endif %}
|
||||||
logging:
|
logging:
|
||||||
driver: journald
|
driver: journald
|
||||||
|
{{ lookup('template', 'roles/docker-container/templates/resource.yml.j2') | indent(4) }}
|
||||||
{{ "\n" }}
|
{{ "\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) }}
|
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