Skip to content

Resource limits now also from services key; Added tests #1173

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
May 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 18 additions & 8 deletions lib/scenario_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -947,14 +947,24 @@ def setup_services(self):
if 'pause-after-phase' in service:
self.__services_to_pause_phase[service['pause-after-phase']] = self.__services_to_pause_phase.get(service['pause-after-phase'], []) + [container_name]

if 'deploy' in service:
if memory := service['deploy'].get('resources', {}).get('limits', {}).get('memory', None):
docker_run_string.append('--memory') # value in bytes
docker_run_string.append(str(memory))
if cpus := service['deploy'].get('resources', {}).get('limits', {}).get('cpus', None):
docker_run_string.append('--cpus') # value in cores
docker_run_string.append(str(cpus))

# wildly the docker compose spec allows deploy to be None ... thus we need to check and cannot .get()
if 'deploy' in service and service['deploy'] is not None and (memory := service['deploy'].get('resources', {}).get('limits', {}).get('memory', None)):
docker_run_string.append('--memory') # value in bytes
docker_run_string.append(str(memory))
print('Applying Memory Limit from deploy')
elif memory := service.get('mem_limit', None): # we only need to get resources or cpus. they must align anyway
docker_run_string.append('--memory')
docker_run_string.append(str(memory)) # value in bytes e.g. "10M"
print('Applying Memory Limit from services')

if 'deploy' in service and service['deploy'] is not None and (cpus := service['deploy'].get('resources', {}).get('limits', {}).get('cpus', None)):
docker_run_string.append('--cpus') # value in cores
docker_run_string.append(str(cpus))
print('Applying CPU Limit from deploy')
elif cpus := service.get('cpus', None): # we only need to get resources or cpus. they must align anyway
docker_run_string.append('--cpus')
docker_run_string.append(str(cpus)) # value in (fractional) cores
print('Applying CPU Limit from services')

if 'healthcheck' in service: # must come last
if 'disable' in service['healthcheck'] and service['healthcheck']['disable'] is True:
Expand Down
23 changes: 22 additions & 1 deletion lib/schema_checker.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,17 @@ def check_usage_scenario(self, usage_scenario):
Optional("networks"): self.single_or_list(Use(self.contains_no_invalid_chars)),
Optional("environment"): self.single_or_list(Or(dict,And(str, Use(self.not_empty)))),
Optional("ports"): self.single_or_list(Or(And(str, Use(self.not_empty)), int)),
Optional("depends_on"): Or([And(str, Use(self.not_empty))],dict),
Optional('depends_on'): Or([And(str, Use(self.not_empty))],dict),
Optional('deploy'):Or({
Optional('resources'): {
Optional('limits'): {
Optional('cpus'): Or(str, float, int),
Optional('memory') : str,
}
}
}, None),
Optional('mem_limit'): str,
Optional('cpus') : Or(str, float, int),
Optional('container_name'): And(str, Use(self.not_empty)),
Optional("healthcheck"): {
Optional('test'): Or(list, And(str, Use(self.not_empty))),
Expand Down Expand Up @@ -170,6 +180,8 @@ def check_usage_scenario(self, usage_scenario):
raise SchemaError(error_message) from e




# This check is necessary to do in a seperate pass. If tried to bake into the schema object above,
# it will not know how to handle the value passed when it could be either a dict or list
if 'networks' in usage_scenario:
Expand All @@ -191,6 +203,15 @@ def check_usage_scenario(self, usage_scenario):
if 'cmd' in service:
raise SchemaError(f"The 'cmd' key for service '{service_name}' is not supported anymore. Please migrate to 'command'")


if (cpus := service.get('cpus')) and (cpus_deploy := service.get('deploy', {}).get('resources', {}).get('limits', {}).get('cpus')):
if cpus != cpus_deploy:
raise SchemaError('cpus service top level key and deploy.resources.limits.cpus must be identical')

if (mem_limit := service.get('mem_limit')) and (mem_limit_deploy := service.get('deploy', {}).get('resources', {}).get('limits', {}).get('memory')):
if mem_limit != mem_limit_deploy:
raise SchemaError('mem_limit service top level key and deploy.resources.limits.memory must be identical')

known_flow_names = []
for flow in usage_scenario['flow']:
if flow['name'] in known_flow_names:
Expand Down
23 changes: 23 additions & 0 deletions tests/data/usage_scenarios/resource_limits_disalign_cpu.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
name: Test Stress
author: Dan Mateas
description: test

services:

test-container-memory-int:
type: container
image: alpine
deploy:
resources:
limits:
cpus: 2
cpus: 3

flow:
- name: Stress
container: test-container
commands:
- type: console
command: stress-ng -c 1 -t 1 -q
note: Starting Stress
23 changes: 23 additions & 0 deletions tests/data/usage_scenarios/resource_limits_disalign_memory.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
name: Test Stress
author: Dan Mateas
description: test

services:

test-container-memory-int:
type: container
image: alpine
deploy:
resources:
limits:
memory: "10M"
mem_limit: "100M"

flow:
- name: Stress
container: test-container
commands:
- type: console
command: stress-ng -c 1 -t 1 -q
note: Starting Stress
79 changes: 79 additions & 0 deletions tests/data/usage_scenarios/resource_limits_good.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
name: Test Stress
author: Dan Mateas
description: test

services:
test-container-only-cpu:
type: container
image: alpine
deploy:
resources:
limits:
cpus: "1.2"

test-container-only-memory:
type: container
image: alpine
deploy:
resources:
limits:
memory: "100MB"

test-container-both:
type: container
image: alpine
deploy:
resources:
limits:
cpus: "1.2"
memory: "10M"

test-container-cpu-float:
type: container
image: alpine
deploy:
resources:
limits:
cpus: 1.2
memory: "10M"

test-container-cpu-int:
type: container
image: alpine
deploy:
resources:
limits:
cpus: 1
memory: "10M"

test-container-limits-partial:
type: container
image: alpine
deploy: # allowed to be None

test-container-cpu-and-memory-in-both:
type: container
image: alpine
deploy:
resources:
limits:
cpus: 1
memory: "10M"
cpus: 1
mem_limit: "10M"

test-container-limit-only-service-level:
type: container
image: alpine
cpus: 1
mem_limit: "10M"


flow:
- name: Stress
container: test-container
commands:
- type: console
command: stress-ng -c 1 -t 1 -q
note: Starting Stress
22 changes: 22 additions & 0 deletions tests/data/usage_scenarios/resource_limits_memory_float.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
name: Test Stress
author: Dan Mateas
description: test

services:

test-container-memory-int:
type: container
image: alpine
deploy:
resources:
limits:
memory: 1.2

flow:
- name: Stress
container: test-container
commands:
- type: console
command: stress-ng -c 1 -t 1 -q
note: Starting Stress
23 changes: 23 additions & 0 deletions tests/data/usage_scenarios/resource_limits_memory_int.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
name: Test Stress
author: Dan Mateas
description: test

services:

test-container-memory-int:
type: container
image: alpine
deploy:
resources:
limits:
memory: 1


flow:
- name: Stress
container: test-container
commands:
- type: console
command: stress-ng -c 1 -t 1 -q
note: Starting Stress
91 changes: 91 additions & 0 deletions tests/test_resource_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
# https://docs.docker.com/engine/reference/commandline/port/
# List port mappings or a specific mapping for the container
# docker port CONTAINER [PRIVATE_PORT[/PROTO]]

import io
import os
import subprocess

GMT_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), '../')

from contextlib import redirect_stdout, redirect_stderr
import pytest

from tests import test_functions as Tests
from lib.scenario_runner import ScenarioRunner
from lib.schema_checker import SchemaError

## Note:
# Always do asserts after try:finally: blocks
# otherwise failing Tests will not run the runner.cleanup() properly


# This function runs the runner up to and *including* the specified step
#pylint: disable=redefined-argument-from-local
### The Tests for usage_scenario configurations

# environment: [object] (optional)
# Key-Value pairs for ENV variables inside the container

def get_env_vars():
ps = subprocess.run(
['docker', 'exec', 'test-container', '/bin/sh',
'-c', 'env'],
check=True,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
encoding='UTF-8'
)
env_var_output = ps.stdout
return env_var_output

def test_resource_limits_good():

runner = ScenarioRunner(uri=GMT_DIR, uri_type='folder', filename='tests/data/usage_scenarios/resource_limits_good.yml', skip_unsafe=False, skip_system_checks=True, dev_cache_build=True, dev_no_sleeps=True, dev_no_metrics=True, dev_no_phase_stats=True)

out = io.StringIO()
err = io.StringIO()

with redirect_stdout(out), redirect_stderr(err):
with Tests.RunUntilManager(runner) as context:
context.run_until('setup_services')

assert err.getvalue() == ''
assert 'Applying CPU Limit from deploy' in out.getvalue()
assert 'Applying CPU Limit from services' in out.getvalue()
assert 'Applying Memory Limit from deploy' in out.getvalue()
assert 'Applying Memory Limit from services' in out.getvalue()

def test_resource_limits_memory_int():
runner = ScenarioRunner(uri=GMT_DIR, uri_type='folder', filename='tests/data/usage_scenarios/resource_limits_memory_int.yml', skip_system_checks=True, dev_no_metrics=True, dev_no_phase_stats=True, dev_no_sleeps=True, dev_cache_build=True)
with pytest.raises(SchemaError) as e:
with Tests.RunUntilManager(runner) as context:
context.run_until('setup_services')

assert "Or({Optional('resources'): {Optional('limits'): {Optional('cpus'): Or(<class 'str'>, <class 'float'>, <class 'int'>), Optional('memory'): <class 'str'>}}}, None) did not validate {'resources': {'limits': {'memory': 1}}}" in str(e.value)
assert "1 should be instance of 'str'" in str(e.value)

def test_resource_limits_memory_float():
runner = ScenarioRunner(uri=GMT_DIR, uri_type='folder', filename='tests/data/usage_scenarios/resource_limits_memory_float.yml', skip_system_checks=True, dev_no_metrics=True, dev_no_phase_stats=True, dev_no_sleeps=True, dev_cache_build=True)
with pytest.raises(SchemaError) as e:
with Tests.RunUntilManager(runner) as context:
context.run_until('setup_services')

assert "1.2 should be instance of 'str'" in str(e.value)


def test_resource_limits_disalign_cpu():
runner = ScenarioRunner(uri=GMT_DIR, uri_type='folder', filename='tests/data/usage_scenarios/resource_limits_disalign_cpu.yml', skip_system_checks=True, dev_no_metrics=True, dev_no_phase_stats=True, dev_no_sleeps=True, dev_cache_build=True)
with pytest.raises(SchemaError) as e:
with Tests.RunUntilManager(runner) as context:
context.run_until('setup_services')

assert "cpus service top level key and deploy.resources.limits.cpus must be identical" in str(e.value)

def test_resource_limits_disalign_memory():
runner = ScenarioRunner(uri=GMT_DIR, uri_type='folder', filename='tests/data/usage_scenarios/resource_limits_disalign_memory.yml', skip_system_checks=True, dev_no_metrics=True, dev_no_phase_stats=True, dev_no_sleeps=True, dev_cache_build=True)
with pytest.raises(SchemaError) as e:
with Tests.RunUntilManager(runner) as context:
context.run_until('setup_services')

assert "mem_limit service top level key and deploy.resources.limits.memory must be identical" in str(e.value)