cf67ba0a86
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 35s
CI / typecheck (pull_request) Successful in 37s
CI / unit_tests (pull_request) Successful in 2m27s
CI / docker (pull_request) Successful in 49s
CI / integration_tests (pull_request) Successful in 3m46s
CI / coverage (pull_request) Successful in 4m56s
CI / lint (push) Successful in 12s
CI / build (push) Successful in 14s
CI / quality (push) Successful in 16s
CI / security (push) Successful in 32s
CI / typecheck (push) Successful in 36s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 4m7s
CI / integration_tests (push) Successful in 4m41s
CI / docker (push) Successful in 44s
CI / coverage (push) Successful in 4m56s
CI / benchmark-publish (push) Successful in 17m17s
CI / benchmark-regression (pull_request) Successful in 31m25s
Implemented lazy container activation for devcontainer-instance resources with ContainerLifecycleState enum tracking six states (inactive, starting, active, stopping, stopped, error) with validated transitions. Extended DevcontainerHandler with devcontainer up CLI integration and JSON output parsing for container start. Added periodic health checking via devcontainer exec ping with configurable interval. Added agents resource stop and agents resource rebuild CLI commands for manual lifecycle control. Wired session close and plan completion hooks to automatic container cleanup. Includes lifecycle state persistence in resource registry with timestamped transitions. Added Behave BDD tests, Robot integration tests, and ASV activation latency benchmarks. - Added remoteWorkspaceFolder absolute-path validation - Aligned spec: handler name, rebuild types, --yes flag on stop/rebuild - Added registry re-read in stop_container success path for consistency - Added session_id field to ContainerLifecycleTracker for scoped cleanup - Scoped stop_all_active_containers to session_id when provided - Wired _cleanup_devcontainers into fail_apply and fail_execute - Wired start_health_check into activate_container success path - Restructured facade session close to always run container cleanup even without session service (F4) - Re-read tracker from registry in activate_container success path - Added evict_terminal_trackers to cap registry growth - Updated devcontainer_resources.md: health check auto-start, scoped cleanup hooks, known limitations for eviction and sandbox_strategy - Wired evict_terminal_trackers into stop_all_active_containers so terminal-state trackers are actually evicted in production - Made stop_container idempotent: returns early when container is already in a terminal state instead of raising ValueError - Fixed benchmark health check thread leak in TimeActivationLatency by clearing registry after each timing loop - Added rebuild pass-through (--reset-container flag to devcontainer up) - Added host_workspace_path field on ContainerLifecycleTracker so health probes use the host-side path for devcontainer exec - Wired lazy activation into DevcontainerHandler.resolve() for devcontainer-instance resources in non-running states - Changed _default_strategy from SNAPSHOT to NONE (container itself provides isolation; SandboxFactory raises NotImplementedError for snapshot) - Restricted _STOPPABLE_TYPES to devcontainer-instance only (container-instance is not directly stoppable via CLI) ISSUES CLOSED: #514
495 lines
17 KiB
Python
495 lines
17 KiB
Python
"""Step definitions for devcontainer activation, stop, and rebuild.
|
|
|
|
Covers: lazy activation (success/failure/timeout/orphan), manual stop,
|
|
rebuild, concurrent activation and stop, argument validation for
|
|
activate/stop/rebuild, container_id clearing, and early-return on
|
|
terminal state.
|
|
|
|
All mocks are in ``features/mocks/mock_devcontainer_cli.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
from typing import cast
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.domain.models.core.container_lifecycle import (
|
|
ContainerLifecycleState,
|
|
ContainerLifecycleTracker,
|
|
)
|
|
from cleveragents.resource.handlers.devcontainer import (
|
|
activate_container,
|
|
get_lifecycle_tracker,
|
|
rebuild_container,
|
|
set_lifecycle_tracker,
|
|
stop_container,
|
|
)
|
|
from features.mocks.mock_devcontainer_cli import (
|
|
MockDevcontainerRunner,
|
|
MockProcessResult,
|
|
)
|
|
|
|
# ── Given steps ──────────────────────────────────────────────
|
|
|
|
|
|
@given("a mock devcontainer CLI runner")
|
|
def step_create_mock_runner(context: Context) -> None:
|
|
context.mock_runner = MockDevcontainerRunner()
|
|
|
|
|
|
@given("the runner configured for successful activation")
|
|
def step_configure_success(context: Context) -> None:
|
|
context.mock_runner.set_up_result(
|
|
container_id="aabbccddee0011223344",
|
|
workspace="/workspaces/project",
|
|
)
|
|
|
|
|
|
@given("the runner configured for failed activation")
|
|
def step_configure_failure(context: Context) -> None:
|
|
context.mock_runner.set_up_failure(stderr="container build failed")
|
|
|
|
|
|
@given('an active container "{resource_id}" with container ID "{ctr_id}"')
|
|
def step_create_active_container(
|
|
context: Context, resource_id: str, ctr_id: str
|
|
) -> None:
|
|
tracker = ContainerLifecycleTracker(
|
|
resource_id=resource_id,
|
|
current_state=ContainerLifecycleState.RUNNING,
|
|
container_id=ctr_id,
|
|
workspace_path="/workspaces/project",
|
|
)
|
|
set_lifecycle_tracker(tracker)
|
|
|
|
|
|
@given('a stopped container "{resource_id}"')
|
|
def step_create_stopped_container(context: Context, resource_id: str) -> None:
|
|
tracker = ContainerLifecycleTracker(
|
|
resource_id=resource_id,
|
|
current_state=ContainerLifecycleState.STOPPED,
|
|
)
|
|
set_lifecycle_tracker(tracker)
|
|
|
|
|
|
@given('an errored container "{resource_id}"')
|
|
def step_create_errored_container(context: Context, resource_id: str) -> None:
|
|
tracker = ContainerLifecycleTracker(
|
|
resource_id=resource_id,
|
|
current_state=ContainerLifecycleState.FAILED,
|
|
)
|
|
set_lifecycle_tracker(tracker)
|
|
|
|
|
|
@given('an active container with no container_id "{resource_id}"')
|
|
def step_create_active_no_ctr_id(context: Context, resource_id: str) -> None:
|
|
tracker = ContainerLifecycleTracker(
|
|
resource_id=resource_id,
|
|
current_state=ContainerLifecycleState.RUNNING,
|
|
container_id=None,
|
|
workspace_path="/workspaces/project",
|
|
)
|
|
set_lifecycle_tracker(tracker)
|
|
|
|
|
|
@given("the runner configured for activation with no container_id")
|
|
def step_configure_activation_no_ctr_id(context: Context) -> None:
|
|
"""Configure devcontainer up to succeed but return no containerId."""
|
|
context.mock_runner.up_result = MockProcessResult(
|
|
returncode=0,
|
|
stdout=json.dumps({"outcome": "success"}),
|
|
stderr="",
|
|
)
|
|
|
|
|
|
@given("the runner configured for failed docker stop")
|
|
def step_configure_stop_failure_rc(context: Context) -> None:
|
|
"""Configure docker stop to return a non-zero exit code (not raise)."""
|
|
context.mock_runner.set_stop_result(returncode=1)
|
|
|
|
|
|
@given("the runner configured to raise exception on stop")
|
|
def step_configure_stop_exception(context: Context) -> None:
|
|
"""Replace mock_runner with a wrapper that raises on docker stop."""
|
|
inner = context.mock_runner
|
|
|
|
def raising_runner(args: list[str], **kwargs: object) -> object:
|
|
inner.calls.append((list(args), dict(kwargs)))
|
|
if len(args) >= 2 and args[0] == "docker" and args[1] == "stop":
|
|
raise OSError("docker daemon not running")
|
|
return inner(args, **kwargs)
|
|
|
|
context.mock_runner = cast(MockDevcontainerRunner, raising_runner)
|
|
|
|
|
|
@given("the runner configured to raise generic exception on up")
|
|
def step_configure_up_generic_exception(context: Context) -> None:
|
|
"""Replace mock_runner with a wrapper that raises on devcontainer up."""
|
|
inner = context.mock_runner
|
|
|
|
def raising_runner(args: list[str], **kwargs: object) -> object:
|
|
inner.calls.append((list(args), dict(kwargs)))
|
|
if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "up":
|
|
raise OSError("network unreachable")
|
|
return inner(args, **kwargs)
|
|
|
|
context.mock_runner = cast(MockDevcontainerRunner, raising_runner)
|
|
|
|
|
|
@given("a mock devcontainer CLI runner that times out")
|
|
def step_create_timeout_runner(context: Context) -> None:
|
|
"""Create a runner that raises subprocess.TimeoutExpired."""
|
|
|
|
def _timeout_runner(args: list[str], **kwargs: object) -> None:
|
|
raise subprocess.TimeoutExpired(cmd=args, timeout=300)
|
|
|
|
context.mock_runner = _timeout_runner
|
|
|
|
|
|
@given("a mock devcontainer CLI runner that times out with orphan containers")
|
|
def step_create_timeout_orphan_runner(context: Context) -> None:
|
|
"""Create a runner that times out on devcontainer up but returns
|
|
orphan container IDs on docker ps.
|
|
"""
|
|
call_log: list[tuple[list[str], dict[str, object]]] = []
|
|
|
|
def _runner(args: list[str], **kwargs: object) -> object:
|
|
call_log.append((list(args), dict(kwargs)))
|
|
if len(args) >= 2 and args[0] == "devcontainer" and args[1] == "up":
|
|
raise subprocess.TimeoutExpired(cmd=args, timeout=300)
|
|
if len(args) >= 2 and args[0] == "docker" and args[1] == "ps":
|
|
return MockProcessResult(returncode=0, stdout="orphan123abc\n", stderr="")
|
|
if len(args) >= 2 and args[0] == "docker" and args[1] == "stop":
|
|
return MockProcessResult(returncode=0)
|
|
return MockProcessResult(returncode=0)
|
|
|
|
context.mock_runner = _runner
|
|
context.orphan_call_log = call_log
|
|
|
|
|
|
# ── When steps ───────────────────────────────────────────────
|
|
|
|
|
|
@when('I activate container "{resource_id}" at "{workspace}"')
|
|
def step_activate(context: Context, resource_id: str, workspace: str) -> None:
|
|
context.activation_error = None
|
|
try:
|
|
context.activated_tracker = activate_container(
|
|
resource_id,
|
|
workspace,
|
|
run_command=context.mock_runner,
|
|
)
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.activation_error = exc
|
|
|
|
|
|
@when('I attempt to activate container "{resource_id}" at "{workspace}"')
|
|
def step_attempt_activate(context: Context, resource_id: str, workspace: str) -> None:
|
|
context.activation_error = None
|
|
try:
|
|
context.activated_tracker = activate_container(
|
|
resource_id,
|
|
workspace,
|
|
run_command=getattr(context, "mock_runner", None),
|
|
)
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.activation_error = exc
|
|
|
|
|
|
@when('I stop container "{resource_id}"')
|
|
def step_stop(context: Context, resource_id: str) -> None:
|
|
context.stop_error = None
|
|
try:
|
|
stop_container(
|
|
resource_id,
|
|
run_command=context.mock_runner,
|
|
)
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.stop_error = exc
|
|
|
|
|
|
@when('I attempt to stop container "{resource_id}"')
|
|
def step_attempt_stop(context: Context, resource_id: str) -> None:
|
|
context.stop_error = None
|
|
try:
|
|
stop_container(
|
|
resource_id,
|
|
run_command=getattr(context, "mock_runner", None),
|
|
)
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.stop_error = exc
|
|
|
|
|
|
@when('I rebuild container "{resource_id}" at "{workspace}"')
|
|
def step_rebuild(context: Context, resource_id: str, workspace: str) -> None:
|
|
context.rebuild_error = None
|
|
try:
|
|
rebuild_container(
|
|
resource_id,
|
|
workspace,
|
|
run_command=context.mock_runner,
|
|
)
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.rebuild_error = exc
|
|
|
|
|
|
@when('I call rebuild_container for "{resource_id}" at "{workspace}"')
|
|
def step_rebuild_direct(context: Context, resource_id: str, workspace: str) -> None:
|
|
context.rebuild_error = None
|
|
try:
|
|
rebuild_container(
|
|
resource_id,
|
|
workspace,
|
|
run_command=context.mock_runner,
|
|
)
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.rebuild_error = exc
|
|
|
|
|
|
@when("I call rebuild_container with empty resource_id")
|
|
def step_rebuild_empty_resource_id(context: Context) -> None:
|
|
context.rebuild_error = None
|
|
try:
|
|
rebuild_container("", "/workspace")
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.rebuild_error = exc
|
|
|
|
|
|
@when("I call rebuild_container with empty workspace_folder")
|
|
def step_rebuild_empty_workspace(context: Context) -> None:
|
|
context.rebuild_error = None
|
|
try:
|
|
rebuild_container("01TESTLIFECYCLE0000000091", "")
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.rebuild_error = exc
|
|
|
|
|
|
@when("I attempt to activate a container with empty resource_id")
|
|
def step_activate_empty_resource_id(context: Context) -> None:
|
|
context.activation_error = None
|
|
try:
|
|
activate_container("", "/workspace")
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.activation_error = exc
|
|
|
|
|
|
@when("I attempt to activate a container with empty workspace_folder")
|
|
def step_activate_empty_workspace(context: Context) -> None:
|
|
context.activation_error = None
|
|
try:
|
|
activate_container("01TESTLIFECYCLE0000000080", "")
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.activation_error = exc
|
|
|
|
|
|
@when("I attempt to stop a container with empty resource_id")
|
|
def step_stop_empty_resource_id(context: Context) -> None:
|
|
context.stop_error = None
|
|
try:
|
|
stop_container("")
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.stop_error = exc
|
|
|
|
|
|
@when("I attempt to activate a container with relative workspace_folder")
|
|
def step_activate_relative_workspace(context: Context) -> None:
|
|
context.activation_error = None
|
|
try:
|
|
activate_container("01TESTLIFECYCLE0000000080", "relative/path")
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.activation_error = exc
|
|
|
|
|
|
@when("I attempt to activate the timed-out container")
|
|
def step_activate_timeout(context: Context) -> None:
|
|
context.activation_error = None
|
|
try:
|
|
activate_container(
|
|
context.resource_id,
|
|
"/workspace",
|
|
run_command=context.mock_runner,
|
|
)
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.activation_error = exc
|
|
|
|
|
|
@when("I attempt to activate the timed-out container with orphan cleanup")
|
|
def step_activate_timeout_orphan(context: Context) -> None:
|
|
context.activation_error = None
|
|
try:
|
|
activate_container(
|
|
context.resource_id,
|
|
"/workspace",
|
|
run_command=context.mock_runner,
|
|
)
|
|
except (RuntimeError, ValueError) as exc:
|
|
context.activation_error = exc
|
|
|
|
|
|
@when('I stop container "{resource_id}" expecting early return')
|
|
def step_stop_container_early_return(context: Context, resource_id: str) -> None:
|
|
"""Stop a container that is already in a terminal state."""
|
|
context.stop_result = stop_container(
|
|
resource_id,
|
|
run_command=context.mock_runner,
|
|
)
|
|
|
|
|
|
# ── Then steps ───────────────────────────────────────────────
|
|
|
|
|
|
@then('the container state should be "{state}"')
|
|
def step_check_container_state(context: Context, state: str) -> None:
|
|
assert context.activated_tracker.current_state == ContainerLifecycleState(state)
|
|
|
|
|
|
@then('the container ID should be "{ctr_id}"')
|
|
def step_check_container_id(context: Context, ctr_id: str) -> None:
|
|
assert context.activated_tracker.container_id == ctr_id
|
|
|
|
|
|
@then('the workspace path should be "{ws_path}"')
|
|
def step_check_workspace_path(context: Context, ws_path: str) -> None:
|
|
assert context.activated_tracker.workspace_path == ws_path
|
|
|
|
|
|
@then("the runner should have received a devcontainer up call")
|
|
def step_check_up_call(context: Context) -> None:
|
|
assert len(context.mock_runner.up_calls) > 0
|
|
|
|
|
|
@then("the activation should raise RuntimeError")
|
|
def step_check_activation_runtime_error(context: Context) -> None:
|
|
assert context.activation_error is not None
|
|
assert isinstance(context.activation_error, RuntimeError)
|
|
|
|
|
|
@then('the container state for "{resource_id}" should be "{state}"')
|
|
def step_check_specific_state(context: Context, resource_id: str, state: str) -> None:
|
|
tracker = get_lifecycle_tracker(resource_id)
|
|
assert tracker.current_state == ContainerLifecycleState(state), (
|
|
f"Expected {state}, got {tracker.current_state.value}"
|
|
)
|
|
|
|
|
|
@then("the runner should have received a docker stop call")
|
|
def step_check_stop_call(context: Context) -> None:
|
|
assert len(context.mock_runner.stop_calls) > 0
|
|
|
|
|
|
@then("the runner should not have received a docker stop call")
|
|
def step_check_no_stop_call(context: Context) -> None:
|
|
assert len(context.mock_runner.stop_calls) == 0, (
|
|
f"Expected 0 docker stop calls, got {len(context.mock_runner.stop_calls)}"
|
|
)
|
|
|
|
|
|
@then('the activation should raise ValueError with "{keyword}"')
|
|
def step_check_activation_value_error(context: Context, keyword: str) -> None:
|
|
assert context.activation_error is not None
|
|
assert isinstance(context.activation_error, ValueError)
|
|
assert keyword in str(context.activation_error)
|
|
|
|
|
|
@then('the stop should raise ValueError with "{keyword}"')
|
|
def step_check_stop_value_error(context: Context, keyword: str) -> None:
|
|
assert context.stop_error is not None
|
|
assert isinstance(context.stop_error, ValueError)
|
|
assert keyword in str(context.stop_error)
|
|
|
|
|
|
@then("the stop should raise RuntimeError")
|
|
def step_check_stop_runtime_error(context: Context) -> None:
|
|
assert context.stop_error is not None
|
|
assert isinstance(context.stop_error, RuntimeError)
|
|
|
|
|
|
@then('the rebuild should raise ValueError with "{keyword}"')
|
|
def step_check_rebuild_value_error(context: Context, keyword: str) -> None:
|
|
assert context.rebuild_error is not None
|
|
assert isinstance(context.rebuild_error, ValueError)
|
|
assert keyword in str(context.rebuild_error)
|
|
|
|
|
|
@then('the activation should raise RuntimeError with "{text}"')
|
|
def step_check_runtime_error(context: Context, text: str) -> None:
|
|
assert context.activation_error is not None, "Expected an error but none was raised"
|
|
assert isinstance(context.activation_error, RuntimeError), (
|
|
f"Expected RuntimeError, got {type(context.activation_error).__name__}"
|
|
)
|
|
assert text in str(context.activation_error), (
|
|
f"Expected '{text}' in error message, got '{context.activation_error}'"
|
|
)
|
|
|
|
|
|
@then('the tracker "{resource_id}" should be in "{state}" state')
|
|
def step_check_named_tracker_state(
|
|
context: Context, resource_id: str, state: str
|
|
) -> None:
|
|
tracker = get_lifecycle_tracker(resource_id)
|
|
assert tracker.current_state == ContainerLifecycleState(state), (
|
|
f"Expected state '{state}', got '{tracker.current_state.value}'"
|
|
)
|
|
|
|
|
|
@then('the container_id for "{resource_id}" should be None')
|
|
def step_check_container_id_none(context: Context, resource_id: str) -> None:
|
|
tracker = get_lifecycle_tracker(resource_id)
|
|
assert tracker.container_id is None, (
|
|
f"Expected container_id None, got '{tracker.container_id}'"
|
|
)
|
|
|
|
|
|
@then('the workspace_path for "{resource_id}" should be None')
|
|
def step_check_workspace_path_none(context: Context, resource_id: str) -> None:
|
|
tracker = get_lifecycle_tracker(resource_id)
|
|
assert tracker.workspace_path is None, (
|
|
f"Expected workspace_path None, got '{tracker.workspace_path}'"
|
|
)
|
|
|
|
|
|
@then('the host_workspace_path for "{resource_id}" should be "{path}"')
|
|
def step_check_host_workspace_path(
|
|
context: Context, resource_id: str, path: str
|
|
) -> None:
|
|
tracker = get_lifecycle_tracker(resource_id)
|
|
assert tracker.host_workspace_path == path, (
|
|
f"Expected host_workspace_path '{path}', got '{tracker.host_workspace_path}'"
|
|
)
|
|
|
|
|
|
@then('the devcontainer up call should include "{flag}"')
|
|
def step_check_up_call_includes_flag(context: Context, flag: str) -> None:
|
|
up_calls = context.mock_runner.up_calls
|
|
assert len(up_calls) > 0, "No devcontainer up calls recorded"
|
|
args, _ = up_calls[-1]
|
|
assert flag in args, f"Expected '{flag}' in {args}"
|
|
|
|
|
|
@then('the devcontainer up call should not include "{flag}"')
|
|
def step_check_up_call_excludes_flag(context: Context, flag: str) -> None:
|
|
up_calls = context.mock_runner.up_calls
|
|
assert len(up_calls) > 0, "No devcontainer up calls recorded"
|
|
args, _ = up_calls[-1]
|
|
assert flag not in args, f"Did not expect '{flag}' in {args}"
|
|
|
|
|
|
@then("the runner should have attempted orphan container cleanup")
|
|
def step_check_orphan_cleanup(context: Context) -> None:
|
|
ps_calls = [
|
|
(a, k)
|
|
for a, k in context.orphan_call_log
|
|
if len(a) >= 2 and a[0] == "docker" and a[1] == "ps"
|
|
]
|
|
assert len(ps_calls) >= 1, "Expected docker ps call for orphan lookup"
|
|
stop_calls = [
|
|
(a, k)
|
|
for a, k in context.orphan_call_log
|
|
if len(a) >= 2 and a[0] == "docker" and a[1] == "stop"
|
|
]
|
|
assert len(stop_calls) >= 1, "Expected docker stop call for orphan cleanup (F1 fix)"
|