619e8eff36
- Adds a --clone-into option to the container-instance command to clone repository contents into a specified path during container setup. - Fixes the devcontainer-instance sandbox strategy to ensure proper isolation, correct mount permissions, and deterministic behavior across environments. - Updates related validation and error handling to reflect the new option and sandbox changes. ISSUES CLOSED: #7555
465 lines
16 KiB
Python
465 lines
16 KiB
Python
"""Step definitions for devcontainer lifecycle state model, registry, and JSON parsing.
|
|
|
|
Covers: lifecycle state enum, state transitions, tracker creation and
|
|
retrieval, registry operations (get/set/clear/evict), argument
|
|
validation for validate_transition and transition_state, the
|
|
transition history cap, and devcontainer-up JSON output parsing.
|
|
|
|
All mocks are in ``features/mocks/mock_devcontainer_cli.py``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
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,
|
|
transition_state,
|
|
validate_transition,
|
|
)
|
|
from cleveragents.resource.handlers.devcontainer import (
|
|
_lifecycle_registry,
|
|
_parse_devcontainer_up_output,
|
|
_registry_lock,
|
|
clear_lifecycle_registry,
|
|
evict_terminal_trackers,
|
|
get_lifecycle_tracker,
|
|
list_active_containers,
|
|
set_lifecycle_tracker,
|
|
)
|
|
|
|
# ── Shared helper ────────────────────────────────────────────
|
|
|
|
|
|
# R21 fix: shared _OneShotEvent helper — extracted from duplicated
|
|
# definitions in step_single_health_probe and step_run_health_check_loop_once.
|
|
#
|
|
# F8 fix: documented the exact call sequence the counter relies on.
|
|
class _OneShotEvent:
|
|
"""Stop event that allows exactly one probe iteration.
|
|
|
|
Simulates ``threading.Event`` for health check loop testing,
|
|
running exactly one probe cycle before signaling stop.
|
|
|
|
The counter relies on the following call sequence inside
|
|
``_health_check_loop``:
|
|
|
|
1. ``while not stop_event.is_set()`` → count=1, returns False
|
|
2. ``stop_event.wait(interval)`` → overridden to no-op
|
|
3. ``if stop_event.is_set()`` → count=2, returns False
|
|
4. (probe executes)
|
|
5. ``while not stop_event.is_set()`` → count=3, returns True → exit
|
|
|
|
**WARNING**: If the loop's ``is_set()`` call pattern changes, this
|
|
counter threshold must be updated to match.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._count = 0
|
|
|
|
def is_set(self) -> bool:
|
|
self._count += 1
|
|
return self._count > 2
|
|
|
|
def set(self) -> None:
|
|
self._count = 999
|
|
|
|
def wait(self, timeout: float = 0) -> bool:
|
|
return False
|
|
|
|
|
|
# ── Given steps ──────────────────────────────────────────────
|
|
# Registry cleanup is handled by the before_scenario hook in
|
|
# features/environment.py so that all scenarios start with a
|
|
# clean lifecycle registry regardless of execution order.
|
|
|
|
|
|
@given('a lifecycle tracker for resource "{resource_id}"')
|
|
def step_create_tracker(context: Context, resource_id: str) -> None:
|
|
tracker = ContainerLifecycleTracker(resource_id=resource_id)
|
|
set_lifecycle_tracker(tracker)
|
|
context.tracker = tracker
|
|
context.resource_id = resource_id
|
|
|
|
|
|
@given('a lifecycle tracker in "{state}" state for resource "{resource_id}"')
|
|
def step_create_tracker_in_state(
|
|
context: Context, state: str, resource_id: str
|
|
) -> None:
|
|
tracker = ContainerLifecycleTracker(
|
|
resource_id=resource_id,
|
|
current_state=ContainerLifecycleState(state),
|
|
)
|
|
set_lifecycle_tracker(tracker)
|
|
context.tracker = tracker
|
|
context.resource_id = resource_id
|
|
|
|
|
|
@given("{count:d} stopped container trackers in the registry")
|
|
def step_create_many_stopped(context: Context, count: int) -> None:
|
|
with _registry_lock:
|
|
for i in range(count):
|
|
rid = f"01EVICT{i:020d}"
|
|
_lifecycle_registry[rid] = ContainerLifecycleTracker(
|
|
resource_id=rid,
|
|
current_state=ContainerLifecycleState.STOPPED,
|
|
)
|
|
|
|
|
|
# ── When steps ───────────────────────────────────────────────
|
|
|
|
|
|
@when("I inspect ContainerLifecycleState enum values")
|
|
def step_inspect_enum(context: Context) -> None:
|
|
context.enum_values = [s.value for s in ContainerLifecycleState]
|
|
|
|
|
|
@when('I transition from "{from_state}" to "{to_state}"')
|
|
def step_transition(context: Context, from_state: str, to_state: str) -> None:
|
|
context.transition_error = None
|
|
try:
|
|
context.tracker = transition_state(
|
|
context.tracker,
|
|
ContainerLifecycleState(to_state),
|
|
reason="test transition",
|
|
)
|
|
set_lifecycle_tracker(context.tracker)
|
|
except (ValueError, TypeError) as exc:
|
|
context.transition_error = exc
|
|
|
|
|
|
@when('I attempt to transition from "{from_state}" to "{to_state}"')
|
|
def step_attempt_transition(context: Context, from_state: str, to_state: str) -> None:
|
|
context.transition_error = None
|
|
try:
|
|
context.tracker = transition_state(
|
|
context.tracker,
|
|
ContainerLifecycleState(to_state),
|
|
reason="test transition",
|
|
)
|
|
set_lifecycle_tracker(context.tracker)
|
|
except (ValueError, TypeError) as exc:
|
|
context.transition_error = exc
|
|
|
|
|
|
@when("I call validate_transition with non-enum arguments")
|
|
def step_validate_transition_bad_types(context: Context) -> None:
|
|
context.type_error = None
|
|
try:
|
|
validate_transition(
|
|
cast(ContainerLifecycleState, "inactive"),
|
|
cast(ContainerLifecycleState, "starting"),
|
|
)
|
|
except TypeError as exc:
|
|
context.type_error = exc
|
|
|
|
|
|
@when("I call validate_transition with non-enum to_state")
|
|
def step_validate_transition_bad_to_state(context: Context) -> None:
|
|
context.type_error = None
|
|
try:
|
|
validate_transition(
|
|
ContainerLifecycleState.DISCOVERED,
|
|
cast(ContainerLifecycleState, "starting"),
|
|
)
|
|
except TypeError as exc:
|
|
context.type_error = exc
|
|
|
|
|
|
@when("I call transition_state with non-tracker argument")
|
|
def step_transition_state_bad_tracker(context: Context) -> None:
|
|
context.type_error = None
|
|
try:
|
|
transition_state(
|
|
cast(ContainerLifecycleTracker, "not-a-tracker"),
|
|
ContainerLifecycleState.BUILDING,
|
|
)
|
|
except TypeError as exc:
|
|
context.type_error = exc
|
|
|
|
|
|
@when("I call transition_state with non-enum to_state argument")
|
|
def step_transition_state_bad_to_state(context: Context) -> None:
|
|
context.type_error = None
|
|
try:
|
|
tracker = ContainerLifecycleTracker(resource_id="01TESTLIFECYCLE0099000001")
|
|
transition_state(tracker, cast(ContainerLifecycleState, "starting"))
|
|
except TypeError as exc:
|
|
context.type_error = exc
|
|
|
|
|
|
@when('I retrieve the tracker for "{resource_id}"')
|
|
def step_retrieve_tracker(context: Context, resource_id: str) -> None:
|
|
context.retrieved_tracker = get_lifecycle_tracker(resource_id)
|
|
|
|
|
|
@when("I list active containers")
|
|
def step_list_active(context: Context) -> None:
|
|
context.active_list = list_active_containers()
|
|
|
|
|
|
@when("I perform 105 transitions cycling through valid states")
|
|
def step_perform_105_transitions(context: Context) -> None:
|
|
"""Cycle discovered->building->running->stopping->stopped->building->... 105 times."""
|
|
tracker = context.tracker
|
|
# First transition: discovered -> building
|
|
tracker = transition_state(
|
|
tracker, ContainerLifecycleState.BUILDING, reason="t1-init"
|
|
)
|
|
cycle = [
|
|
ContainerLifecycleState.RUNNING,
|
|
ContainerLifecycleState.STOPPING,
|
|
ContainerLifecycleState.STOPPED,
|
|
ContainerLifecycleState.BUILDING,
|
|
]
|
|
# We've done 1 transition, need 104 more for 105 total
|
|
for i in range(104):
|
|
state = cycle[i % len(cycle)]
|
|
tracker = transition_state(tracker, state, reason=f"t1-{i}")
|
|
set_lifecycle_tracker(tracker)
|
|
context.tracker = tracker
|
|
|
|
|
|
@when("I attempt to get tracker with empty resource_id")
|
|
def step_get_tracker_empty(context: Context) -> None:
|
|
context.get_error = None
|
|
try:
|
|
get_lifecycle_tracker("")
|
|
except ValueError as exc:
|
|
context.get_error = exc
|
|
|
|
|
|
@when("I attempt to set tracker with wrong type")
|
|
def step_set_tracker_wrong_type(context: Context) -> None:
|
|
context.set_error = None
|
|
try:
|
|
set_lifecycle_tracker(cast(ContainerLifecycleTracker, "not-a-tracker"))
|
|
except TypeError as exc:
|
|
context.set_error = exc
|
|
|
|
|
|
@when("I run terminal tracker eviction")
|
|
def step_run_eviction(context: Context) -> None:
|
|
context.evicted_count = evict_terminal_trackers()
|
|
|
|
|
|
@when('I get tracker for new resource "{resource_id}"')
|
|
def step_get_new_tracker(context: Context, resource_id: str) -> None:
|
|
context.auto_tracker = get_lifecycle_tracker(resource_id)
|
|
|
|
|
|
@when("I clear the lifecycle registry")
|
|
def step_clear_registry(context: Context) -> None:
|
|
clear_lifecycle_registry()
|
|
|
|
|
|
# ── Then steps ───────────────────────────────────────────────
|
|
|
|
|
|
@then('the lifecycle enum should contain "{value}"')
|
|
def step_check_lifecycle_enum_value(context: Context, value: str) -> None:
|
|
assert value in context.enum_values, f"'{value}' not in {context.enum_values}"
|
|
|
|
|
|
@then("the transition should succeed")
|
|
def step_transition_ok(context: Context) -> None:
|
|
assert context.transition_error is None, (
|
|
f"Unexpected error: {context.transition_error}"
|
|
)
|
|
|
|
|
|
@then('the tracker state should be "{state}"')
|
|
def step_check_tracker_state(context: Context, state: str) -> None:
|
|
assert context.tracker.current_state == ContainerLifecycleState(state)
|
|
|
|
|
|
@then("the transition should raise ValueError")
|
|
def step_transition_error(context: Context) -> None:
|
|
assert context.transition_error is not None
|
|
assert isinstance(context.transition_error, ValueError)
|
|
|
|
|
|
@then("the tracker should have {count:d} transition in history")
|
|
def step_check_history_count(context: Context, count: int) -> None:
|
|
assert len(context.tracker.transitions) == count
|
|
|
|
|
|
@then('the retrieved tracker state should be "{state}"')
|
|
def step_check_retrieved_state(context: Context, state: str) -> None:
|
|
assert context.retrieved_tracker.current_state == ContainerLifecycleState(state)
|
|
|
|
|
|
@then('the active list should contain "{resource_id}"')
|
|
def step_check_active_contains(context: Context, resource_id: str) -> None:
|
|
assert resource_id in context.active_list
|
|
|
|
|
|
@then('the active list should not contain "{resource_id}"')
|
|
def step_check_active_not_contains(context: Context, resource_id: str) -> None:
|
|
assert resource_id not in context.active_list
|
|
|
|
|
|
@then("it should raise TypeError")
|
|
def step_check_type_error(context: Context) -> None:
|
|
assert context.type_error is not None
|
|
assert isinstance(context.type_error, TypeError)
|
|
|
|
|
|
@then("the tracker should have at most 100 transitions")
|
|
def step_check_max_transitions(context: Context) -> None:
|
|
assert len(context.tracker.transitions) <= 100, (
|
|
f"Expected at most 100 transitions, got {len(context.tracker.transitions)}"
|
|
)
|
|
|
|
|
|
@then("the get tracker should raise ValueError")
|
|
def step_check_get_error(context: Context) -> None:
|
|
assert context.get_error is not None
|
|
assert isinstance(context.get_error, ValueError)
|
|
|
|
|
|
@then("the set tracker should raise TypeError")
|
|
def step_check_set_error(context: Context) -> None:
|
|
assert context.set_error is not None
|
|
assert isinstance(context.set_error, TypeError)
|
|
|
|
|
|
@then("the registry should have at most {max_count:d} terminal trackers")
|
|
def step_check_registry_cap(context: Context, max_count: int) -> None:
|
|
with _registry_lock:
|
|
terminal = [
|
|
rid
|
|
for rid, t in _lifecycle_registry.items()
|
|
if t.current_state
|
|
in (ContainerLifecycleState.STOPPED, ContainerLifecycleState.FAILED)
|
|
]
|
|
assert len(terminal) <= max_count, (
|
|
f"Expected at most {max_count} terminal trackers, got {len(terminal)}"
|
|
)
|
|
|
|
|
|
@then("the eviction count should be {count:d}")
|
|
def step_check_eviction_count(context: Context, count: int) -> None:
|
|
assert context.evicted_count == count, (
|
|
f"Expected eviction count {count}, got {context.evicted_count}"
|
|
)
|
|
|
|
|
|
@then('the auto-created tracker state should be "{state}"')
|
|
def step_check_auto_tracker_state(context: Context, state: str) -> None:
|
|
assert context.auto_tracker.current_state == ContainerLifecycleState(state)
|
|
|
|
|
|
@then('the auto-created tracker resource_id should be "{resource_id}"')
|
|
def step_check_auto_tracker_id(context: Context, resource_id: str) -> None:
|
|
assert context.auto_tracker.resource_id == resource_id
|
|
|
|
|
|
@then("the registry should be empty")
|
|
def step_check_registry_empty(context: Context) -> None:
|
|
with _registry_lock:
|
|
assert len(_lifecycle_registry) == 0, (
|
|
f"Expected empty registry, got {len(_lifecycle_registry)} entries"
|
|
)
|
|
|
|
|
|
# ── JSON output parsing steps ────────────────────────────────
|
|
|
|
|
|
@when('I parse devcontainer up output with containerId "{ctr_id}" and workspace "{ws}"')
|
|
def step_parse_json(context: Context, ctr_id: str, ws: str) -> None:
|
|
stdout = json.dumps(
|
|
{
|
|
"outcome": "success",
|
|
"containerId": ctr_id,
|
|
"remoteWorkspaceFolder": ws,
|
|
}
|
|
)
|
|
context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout)
|
|
|
|
|
|
@when("I parse devcontainer up output with invalid JSON")
|
|
def step_parse_invalid_json(context: Context) -> None:
|
|
context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(
|
|
"not valid json {{"
|
|
)
|
|
|
|
|
|
@when("I parse devcontainer up output with log lines before JSON")
|
|
def step_parse_multiline_json(context: Context) -> None:
|
|
stdout = (
|
|
"[2025-01-01T00:00:00Z] Starting container...\n"
|
|
"Some log line\n"
|
|
+ json.dumps(
|
|
{
|
|
"outcome": "success",
|
|
"containerId": "aabbccddee0099887766",
|
|
"remoteWorkspaceFolder": "/workspaces/multi",
|
|
}
|
|
)
|
|
)
|
|
context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout)
|
|
|
|
|
|
@when('I parse devcontainer up output with invalid container ID "{bad_id}"')
|
|
def step_parse_invalid_container_id(context: Context, bad_id: str) -> None:
|
|
stdout = json.dumps(
|
|
{
|
|
"outcome": "success",
|
|
"containerId": bad_id,
|
|
"remoteWorkspaceFolder": "/workspaces/project",
|
|
}
|
|
)
|
|
context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout)
|
|
|
|
|
|
@when('I parse devcontainer up output with relative workspace "{ws}"')
|
|
def step_parse_relative_ws(context: Context, ws: str) -> None:
|
|
stdout = json.dumps(
|
|
{
|
|
"outcome": "success",
|
|
"containerId": "aabbccddee0011223344",
|
|
"remoteWorkspaceFolder": ws,
|
|
}
|
|
)
|
|
context.parsed_ctr_id, context.parsed_ws = _parse_devcontainer_up_output(stdout)
|
|
|
|
|
|
@then('the parsed container ID should be "{ctr_id}"')
|
|
def step_check_parsed_ctr_id(context: Context, ctr_id: str) -> None:
|
|
assert context.parsed_ctr_id == ctr_id
|
|
|
|
|
|
@then("the parsed container ID should be None")
|
|
def step_check_parsed_ctr_id_none(context: Context) -> None:
|
|
assert context.parsed_ctr_id is None
|
|
|
|
|
|
@then('the parsed workspace path should be "{ws}"')
|
|
def step_check_parsed_ws(context: Context, ws: str) -> None:
|
|
assert context.parsed_ws == ws
|
|
|
|
|
|
@then("the parsed workspace path should be None")
|
|
def step_check_parsed_ws_none(context: Context) -> None:
|
|
assert context.parsed_ws is None
|
|
|
|
|
|
@then("the parsed container ID should be empty")
|
|
def step_check_empty_container_id(context: Context) -> None:
|
|
assert context.parsed_ctr_id is None, (
|
|
f"Expected None container ID, got '{context.parsed_ctr_id}'"
|
|
)
|
|
|
|
|
|
@then("the runner should not have received a devcontainer up call")
|
|
def step_check_no_up_call(context: Context) -> None:
|
|
assert len(context.mock_runner.up_calls) == 0, (
|
|
f"Expected 0 devcontainer up calls, got {len(context.mock_runner.up_calls)}"
|
|
)
|