forked from cleveragents/cleveragents-core
9c4655fd5c
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
402 lines
18 KiB
Gherkin
402 lines
18 KiB
Gherkin
Feature: Container and Devcontainer Lifecycle Management
|
|
As a CleverAgents server operator
|
|
I want to manage container lifecycles (create/start/stop/destroy)
|
|
So that development containers are reliably orchestrated
|
|
|
|
# ── DevcontainerSpec parsing ──────────────────────────────
|
|
|
|
Scenario: Parse devcontainer.json with image
|
|
Given a devcontainer.json string with image "mcr.microsoft.com/devcontainers/python:3.13"
|
|
When I parse the devcontainer spec string
|
|
Then the spec image should be "mcr.microsoft.com/devcontainers/python:3.13"
|
|
And the spec should have image set
|
|
And the spec should not have build set
|
|
|
|
Scenario: Parse devcontainer.json with build config
|
|
Given a devcontainer.json string with build dockerfile "Dockerfile.dev"
|
|
When I parse the devcontainer spec string
|
|
Then the spec should have build set
|
|
And the build dockerfile should be "Dockerfile.dev"
|
|
And the spec should not have image set
|
|
|
|
Scenario: Parse devcontainer.json with features
|
|
Given a devcontainer.json string with features
|
|
When I parse the devcontainer spec string
|
|
Then the spec should have features
|
|
And the feature "ghcr.io/devcontainers/features/python:1" should be present
|
|
|
|
Scenario: Parse devcontainer.json with mounts
|
|
Given a devcontainer.json string with mounts
|
|
When I parse the devcontainer spec string
|
|
Then the spec should have 1 mount
|
|
And mount 0 source should be "/host/data"
|
|
And mount 0 target should be "/container/data"
|
|
|
|
Scenario: Parse devcontainer.json with environment variables
|
|
Given a devcontainer.json string with container environment variables
|
|
When I parse the devcontainer spec string
|
|
Then the container env should contain "MY_VAR" with value "hello"
|
|
|
|
Scenario: Parse devcontainer.json with forwarded ports
|
|
Given a devcontainer.json string with forwarded ports 8080 and 3000
|
|
When I parse the devcontainer spec string
|
|
Then the forwarded ports should contain 8080
|
|
And the forwarded ports should contain 3000
|
|
|
|
Scenario: Parse devcontainer.json with host requirements
|
|
Given a devcontainer.json string with host requirements cpus 4 and memory "8gb"
|
|
When I parse the devcontainer spec string
|
|
Then the resource limits cpu should be 4.0
|
|
And the resource limits memory should be 8192
|
|
|
|
Scenario: Parse devcontainer.json from file
|
|
Given a temporary devcontainer.json file with image "ubuntu:22.04"
|
|
When I parse the devcontainer spec file
|
|
Then the spec image should be "ubuntu:22.04"
|
|
|
|
Scenario: Parse devcontainer.json rejects empty path
|
|
When I attempt to parse devcontainer spec from empty path
|
|
Then a container ValueError should be raised with "path must not be empty"
|
|
|
|
Scenario: Parse devcontainer.json rejects relative path
|
|
When I attempt to parse devcontainer spec from relative path "devcontainer.json"
|
|
Then a container ValueError should be raised with "path must be an absolute path"
|
|
|
|
Scenario: Parse devcontainer.json rejects missing file
|
|
When I attempt to parse devcontainer spec from missing file "/nonexistent/devcontainer.json"
|
|
Then a container FileNotFoundError should be raised
|
|
|
|
Scenario: Parse devcontainer.json string rejects empty content
|
|
When I attempt to parse devcontainer spec from empty string
|
|
Then a container ValueError should be raised with "content must not be empty"
|
|
|
|
Scenario: Parse mount string in Docker format
|
|
Given a devcontainer.json string with string mount "type=bind,source=/src,target=/dst"
|
|
When I parse the devcontainer spec string
|
|
Then the spec should have 1 mount
|
|
And mount 0 source should be "/src"
|
|
And mount 0 target should be "/dst"
|
|
|
|
Scenario: Parse mount string in shorthand format
|
|
Given a devcontainer.json string with string mount "/host/path:/container/path"
|
|
When I parse the devcontainer spec string
|
|
Then the spec should have 1 mount
|
|
And mount 0 source should be "/host/path"
|
|
And mount 0 target should be "/container/path"
|
|
|
|
Scenario: Spec with post-create command
|
|
Given a devcontainer.json string with postCreateCommand "pip install -e ."
|
|
When I parse the devcontainer spec string
|
|
Then the post create command should be "pip install -e ."
|
|
|
|
Scenario: Spec with remote user
|
|
Given a devcontainer.json string with remoteUser "vscode"
|
|
When I parse the devcontainer spec string
|
|
Then the remote user should be "vscode"
|
|
|
|
Scenario: Spec with workspace folder
|
|
Given a devcontainer.json string with workspaceFolder "/workspaces/myproject"
|
|
When I parse the devcontainer spec string
|
|
Then the workspace folder should be "/workspaces/myproject"
|
|
|
|
# ── ResourceLimits ───────────────────────────────────────
|
|
|
|
Scenario: Default resource limits
|
|
When I create default resource limits
|
|
Then the cpu limit should be 2.0
|
|
And the memory limit should be 2048
|
|
|
|
Scenario: Custom resource limits
|
|
When I create resource limits with cpu 4.0 and memory 4096
|
|
Then the cpu limit should be 4.0
|
|
And the memory limit should be 4096
|
|
|
|
Scenario: Resource limits from host requirements
|
|
Given host requirements with cpus 8 and memory "16gb"
|
|
When I create resource limits from host requirements
|
|
Then the cpu limit should be 8.0
|
|
And the memory limit should be 16384
|
|
|
|
Scenario: Resource limits to docker args
|
|
When I create resource limits with cpu 2.0 and memory 1024
|
|
Then the docker args should contain "--cpus"
|
|
And the docker args should contain "--memory"
|
|
|
|
Scenario: Resource limits rejects invalid cpu
|
|
When I attempt to create resource limits with cpu 0.0
|
|
Then a container validation error should be raised
|
|
|
|
Scenario: Resource limits rejects invalid memory
|
|
When I attempt to create resource limits with memory 0
|
|
Then a container validation error should be raised
|
|
|
|
Scenario: Parse memory string gb
|
|
When I parse memory string "4gb"
|
|
Then the parsed memory should be 4096
|
|
|
|
Scenario: Parse memory string mb
|
|
When I parse memory string "512mb"
|
|
Then the parsed memory should be 512
|
|
|
|
Scenario: Parse memory string plain integer
|
|
When I parse memory string "2048"
|
|
Then the parsed memory should be 2048
|
|
|
|
Scenario: Parse memory string rejects empty
|
|
When I attempt to parse empty memory string
|
|
Then a container ValueError should be raised with "memory string must not be empty"
|
|
|
|
Scenario: Resource limits from host requirements rejects non-dict
|
|
When I attempt to create resource limits from non-dict host requirements
|
|
Then a container TypeError should be raised
|
|
|
|
Scenario: Resource limits with pids limit
|
|
When I create resource limits with pids limit 500
|
|
Then the docker args should contain "--pids-limit"
|
|
|
|
Scenario: Resource limits coerces string values
|
|
When I create resource limits from string values cpu "4.0" and memory "2048"
|
|
Then the cpu limit should be 4.0
|
|
And the memory limit should be 2048
|
|
|
|
# ── ContainerManager lifecycle ────────────────────────────
|
|
|
|
Scenario: Create container with mock runner
|
|
Given a mock container command runner
|
|
And the container runner configured for successful creation
|
|
When I create container "test-container" with workspace "/workspace"
|
|
Then the container "test-container" should have status "created"
|
|
And the container "test-container" should have a container ID
|
|
|
|
Scenario: Start a created container
|
|
Given a mock container command runner
|
|
And a created container "start-test" with container ID "abc123"
|
|
When I start container "start-test"
|
|
Then the container "start-test" should have status "running"
|
|
|
|
Scenario: Stop a running container
|
|
Given a mock container command runner
|
|
And a running container "stop-test" with container ID "def456"
|
|
When I stop managed container "stop-test"
|
|
Then the container "stop-test" should have status "stopped"
|
|
|
|
Scenario: Destroy a stopped container
|
|
Given a mock container command runner
|
|
And a stopped managed container "destroy-test" with container ID "ghi789"
|
|
When I destroy container "destroy-test"
|
|
Then the container "destroy-test" should have status "destroyed"
|
|
|
|
Scenario: Create container rejects empty name
|
|
Given a mock container command runner
|
|
When I attempt to create container with empty name
|
|
Then a container ValueError should be raised with "name must not be empty"
|
|
|
|
Scenario: Create container rejects duplicate name
|
|
Given a mock container command runner
|
|
And the container runner configured for successful creation
|
|
And an existing container "duplicate" in the manager
|
|
When I attempt to create duplicate container "duplicate"
|
|
Then a container ValueError should be raised with "already exists"
|
|
|
|
Scenario: Start non-existent container raises ValueError
|
|
Given a mock container command runner
|
|
When I attempt to start non-existent container "ghost"
|
|
Then a container ValueError should be raised with "not found"
|
|
|
|
Scenario: Stop non-running container raises ValueError
|
|
Given a mock container command runner
|
|
And a created container "not-running" with container ID "xyz"
|
|
When I attempt to stop managed container "not-running"
|
|
Then a container ValueError should be raised with "Cannot stop"
|
|
|
|
Scenario: Destroy running container raises ValueError
|
|
Given a mock container command runner
|
|
And a running container "still-running" with container ID "rrr"
|
|
When I attempt to destroy container "still-running"
|
|
Then a container ValueError should be raised with "Cannot destroy"
|
|
|
|
Scenario: Create container transitions to error on failure
|
|
Given a mock container command runner
|
|
And the container runner configured for failed creation
|
|
When I attempt to create container "fail-create" with workspace "/workspace"
|
|
Then a container RuntimeError should be raised
|
|
And the container "fail-create" should have status "error"
|
|
|
|
Scenario: Start container transitions to error on failure
|
|
Given a mock container command runner
|
|
And the container runner configured for failed start
|
|
And a created container "fail-start" with container ID "fstart"
|
|
When I attempt to start container "fail-start"
|
|
Then a container RuntimeError should be raised
|
|
And the container "fail-start" should have status "error"
|
|
|
|
Scenario: Stop container transitions to error on failure
|
|
Given a mock container command runner
|
|
And the container runner configured for failed stop
|
|
And a running container "fail-stop" with container ID "fstop"
|
|
When I attempt to stop managed container "fail-stop"
|
|
Then a container RuntimeError should be raised
|
|
And the container "fail-stop" should have status "error"
|
|
|
|
Scenario: List containers by status
|
|
Given a mock container command runner
|
|
And the container runner configured for successful creation
|
|
And a running container "running-1" with container ID "r1"
|
|
And a stopped managed container "stopped-1" with container ID "s1"
|
|
When I list containers with status "running"
|
|
Then the list should contain 1 container
|
|
And the list should contain container "running-1"
|
|
|
|
Scenario: List all containers
|
|
Given a mock container command runner
|
|
And the container runner configured for successful creation
|
|
And a running container "all-1" with container ID "a1"
|
|
And a stopped managed container "all-2" with container ID "a2"
|
|
When I list all containers
|
|
Then the list should contain 2 containers
|
|
|
|
Scenario: Get non-existent container returns None
|
|
Given a mock container command runner
|
|
When I get container "non-existent"
|
|
Then the container result should be None
|
|
|
|
Scenario: Get container rejects empty name
|
|
Given a mock container command runner
|
|
When I attempt to get container with empty name
|
|
Then a container ValueError should be raised with "name must not be empty"
|
|
|
|
Scenario: Container status enum has all expected values
|
|
Then the ContainerStatus enum should have 6 values
|
|
And ContainerStatus should include "pending"
|
|
And ContainerStatus should include "created"
|
|
And ContainerStatus should include "running"
|
|
And ContainerStatus should include "stopped"
|
|
And ContainerStatus should include "destroyed"
|
|
And ContainerStatus should include "error"
|
|
|
|
Scenario: Valid status transitions are accepted
|
|
Then container transition from "pending" to "created" should be valid
|
|
And container transition from "created" to "running" should be valid
|
|
And container transition from "running" to "stopped" should be valid
|
|
And container transition from "stopped" to "destroyed" should be valid
|
|
|
|
Scenario: Invalid status transitions are rejected
|
|
Then container transition from "pending" to "running" should be invalid
|
|
And container transition from "destroyed" to "running" should be invalid
|
|
And container transition from "running" to "created" should be invalid
|
|
|
|
Scenario: Create container with spec and resource limits
|
|
Given a mock container command runner
|
|
And the container runner configured for successful creation
|
|
And a devcontainer spec with cpu limit 4.0 and memory 4096
|
|
When I create container "spec-container" with spec and limits
|
|
Then the container "spec-container" should have status "created"
|
|
And the container "spec-container" resource limits cpu should be 4.0
|
|
|
|
# ── HealthMonitor ─────────────────────────────────────────
|
|
|
|
Scenario: Health monitor probes running container
|
|
Given a mock container command runner
|
|
And a health monitor with interval 10.0
|
|
And a running monitored container "health-test" with container ID "h123"
|
|
And the container runner configured for healthy probe
|
|
When I probe container "health-test"
|
|
Then the probe result should be healthy
|
|
|
|
Scenario: Health monitor detects unhealthy container
|
|
Given a mock container command runner
|
|
And a health monitor with interval 10.0
|
|
And a running monitored container "unhealthy-test" with container ID "h456"
|
|
And the container runner configured for unhealthy probe
|
|
When I probe container "unhealthy-test"
|
|
Then the probe result should be unhealthy
|
|
|
|
Scenario: Health monitor rejects empty container name
|
|
Given a mock container command runner
|
|
And a health monitor with interval 10.0
|
|
When I attempt to probe container with empty name
|
|
Then a container ValueError should be raised with "container_name must not be empty"
|
|
|
|
Scenario: Health monitor probe returns unhealthy for missing container
|
|
Given a mock container command runner
|
|
And a health monitor with interval 10.0
|
|
When I probe container "missing-container"
|
|
Then the probe result should be unhealthy
|
|
And the probe message should contain "not found"
|
|
|
|
Scenario: Health monitor probe returns unhealthy when no container_id
|
|
Given a mock container command runner
|
|
And a health monitor with interval 10.0
|
|
And a running monitored container "no-id" without container ID
|
|
When I probe container "no-id"
|
|
Then the probe result should be unhealthy
|
|
And the probe message should contain "no container_id"
|
|
|
|
Scenario: Health monitor start monitoring rejects non-running container
|
|
Given a mock container command runner
|
|
And a health monitor with interval 10.0
|
|
And a stopped monitored container "stopped-mon"
|
|
When I attempt to start monitoring "stopped-mon"
|
|
Then a container ValueError should be raised with "not running"
|
|
|
|
Scenario: Health monitor start monitoring rejects missing container
|
|
Given a mock container command runner
|
|
And a health monitor with interval 10.0
|
|
When I attempt to start monitoring "missing-mon"
|
|
Then a container ValueError should be raised with "not found"
|
|
|
|
Scenario: Health monitor rejects invalid interval
|
|
Given a mock container command runner
|
|
When I attempt to create health monitor with interval 1.0
|
|
Then a container ValueError should be raised with "interval must be between"
|
|
|
|
Scenario: Health monitor rejects non-manager type
|
|
When I attempt to create health monitor with non-manager
|
|
Then a container TypeError should be raised
|
|
|
|
Scenario: Health monitor stop monitoring does not fail for unknown container
|
|
Given a mock container command runner
|
|
And a health monitor with interval 10.0
|
|
When I stop monitoring "unknown-container"
|
|
Then no container error should be raised
|
|
|
|
Scenario: Health monitor stop all monitoring
|
|
Given a mock container command runner
|
|
And a health monitor with interval 10.0
|
|
And a running monitored container "monitored-1" with container ID "m1"
|
|
And a running monitored container "monitored-2" with container ID "m2"
|
|
And I start monitoring "monitored-1"
|
|
And I start monitoring "monitored-2"
|
|
When I stop all monitoring
|
|
Then no containers should be monitored
|
|
|
|
Scenario: Health monitor probe history is maintained
|
|
Given a mock container command runner
|
|
And a health monitor with interval 10.0
|
|
And a running monitored container "history-test" with container ID "ht1"
|
|
And the container runner configured for healthy probe
|
|
When I probe container "history-test"
|
|
Then the probe history should have 1 entry
|
|
|
|
Scenario: Health probe result repr
|
|
Given a health probe result for "test-repr" that is healthy
|
|
Then the probe repr should contain "healthy"
|
|
|
|
Scenario: Health probe result rejects empty name
|
|
When I attempt to create health probe with empty name
|
|
Then a container ValueError should be raised with "container_name must not be empty"
|
|
|
|
Scenario: Validate status transition rejects non-enum types
|
|
When I attempt to validate transition with non-enum current
|
|
Then a container TypeError should be raised
|
|
|
|
Scenario: Validate status transition rejects non-enum target
|
|
When I attempt to validate transition with non-enum target
|
|
Then a container TypeError should be raised
|
|
|
|
Scenario: Destroy container from error state
|
|
Given a mock container command runner
|
|
And an errored managed container "err-destroy" with container ID "errd"
|
|
When I destroy container "err-destroy"
|
|
Then the container "err-destroy" should have status "destroyed"
|