Files
cleveragents-core/features/steps/container_lifecycle_steps.py
freemo 9c4655fd5c feat(server): container/devcontainer support lifecycle
Implement server-side container and devcontainer lifecycle management
for the CleverAgents server infrastructure.

New modules in src/cleveragents/infrastructure/server/containers/:
- DevcontainerSpec: Pydantic model for devcontainer.json parsing and
  validation (image, build, features, mounts, env vars, ports,
  hostRequirements, postCreateCommand, remoteUser, workspaceFolder).
- ResourceLimits: CPU, memory, storage, and PID limits model with
  Docker CLI argument generation and hostRequirements conversion.
- ContainerManager: Server-side lifecycle orchestration with
  create/start/stop/destroy operations using pluggable Docker CLI
  runner (mocked for tests).
- HealthMonitor: Periodic container health probing via docker
  inspect with background thread management and probe history.
- ContainerStatus enum: pending/created/running/stopped/destroyed/error
  with validated state transitions.

Tests:
- Behave BDD: 67 scenarios covering spec parsing, resource limits,
  lifecycle operations, health monitoring, and error handling
  (features/container_lifecycle.feature).
- Robot Framework: 13 integration test cases with mock Docker
  runner (robot/container_lifecycle_server.robot).

ISSUES CLOSED: #865
2026-03-23 22:50:51 +00:00

930 lines
31 KiB
Python

"""Step definitions for container lifecycle management (issue #865).
Covers: devcontainer.json parsing, resource limits, ContainerManager
lifecycle (create/start/stop/destroy), health monitoring, and status
transitions. All Docker interactions are mocked.
"""
from __future__ import annotations
import json
import os
import tempfile
from dataclasses import dataclass, field
from behave import given, then, when
from behave.runner import Context
from pydantic import ValidationError
from cleveragents.infrastructure.server.containers.container_manager import (
ContainerManager,
ContainerStatus,
ManagedContainer,
_validate_status_transition,
)
from cleveragents.infrastructure.server.containers.devcontainer_spec import (
DevcontainerSpec,
parse_devcontainer_json,
parse_devcontainer_json_string,
)
from cleveragents.infrastructure.server.containers.health_monitor import (
HealthMonitor,
HealthProbeResult,
)
from cleveragents.infrastructure.server.containers.resource_limits import (
ResourceLimits,
_parse_memory_string,
resource_limits_from_host_requirements,
)
# ── Mock runner ──────────────────────────────────────────────
@dataclass
class MockContainerResult:
"""Minimal subprocess.CompletedProcess stand-in."""
returncode: int = 0
stdout: str = ""
stderr: str = ""
args: list[str] = field(default_factory=list)
class MockContainerRunner:
"""Configurable mock for subprocess.run in container operations."""
def __init__(self) -> None:
self.calls: list[tuple[list[str], dict[str, object]]] = []
self.up_result: MockContainerResult = MockContainerResult()
self.start_result: MockContainerResult = MockContainerResult()
self.stop_result: MockContainerResult = MockContainerResult()
self.rm_result: MockContainerResult = MockContainerResult()
self.inspect_result: MockContainerResult = MockContainerResult()
def __call__(self, args: list[str], **kwargs: object) -> MockContainerResult:
self.calls.append((list(args), dict(kwargs)))
if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "up":
return self.up_result
if len(args) >= 2 and args[0] == "docker" and args[1] == "start":
return self.start_result
if len(args) >= 2 and args[0] == "docker" and args[1] == "stop":
return self.stop_result
if len(args) >= 2 and args[0] == "docker" and args[1] == "rm":
return self.rm_result
if len(args) >= 2 and args[0] == "docker" and args[1] == "inspect":
return self.inspect_result
return MockContainerResult(returncode=0)
def set_up_success(
self,
container_id: str = "aabbccddee0011223344",
) -> None:
output = json.dumps(
{
"outcome": "success",
"containerId": container_id,
"remoteWorkspaceFolder": "/workspaces/project",
}
)
self.up_result = MockContainerResult(returncode=0, stdout=output)
def set_up_failure(self) -> None:
self.up_result = MockContainerResult(
returncode=1, stderr="container build failed"
)
def set_start_failure(self) -> None:
self.start_result = MockContainerResult(returncode=1, stderr="start failed")
def set_stop_failure(self) -> None:
self.stop_result = MockContainerResult(returncode=1, stderr="stop failed")
def set_inspect_healthy(self) -> None:
self.inspect_result = MockContainerResult(returncode=0, stdout="true")
def set_inspect_unhealthy(self) -> None:
self.inspect_result = MockContainerResult(returncode=0, stdout="false")
# ── DevcontainerSpec parsing steps ───────────────────────────
@given('a devcontainer.json string with image "{image}"')
def step_spec_string_with_image(context: Context, image: str) -> None:
context.spec_json = json.dumps({"image": image})
@given('a devcontainer.json string with build dockerfile "{dockerfile}"')
def step_spec_string_with_build(context: Context, dockerfile: str) -> None:
context.spec_json = json.dumps(
{"build": {"dockerfile": dockerfile, "context": "."}}
)
@given("a devcontainer.json string with features")
def step_spec_string_with_features(context: Context) -> None:
context.spec_json = json.dumps(
{"features": {"ghcr.io/devcontainers/features/python:1": {}}}
)
@given("a devcontainer.json string with mounts")
def step_spec_string_with_mounts(context: Context) -> None:
context.spec_json = json.dumps(
{
"mounts": [
{
"source": "/host/data",
"target": "/container/data",
"type": "bind",
}
]
}
)
@given("a devcontainer.json string with container environment variables")
def step_spec_string_with_env(context: Context) -> None:
context.spec_json = json.dumps({"containerEnv": {"MY_VAR": "hello"}})
@given("a devcontainer.json string with forwarded ports {port1:d} and {port2:d}")
def step_spec_string_with_ports(context: Context, port1: int, port2: int) -> None:
context.spec_json = json.dumps({"forwardPorts": [port1, port2]})
@given(
'a devcontainer.json string with host requirements cpus {cpus:d} and memory "{memory}"'
)
def step_spec_string_with_host_reqs(context: Context, cpus: int, memory: str) -> None:
context.spec_json = json.dumps(
{"hostRequirements": {"cpus": cpus, "memory": memory}}
)
@given('a temporary devcontainer.json file with image "{image}"')
def step_spec_file_with_image(context: Context, image: str) -> None:
with tempfile.NamedTemporaryFile(
mode="w",
suffix=".json",
delete=False,
prefix="devcontainer_",
) as tmp:
json.dump({"image": image}, tmp)
context.spec_file_path = tmp.name
@given('a devcontainer.json string with string mount "{mount_str}"')
def step_spec_string_with_string_mount(context: Context, mount_str: str) -> None:
context.spec_json = json.dumps({"mounts": [mount_str]})
@given('a devcontainer.json string with postCreateCommand "{cmd}"')
def step_spec_string_with_post_create(context: Context, cmd: str) -> None:
context.spec_json = json.dumps({"postCreateCommand": cmd})
@given('a devcontainer.json string with remoteUser "{user}"')
def step_spec_string_with_remote_user(context: Context, user: str) -> None:
context.spec_json = json.dumps({"remoteUser": user})
@given('a devcontainer.json string with workspaceFolder "{folder}"')
def step_spec_string_with_workspace(context: Context, folder: str) -> None:
context.spec_json = json.dumps({"workspaceFolder": folder})
@when("I parse the devcontainer spec string")
def step_parse_spec_string(context: Context) -> None:
context.spec = parse_devcontainer_json_string(context.spec_json)
@when("I parse the devcontainer spec file")
def step_parse_spec_file(context: Context) -> None:
context.spec = parse_devcontainer_json(context.spec_file_path)
os.unlink(context.spec_file_path)
@when("I attempt to parse devcontainer spec from empty path")
def step_parse_empty_path(context: Context) -> None:
try:
parse_devcontainer_json("")
context.raised = None
except ValueError as exc:
context.raised = exc
@when('I attempt to parse devcontainer spec from relative path "{path}"')
def step_parse_relative_path(context: Context, path: str) -> None:
try:
parse_devcontainer_json(path)
context.raised = None
except ValueError as exc:
context.raised = exc
@when('I attempt to parse devcontainer spec from missing file "{path}"')
def step_parse_missing_file(context: Context, path: str) -> None:
try:
parse_devcontainer_json(path)
context.raised = None
except FileNotFoundError as exc:
context.raised = exc
@when("I attempt to parse devcontainer spec from empty string")
def step_parse_empty_string(context: Context) -> None:
try:
parse_devcontainer_json_string("")
context.raised = None
except ValueError as exc:
context.raised = exc
@then('the spec image should be "{expected}"')
def step_spec_image(context: Context, expected: str) -> None:
assert context.spec.image == expected
@then("the spec should have image set")
def step_spec_has_image(context: Context) -> None:
assert context.spec.has_image()
@then("the spec should not have image set")
def step_spec_no_image(context: Context) -> None:
assert not context.spec.has_image()
@then("the spec should have build set")
def step_spec_has_build(context: Context) -> None:
assert context.spec.has_build()
@then("the spec should not have build set")
def step_spec_no_build(context: Context) -> None:
assert not context.spec.has_build()
@then('the build dockerfile should be "{expected}"')
def step_build_dockerfile(context: Context, expected: str) -> None:
assert context.spec.build is not None
assert context.spec.build.dockerfile == expected
@then("the spec should have features")
def step_spec_has_features(context: Context) -> None:
assert len(context.spec.features) > 0
@then('the feature "{feature}" should be present')
def step_feature_present(context: Context, feature: str) -> None:
assert feature in context.spec.features
@then("the spec should have {count:d} mount")
def step_spec_mount_count(context: Context, count: int) -> None:
assert len(context.spec.mounts) == count
@then('mount {index:d} source should be "{expected}"')
def step_mount_source(context: Context, index: int, expected: str) -> None:
assert context.spec.mounts[index].source == expected
@then('mount {index:d} target should be "{expected}"')
def step_mount_target(context: Context, index: int, expected: str) -> None:
assert context.spec.mounts[index].target == expected
@then('the container env should contain "{key}" with value "{value}"')
def step_container_env(context: Context, key: str, value: str) -> None:
assert context.spec.container_env.get(key) == value
@then("the forwarded ports should contain {port:d}")
def step_forwarded_port(context: Context, port: int) -> None:
assert port in context.spec.forwarded_ports
@then("the resource limits cpu should be {cpu:g}")
def step_resource_limits_cpu(context: Context, cpu: float) -> None:
assert context.spec.resource_limits.cpu_limit == cpu
@then("the resource limits memory should be {memory:d}")
def step_resource_limits_memory(context: Context, memory: int) -> None:
assert context.spec.resource_limits.memory_limit_mb == memory
@then('the post create command should be "{expected}"')
def step_post_create_command(context: Context, expected: str) -> None:
assert context.spec.post_create_command == expected
@then('the remote user should be "{expected}"')
def step_remote_user(context: Context, expected: str) -> None:
assert context.spec.remote_user == expected
@then('the workspace folder should be "{expected}"')
def step_workspace_folder(context: Context, expected: str) -> None:
assert context.spec.workspace_folder == expected
# ── ResourceLimits steps ─────────────────────────────────────
@when("I create default resource limits")
def step_default_limits(context: Context) -> None:
context.limits = ResourceLimits()
@when("I create resource limits with cpu {cpu:g} and memory {memory:d}")
def step_custom_limits(context: Context, cpu: float, memory: int) -> None:
context.limits = ResourceLimits(cpu_limit=cpu, memory_limit_mb=memory)
@given('host requirements with cpus {cpus:d} and memory "{memory}"')
def step_host_requirements(context: Context, cpus: int, memory: str) -> None:
context.host_reqs = {"cpus": cpus, "memory": memory}
@when("I create resource limits from host requirements")
def step_limits_from_host_reqs(context: Context) -> None:
context.limits = resource_limits_from_host_requirements(context.host_reqs)
@then("the cpu limit should be {cpu:g}")
def step_check_cpu(context: Context, cpu: float) -> None:
assert context.limits.cpu_limit == cpu
@then("the memory limit should be {memory:d}")
def step_check_memory(context: Context, memory: int) -> None:
assert context.limits.memory_limit_mb == memory
@then('the docker args should contain "{arg}"')
def step_docker_args_contain(context: Context, arg: str) -> None:
args = context.limits.to_docker_args()
assert arg in args, f"Expected {arg!r} in {args}"
@when("I attempt to create resource limits with cpu {cpu:g}")
def step_invalid_cpu(context: Context, cpu: float) -> None:
try:
ResourceLimits(cpu_limit=cpu)
context.raised = None
except ValidationError as exc:
context.raised = exc
@when("I attempt to create resource limits with memory {memory:d}")
def step_invalid_memory(context: Context, memory: int) -> None:
try:
ResourceLimits(memory_limit_mb=memory)
context.raised = None
except ValidationError as exc:
context.raised = exc
@then("a container validation error should be raised")
def step_validation_error(context: Context) -> None:
assert context.raised is not None
assert isinstance(context.raised, (ValidationError, ValueError))
@when('I parse memory string "{value}"')
def step_parse_memory(context: Context, value: str) -> None:
context.parsed_memory = _parse_memory_string(value)
@then("the parsed memory should be {expected:d}")
def step_check_parsed_memory(context: Context, expected: int) -> None:
assert context.parsed_memory == expected
@when("I attempt to parse empty memory string")
def step_parse_empty_memory(context: Context) -> None:
try:
_parse_memory_string("")
context.raised = None
except ValueError as exc:
context.raised = exc
@when("I attempt to create resource limits from non-dict host requirements")
def step_limits_from_non_dict(context: Context) -> None:
try:
resource_limits_from_host_requirements("not a dict") # type: ignore[arg-type]
context.raised = None
except TypeError as exc:
context.raised = exc
@when("I create resource limits with pids limit {pids:d}")
def step_limits_with_pids(context: Context, pids: int) -> None:
context.limits = ResourceLimits(pids_limit=pids)
@when('I create resource limits from string values cpu "{cpu}" and memory "{memory}"')
def step_limits_from_strings(context: Context, cpu: str, memory: str) -> None:
context.limits = ResourceLimits(**{"cpu_limit": cpu, "memory_limit_mb": memory})
# ── Common error assertion steps ─────────────────────────────
@then('a container ValueError should be raised with "{message}"')
def step_value_error_with_msg(context: Context, message: str) -> None:
assert context.raised is not None, "Expected ValueError but none raised"
assert isinstance(context.raised, ValueError), (
f"Expected ValueError, got {type(context.raised).__name__}"
)
assert message in str(context.raised), f"Expected '{message}' in '{context.raised}'"
@then("a container FileNotFoundError should be raised")
def step_file_not_found(context: Context) -> None:
assert context.raised is not None
assert isinstance(context.raised, FileNotFoundError)
@then("a container TypeError should be raised")
def step_type_error(context: Context) -> None:
assert context.raised is not None
assert isinstance(context.raised, TypeError)
@then("a container RuntimeError should be raised")
def step_runtime_error(context: Context) -> None:
assert context.raised is not None
assert isinstance(context.raised, RuntimeError)
# ── ContainerManager steps ───────────────────────────────────
@given("a mock container command runner")
def step_mock_container_runner(context: Context) -> None:
context.container_runner = MockContainerRunner()
context.container_manager = ContainerManager(run_command=context.container_runner)
@given("the container runner configured for successful creation")
def step_runner_success(context: Context) -> None:
context.container_runner.set_up_success()
@given("the container runner configured for failed creation")
def step_runner_fail_create(context: Context) -> None:
context.container_runner.set_up_failure()
@given("the container runner configured for failed start")
def step_runner_fail_start(context: Context) -> None:
context.container_runner.set_start_failure()
@given("the container runner configured for failed stop")
def step_runner_fail_stop(context: Context) -> None:
context.container_runner.set_stop_failure()
@given('a created container "{name}" with container ID "{ctr_id}"')
def step_created_container(context: Context, name: str, ctr_id: str) -> None:
mgr = context.container_manager
container = ManagedContainer(
name=name,
status=ContainerStatus.CREATED,
container_id=ctr_id,
)
mgr._containers[name] = container
@given('a running container "{name}" with container ID "{ctr_id}"')
def step_running_container(context: Context, name: str, ctr_id: str) -> None:
mgr = context.container_manager
container = ManagedContainer(
name=name,
status=ContainerStatus.RUNNING,
container_id=ctr_id,
)
mgr._containers[name] = container
@given('a stopped managed container "{name}" with container ID "{ctr_id}"')
def step_stopped_container(context: Context, name: str, ctr_id: str) -> None:
mgr = context.container_manager
container = ManagedContainer(
name=name,
status=ContainerStatus.STOPPED,
container_id=ctr_id,
)
mgr._containers[name] = container
@given('an errored managed container "{name}" with container ID "{ctr_id}"')
def step_errored_managed_container(context: Context, name: str, ctr_id: str) -> None:
mgr = context.container_manager
container = ManagedContainer(
name=name,
status=ContainerStatus.ERROR,
container_id=ctr_id,
)
mgr._containers[name] = container
@given('an existing container "{name}" in the manager')
def step_existing_container(context: Context, name: str) -> None:
mgr = context.container_manager
container = ManagedContainer(
name=name,
status=ContainerStatus.PENDING,
)
mgr._containers[name] = container
@given("a devcontainer spec with cpu limit {cpu:g} and memory {memory:d}")
def step_spec_with_limits(context: Context, cpu: float, memory: int) -> None:
limits = ResourceLimits(cpu_limit=cpu, memory_limit_mb=memory)
context.test_spec = DevcontainerSpec(resource_limits=limits)
@when('I create container "{name}" with workspace "{workspace}"')
def step_create_container(context: Context, name: str, workspace: str) -> None:
context.container_result = context.container_manager.create(
name, workspace_folder=workspace
)
@when('I start container "{name}"')
def step_start_container(context: Context, name: str) -> None:
context.container_result = context.container_manager.start(name)
@when('I stop managed container "{name}"')
def step_stop_container(context: Context, name: str) -> None:
context.container_result = context.container_manager.stop(name)
@when('I destroy container "{name}"')
def step_destroy_container(context: Context, name: str) -> None:
context.container_result = context.container_manager.destroy(name)
@when("I attempt to create container with empty name")
def step_create_empty_name(context: Context) -> None:
try:
context.container_manager.create("")
context.raised = None
except ValueError as exc:
context.raised = exc
@when('I attempt to create duplicate container "{name}"')
def step_attempt_create_container(context: Context, name: str) -> None:
try:
context.container_manager.create(name)
context.raised = None
except ValueError as exc:
context.raised = exc
@when('I attempt to create container "{name}" with workspace "{workspace}"')
def step_attempt_create_with_ws(context: Context, name: str, workspace: str) -> None:
try:
context.container_manager.create(name, workspace_folder=workspace)
context.raised = None
except RuntimeError as exc:
context.raised = exc
@when('I attempt to start non-existent container "{name}"')
def step_start_nonexistent(context: Context, name: str) -> None:
try:
context.container_manager.start(name)
context.raised = None
except ValueError as exc:
context.raised = exc
@when('I attempt to stop managed container "{name}"')
def step_attempt_stop(context: Context, name: str) -> None:
try:
context.container_manager.stop(name)
context.raised = None
except (ValueError, RuntimeError) as exc:
context.raised = exc
@when('I attempt to destroy container "{name}"')
def step_attempt_destroy(context: Context, name: str) -> None:
try:
context.container_manager.destroy(name)
context.raised = None
except ValueError as exc:
context.raised = exc
@when('I attempt to start container "{name}"')
def step_attempt_start(context: Context, name: str) -> None:
try:
context.container_manager.start(name)
context.raised = None
except (ValueError, RuntimeError) as exc:
context.raised = exc
@when('I list containers with status "{status}"')
def step_list_by_status(context: Context, status: str) -> None:
context.container_list = context.container_manager.list_containers(
status=ContainerStatus(status)
)
@when("I list all containers")
def step_list_all(context: Context) -> None:
context.container_list = context.container_manager.list_containers()
@when('I get container "{name}"')
def step_get_container(context: Context, name: str) -> None:
context.get_result = context.container_manager.get_container(name)
@when("I attempt to get container with empty name")
def step_get_empty_name(context: Context) -> None:
try:
context.container_manager.get_container("")
context.raised = None
except ValueError as exc:
context.raised = exc
@when('I create container "{name}" with spec and limits')
def step_create_with_spec(context: Context, name: str) -> None:
context.container_result = context.container_manager.create(
name,
spec=context.test_spec,
workspace_folder="/workspace",
)
@then('the container "{name}" should have status "{status}"')
def step_container_status(context: Context, name: str, status: str) -> None:
container = context.container_manager.get_container(name)
assert container is not None, f"Container '{name}' not found"
assert container.status.value == status, (
f"Expected status '{status}', got '{container.status.value}'"
)
@then('the container "{name}" should have a container ID')
def step_container_has_id(context: Context, name: str) -> None:
container = context.container_manager.get_container(name)
assert container is not None
assert container.container_id != ""
@then("the list should contain {count:d} container")
def step_list_count_singular(context: Context, count: int) -> None:
assert len(context.container_list) == count
@then("the list should contain {count:d} containers")
def step_list_count_plural(context: Context, count: int) -> None:
assert len(context.container_list) == count
@then('the list should contain container "{name}"')
def step_list_contains(context: Context, name: str) -> None:
names = [c.name for c in context.container_list]
assert name in names, f"Expected '{name}' in {names}"
@then("the container result should be None")
def step_result_none(context: Context) -> None:
assert context.get_result is None
@then('the container "{name}" resource limits cpu should be {cpu:g}')
def step_container_limits_cpu(context: Context, name: str, cpu: float) -> None:
container = context.container_manager.get_container(name)
assert container is not None
assert container.resource_limits.cpu_limit == cpu
@then("the ContainerStatus enum should have {count:d} values")
def step_status_enum_count(context: Context, count: int) -> None:
assert len(ContainerStatus) == count
@then('ContainerStatus should include "{value}"')
def step_status_includes(context: Context, value: str) -> None:
values = {s.value for s in ContainerStatus}
assert value in values, f"Expected '{value}' in {values}"
@then('container transition from "{source}" to "{target}" should be valid')
def step_valid_transition(context: Context, source: str, target: str) -> None:
assert _validate_status_transition(ContainerStatus(source), ContainerStatus(target))
@then('container transition from "{source}" to "{target}" should be invalid')
def step_invalid_transition(context: Context, source: str, target: str) -> None:
assert not _validate_status_transition(
ContainerStatus(source), ContainerStatus(target)
)
# ── HealthMonitor steps ──────────────────────────────────────
@given("a health monitor with interval {interval:g}")
def step_health_monitor(context: Context, interval: float) -> None:
context.health_monitor = HealthMonitor(
context.container_manager,
interval=interval,
run_command=context.container_runner,
)
@given('a running monitored container "{name}" with container ID "{ctr_id}"')
def step_running_monitored(context: Context, name: str, ctr_id: str) -> None:
mgr = context.container_manager
container = ManagedContainer(
name=name,
status=ContainerStatus.RUNNING,
container_id=ctr_id,
)
mgr._containers[name] = container
@given('a running monitored container "{name}" without container ID')
def step_running_no_id(context: Context, name: str) -> None:
mgr = context.container_manager
container = ManagedContainer(
name=name,
status=ContainerStatus.RUNNING,
container_id="",
)
mgr._containers[name] = container
@given('a stopped monitored container "{name}"')
def step_stopped_monitored(context: Context, name: str) -> None:
mgr = context.container_manager
container = ManagedContainer(
name=name,
status=ContainerStatus.STOPPED,
)
mgr._containers[name] = container
@given("the container runner configured for healthy probe")
def step_runner_healthy_probe(context: Context) -> None:
context.container_runner.set_inspect_healthy()
@given("the container runner configured for unhealthy probe")
def step_runner_unhealthy_probe(context: Context) -> None:
context.container_runner.set_inspect_unhealthy()
@given('I start monitoring "{name}"')
def step_start_monitoring_given(context: Context, name: str) -> None:
context.health_monitor.start_monitoring(name)
@when('I probe container "{name}"')
def step_probe_container(context: Context, name: str) -> None:
context.probe_result = context.health_monitor.probe(name)
@when("I attempt to probe container with empty name")
def step_probe_empty_name(context: Context) -> None:
try:
context.health_monitor.probe("")
context.raised = None
except ValueError as exc:
context.raised = exc
@when('I attempt to start monitoring "{name}"')
def step_attempt_start_monitoring(context: Context, name: str) -> None:
try:
context.health_monitor.start_monitoring(name)
context.raised = None
except ValueError as exc:
context.raised = exc
@when("I attempt to create health monitor with interval {interval:g}")
def step_attempt_invalid_interval(context: Context, interval: float) -> None:
try:
HealthMonitor(
context.container_manager,
interval=interval,
run_command=context.container_runner,
)
context.raised = None
except ValueError as exc:
context.raised = exc
@when("I attempt to create health monitor with non-manager")
def step_attempt_non_manager(context: Context) -> None:
try:
HealthMonitor("not a manager") # type: ignore[arg-type]
context.raised = None
except TypeError as exc:
context.raised = exc
@when('I stop monitoring "{name}"')
def step_stop_monitoring(context: Context, name: str) -> None:
context.health_monitor.stop_monitoring(name)
@when("I stop all monitoring")
def step_stop_all(context: Context) -> None:
context.health_monitor.stop_all()
@then("the probe result should be healthy")
def step_probe_healthy(context: Context) -> None:
assert context.probe_result.healthy
@then("the probe result should be unhealthy")
def step_probe_unhealthy(context: Context) -> None:
assert not context.probe_result.healthy
@then('the probe message should contain "{message}"')
def step_probe_message(context: Context, message: str) -> None:
assert message in context.probe_result.message
@then("no container error should be raised")
def step_no_error(context: Context) -> None:
pass # If we got here, no error was raised
@then("no containers should be monitored")
def step_no_monitored(context: Context) -> None:
assert len(context.health_monitor.monitored_containers) == 0
@then("the probe history should have {count:d} entry")
def step_probe_history(context: Context, count: int) -> None:
assert len(context.health_monitor.probe_history) == count
@given('a health probe result for "{name}" that is healthy')
def step_health_probe_result(context: Context, name: str) -> None:
context.probe_result = HealthProbeResult(
container_name=name, healthy=True, message="ok"
)
@then('the probe repr should contain "{text}"')
def step_probe_repr(context: Context, text: str) -> None:
assert text in repr(context.probe_result)
@when("I attempt to create health probe with empty name")
def step_probe_empty_container_name(context: Context) -> None:
try:
HealthProbeResult(container_name="", healthy=True)
context.raised = None
except ValueError as exc:
context.raised = exc
@when("I attempt to validate transition with non-enum current")
def step_invalid_transition_current(context: Context) -> None:
try:
_validate_status_transition(
"not_an_enum",
ContainerStatus.RUNNING, # type: ignore[arg-type]
)
context.raised = None
except TypeError as exc:
context.raised = exc
@when("I attempt to validate transition with non-enum target")
def step_invalid_transition_target(context: Context) -> None:
try:
_validate_status_transition(
ContainerStatus.PENDING,
"not_an_enum", # type: ignore[arg-type]
)
context.raised = None
except TypeError as exc:
context.raised = exc