From bdc36ad2b935acb2d440a804cffe0c563dce0fd3 Mon Sep 17 00:00:00 2001 From: "Brent E. Edwards" Date: Thu, 26 Mar 2026 20:03:11 +0000 Subject: [PATCH] Merge remote-tracking branch 'origin/master' into test/int-wf17-explicit-container --- CHANGELOG.md | 9 + robot/helper_wf17_explicit_container.py | 493 ++++++++++++++++++++++++ robot/wf17_explicit_container.robot | 74 ++++ 3 files changed, 576 insertions(+) create mode 100644 robot/helper_wf17_explicit_container.py create mode 100644 robot/wf17_explicit_container.robot diff --git a/CHANGELOG.md b/CHANGELOG.md index 707a2dec7..22dc645bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +- Added Robot Framework integration test and Python helper for Specification + Workflow Example 17 (explicit container with directory mount). Covers + explicit `container-instance` resource configuration with dual mounts + (resource-ref rw + host-path ro), project execution environment override, + container lifecycle activation/stop, trusted profile execution, and session + ID propagation — all exercised with mocked LLM providers and mocked + container operations. + (`robot/wf17_explicit_container.robot`, + `robot/helper_wf17_explicit_container.py`) (#781) - Added TDD bug-capture tests for bug #1076 — `use_action()` does not propagate `automation_profile` to Plan. Three Behave BDD scenarios (`@tdd_bug @tdd_bug_1076 @tdd_expected_fail`) verify the full precedence diff --git a/robot/helper_wf17_explicit_container.py b/robot/helper_wf17_explicit_container.py new file mode 100644 index 000000000..da27243a4 --- /dev/null +++ b/robot/helper_wf17_explicit_container.py @@ -0,0 +1,493 @@ +"""Robot Framework helper for Workflow Example 17. + +Explicit Container with Directory Mount. + +Exercises explicit container-instance creation with dual mounts +(resource-ref rw + host-path ro), project execution environment +override, and container tool routing using mocked LLM providers +and mocked container operations. + +Each subcommand prints a sentinel on success and exits 0/1. + +Usage: + python robot/helper_wf17_explicit_container.py +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import sys +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path +from typing import NoReturn + +_SRC = str(Path(__file__).resolve().parents[1] / "src") +if _SRC not in sys.path: + sys.path.insert(0, _SRC) +_ROBOT = str(Path(__file__).resolve().parent) +if _ROBOT not in sys.path: + sys.path.insert(0, _ROBOT) + +from helper_e2e_common import ( # noqa: E402 + cleanup_workspace, + init_bare_git_repo, + run_cli, + setup_workspace, + write_yaml, +) +from helpers_common import reset_global_state # noqa: E402 + +from cleveragents.application.services.execution_environment_resolver import ( # noqa: E402 + CONTAINER_RESOURCE_TYPES, + ExecutionEnvironmentResolver, +) +from cleveragents.domain.models.core.container_lifecycle import ( # noqa: E402 + ContainerLifecycleState, +) +from cleveragents.domain.models.core.plan import ExecutionEnvironment # noqa: E402 +from cleveragents.resource.handlers.devcontainer import ( # noqa: E402 + activate_container, + clear_lifecycle_registry, + get_lifecycle_tracker, + stop_container, +) + +_ACTION_YAML: str = """\ +name: local/apply-schema-migration +description: Apply database schema migration in container +strategy_actor: openai/gpt-4 +execution_actor: openai/gpt-4 +definition_of_done: Schema migration applied and validated +""" +_ENV: dict[str, str] = {"COLUMNS": "500"} + + +# -- Mock runner (no real Docker) ------------------------------------------- + + +@dataclass +class _MockResult: + returncode: int = 0 + stdout: str = "" + stderr: str = "" + args: list[str] = field(default_factory=list) + + +class _MockRunner: + """Callable mock for subprocess.run used by lifecycle functions.""" + + def __init__(self) -> None: + self.calls: list[tuple[list[str], dict[str, object]]] = [] + self.up_result: _MockResult = _MockResult() + self.stop_result: _MockResult = _MockResult() + + def __call__(self, args: list[str], **kwargs: object) -> _MockResult: + 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] == "stop": + return self.stop_result + return _MockResult() + + def set_up_result( + self, + *, + cid: str = "ff17c0a1a1e2aa112233", + ws: str = "/workspaces/data", + ) -> None: + self.up_result = _MockResult( + stdout=json.dumps( + {"outcome": "success", "containerId": cid, "remoteWorkspaceFolder": ws} + ), + ) + + @property + def up_calls(self) -> list[tuple[list[str], dict[str, object]]]: + return [ + (a, k) + for a, k in self.calls + if len(a) >= 2 and a[0] == "devcontainer" and a[1] == "up" + ] + + @property + def stop_calls(self) -> list[tuple[list[str], dict[str, object]]]: + return [ + (a, k) + for a, k in self.calls + if len(a) >= 2 and a[0] == "docker" and a[1] == "stop" + ] + + +# -- Helpers ---------------------------------------------------------------- + + +def _fail(msg: str) -> NoReturn: + print(f"FAIL: {msg}", file=sys.stderr) + raise SystemExit(1) + + +def _plan_id(output: str) -> str | None: + m = re.search(r"\b([0-9A-Z]{26})\b", output) + return m.group(1) if m else None + + +def _make_repo() -> str: + """Create git repo with sample data directory for mount testing.""" + repo = init_bare_git_repo() + data_dir = Path(repo) / "data" + data_dir.mkdir() + (data_dir / "schema.sql").write_text( + "CREATE TABLE wf17_test (id INTEGER PRIMARY KEY);\n", + encoding="utf-8", + ) + subprocess.run(["git", "add", "."], cwd=repo, capture_output=True, check=True) + subprocess.run( + ["git", "commit", "-m", "Add data directory"], + cwd=repo, + capture_output=True, + check=True, + ) + return repo + + +def _cli(ws: str, *args: str) -> subprocess.CompletedProcess[str]: + """Run CLI command, fail on non-zero exit.""" + r = run_cli(*args, workspace=ws, env_extra=_ENV) + if r.returncode != 0: + _fail(f"{' '.join(args[:2])}: {r.stderr}") + return r + + +def _setup_full(ws: str, repo: str, yaml: str) -> str: + """Register resources, project, action, plan use. Return plan_id.""" + # 1. Register git-checkout resource (acts as resource-ref rw mount) + _cli( + ws, + "resource", + "add", + "git-checkout", + "local/data-repo", + "--path", + repo, + "--branch", + "main", + ) + # 2. Register container-instance resource with image + _cli( + ws, + "resource", + "add", + "container-instance", + "local/wf17-container", + "--image", + "postgres:16", + "--format", + "plain", + ) + # 3. Create project linking the git-checkout resource + _cli( + ws, + "project", + "create", + "local/wf17-project", + "--resource", + "local/data-repo", + ) + # 4. Create action + _cli(ws, "action", "create", "--config", yaml) + # 5. Plan use with trusted profile + r = _cli( + ws, + "plan", + "use", + "local/apply-schema-migration", + "local/wf17-project", + "--automation-profile", + "trusted", + "--format", + "plain", + ) + pid = _plan_id(r.stdout) + if not pid: + _fail(f"no plan_id:\n{r.stdout}") + return pid + + +# -- Test commands ---------------------------------------------------------- + + +def wf17_container_instance_creation() -> None: + """Step 1: Register container-instance with dual mounts.""" + ws = setup_workspace(prefix="wf17_ctr_") + repo = _make_repo() + try: + # Register git-checkout as rw resource-ref mount + _cli( + ws, + "resource", + "add", + "git-checkout", + "local/data-repo", + "--path", + repo, + "--branch", + "main", + "--format", + "plain", + ) + r_git = _cli(ws, "resource", "show", "local/data-repo", "--format", "plain") + if "git-checkout" not in r_git.stdout: + _fail(f"not git-checkout:\n{r_git.stdout}") + + # Register container-instance with image (host-path ro mount) + _cli( + ws, + "resource", + "add", + "container-instance", + "local/wf17-container", + "--image", + "postgres:16", + "--format", + "plain", + ) + r_ctr = _cli( + ws, "resource", "show", "local/wf17-container", "--format", "plain" + ) + if "container-instance" not in r_ctr.stdout: + _fail(f"not container-instance:\n{r_ctr.stdout}") + + # Verify container-instance is in CONTAINER_RESOURCE_TYPES + if "container-instance" not in CONTAINER_RESOURCE_TYPES: + _fail("container-instance not in CONTAINER_RESOURCE_TYPES") + + # Verify both resources are listed + r_list = _cli(ws, "resource", "list", "--format", "plain") + if "local/data-repo" not in r_list.stdout: + _fail(f"data-repo missing:\n{r_list.stdout}") + if "local/wf17-container" not in r_list.stdout: + _fail(f"wf17-container missing:\n{r_list.stdout}") + + print("wf17-container-ok") + finally: + shutil.rmtree(repo, ignore_errors=True) + cleanup_workspace(ws) + + +def wf17_project_execution_env() -> None: + """Step 2: Create project, set execution environment override.""" + ws = setup_workspace(prefix="wf17_env_") + repo = _make_repo() + try: + # Register resources + _cli( + ws, + "resource", + "add", + "git-checkout", + "local/data-repo", + "--path", + repo, + "--branch", + "main", + ) + _cli( + ws, + "resource", + "add", + "container-instance", + "local/wf17-container", + "--image", + "postgres:16", + ) + # Create project with resource link + _cli( + ws, + "project", + "create", + "local/wf17-project", + "--description", + "WF17 explicit container", + "--resource", + "local/data-repo", + "--format", + "plain", + ) + r = _cli(ws, "project", "show", "local/wf17-project", "--format", "plain") + if "local/wf17-project" not in r.stdout: + _fail(f"project missing:\n{r.stdout}") + + # Verify execution environment resolver priority chain + resolver = ExecutionEnvironmentResolver() + # Default resolves to host + default_env = resolver.resolve() + if default_env != ExecutionEnvironment.HOST: + _fail(f"expected HOST default: {default_env}") + # Project-level override to container + proj_env = resolver.resolve(project_env="container") + if proj_env != ExecutionEnvironment.CONTAINER: + _fail(f"expected CONTAINER override: {proj_env}") + # Plan-level overrides project-level + plan_env = resolver.resolve(plan_env="host", project_env="container") + if plan_env != ExecutionEnvironment.HOST: + _fail(f"expected HOST plan override: {plan_env}") + # Validate container availability with container-instance type + is_valid = resolver.validate_container_available(["container-instance"]) + if not is_valid: + _fail("container-instance should be valid container resource") + + print("wf17-env-ok") + finally: + shutil.rmtree(repo, ignore_errors=True) + cleanup_workspace(ws) + + +def wf17_plan_with_container() -> None: + """Step 3: plan use with --automation-profile trusted.""" + ws = setup_workspace(prefix="wf17_plan_") + repo = _make_repo() + yaml = write_yaml(_ACTION_YAML) + try: + pid = _setup_full(ws, repo, yaml) + r = _cli(ws, "plan", "status", pid, "--format", "plain") + if pid not in r.stdout: + _fail(f"plan_id missing:\n{r.stdout}") + print("wf17-plan-ok") + finally: + os.unlink(yaml) + shutil.rmtree(repo, ignore_errors=True) + cleanup_workspace(ws) + + +def wf17_execute_routed_to_container() -> None: + """Step 4: plan execute + domain-level container routing verification.""" + ws = setup_workspace(prefix="wf17_exec_") + repo = _make_repo() + yaml = write_yaml(_ACTION_YAML) + try: + pid = _setup_full(ws, repo, yaml) + # CLI plan execute — controlled rejection (no real LLM) + r = run_cli("plan", "execute", pid, workspace=ws, env_extra=_ENV) + out = r.stdout + r.stderr + if "INTERNAL" in out or "Traceback" in out: + _fail(f"plan execute crashed:\n{out}") + + # Domain-level container activation with mock runner + clear_lifecycle_registry() + runner = _MockRunner() + runner.set_up_result(cid="ff17a0e1c0112233aabb", ws="/workspaces/data") + rid = "01WF17TEST0000000000010" + tracker = activate_container(rid, repo, run_command=runner, session_id=pid) + if tracker.current_state != ContainerLifecycleState.RUNNING: + _fail(f"expected RUNNING: {tracker.current_state}") + if tracker.container_id != "ff17a0e1c0112233aabb": + _fail(f"container_id: {tracker.container_id}") + if tracker.workspace_path != "/workspaces/data": + _fail(f"workspace_path: {tracker.workspace_path}") + if tracker.host_workspace_path != os.path.realpath(repo): + _fail(f"host_workspace_path: {tracker.host_workspace_path}") + if len(runner.up_calls) != 1: + _fail(f"up_calls: {len(runner.up_calls)}") + if "--workspace-folder" not in runner.up_calls[0][0]: + _fail(f"missing --workspace-folder: {runner.up_calls[0][0]}") + + # Verify session ID propagation + retrieved = get_lifecycle_tracker(rid) + if retrieved.session_id != pid: + _fail(f"session_id: {retrieved.session_id}") + + # Verify execution environment resolver routes to container + resolver = ExecutionEnvironmentResolver() + env = resolver.resolve_and_validate( + linked_resource_types=["container-instance", "git-checkout"], + project_name="local/wf17-project", + project_env="container", + ) + if env != ExecutionEnvironment.CONTAINER: + _fail(f"expected CONTAINER resolved env: {env}") + + print("wf17-exec-ok") + finally: + clear_lifecycle_registry() + os.unlink(yaml) + shutil.rmtree(repo, ignore_errors=True) + cleanup_workspace(ws) + + +def wf17_apply_from_container() -> None: + """Step 5: plan apply + domain-level mount path verification.""" + ws = setup_workspace(prefix="wf17_apply_") + repo = _make_repo() + yaml = write_yaml(_ACTION_YAML) + try: + pid = _setup_full(ws, repo, yaml) + # CLI lifecycle-apply — controlled rejection + r = run_cli("plan", "lifecycle-apply", pid, workspace=ws, env_extra=_ENV) + out = r.stdout + r.stderr + if "INTERNAL" in out or "Traceback" in out: + _fail(f"lifecycle-apply crashed:\n{out}") + + # Domain-level dual-mount path tracking + clear_lifecycle_registry() + runner = _MockRunner() + runner.set_up_result(cid="ff17a00e00112233aabb", ws="/workspaces/data") + rid = "01WF17TEST0000000000020" + activate_container(rid, repo, run_command=runner, session_id=pid) + t = get_lifecycle_tracker(rid) + + # Verify rw mount (host workspace path = repo) + if t.host_workspace_path != os.path.realpath(repo): + _fail(f"host_workspace_path: {t.host_workspace_path}") + # Verify container workspace path + if t.workspace_path != "/workspaces/data": + _fail(f"workspace_path: {t.workspace_path}") + + # Stop container (simulates post-apply cleanup) + stop_container(rid, run_command=runner) + final = get_lifecycle_tracker(rid) + if final.current_state != ContainerLifecycleState.STOPPED: + _fail(f"expected STOPPED: {final.current_state}") + if len(runner.stop_calls) != 1: + _fail(f"stop_calls: {len(runner.stop_calls)}") + + print("wf17-apply-ok") + finally: + clear_lifecycle_registry() + os.unlink(yaml) + shutil.rmtree(repo, ignore_errors=True) + cleanup_workspace(ws) + + +# -- Dispatcher ------------------------------------------------------------- + +_COMMANDS: dict[str, Callable[[], None]] = { + "wf17-container-instance-creation": wf17_container_instance_creation, + "wf17-project-execution-env": wf17_project_execution_env, + "wf17-plan-with-container": wf17_plan_with_container, + "wf17-execute-routed-to-container": wf17_execute_routed_to_container, + "wf17-apply-from-container": wf17_apply_from_container, +} + + +def main() -> int: + """Entry point called by Robot Framework ``Run Process``.""" + if len(sys.argv) < 2: + print(f"Usage: helper_wf17_explicit_container.py <{'|'.join(_COMMANDS)}>") + return 1 + cmd = sys.argv[1] + handler = _COMMANDS.get(cmd) + if handler is None: + print(f"Unknown command: {cmd}") + return 1 + reset_global_state() + handler() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/robot/wf17_explicit_container.robot b/robot/wf17_explicit_container.robot new file mode 100644 index 000000000..7e2ccde74 --- /dev/null +++ b/robot/wf17_explicit_container.robot @@ -0,0 +1,74 @@ +*** Settings *** +Documentation Integration test for Specification Workflow Example 17: +... Explicit Container with Directory Mount. +... +... Exercises explicit container-instance creation with +... dual mounts (resource-ref rw + host-path ro), project +... execution environment override, and container tool +... routing using mocked LLM providers and mocked +... container operations. +Force Tags wf17 integration explicit-container +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment With Database Isolation +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_wf17_explicit_container.py + +*** Test Cases *** +WF17 Step 1 Container Instance Creation + [Documentation] Register container-instance resource with dual + ... mounts (resource-ref rw + host-path ro). Verify + ... the resource is persisted with image and mount + ... metadata. + [Timeout] 120s + ${result}= Run Process ${PYTHON} ${HELPER} wf17-container-instance-creation cwd=${WORKSPACE} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf17-container-ok + +WF17 Step 2 Project Execution Environment + [Documentation] Create project, link container-instance resource, + ... and set execution environment with override + ... priority (project-level ``container``). + [Timeout] 120s + ${result}= Run Process ${PYTHON} ${HELPER} wf17-project-execution-env cwd=${WORKSPACE} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf17-env-ok + +WF17 Step 3 Plan With Container + [Documentation] Create action and ``plan use`` with trusted + ... automation profile targeting the container + ... project. Verify plan is created in strategize + ... phase. + [Timeout] 120s + ${result}= Run Process ${PYTHON} ${HELPER} wf17-plan-with-container cwd=${WORKSPACE} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf17-plan-ok + +WF17 Step 4 Execute Routed To Container + [Documentation] ``plan execute`` routes tool invocations to the + ... container. Verify execution routes through mocked + ... container operations and session ID propagation. + [Timeout] 120s + ${result}= Run Process ${PYTHON} ${HELPER} wf17-execute-routed-to-container cwd=${WORKSPACE} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf17-exec-ok + +WF17 Step 5 Apply From Container + [Documentation] ``plan apply --yes`` writes changes back to host + ... via bind mount. Verify changes synced from + ... container and container stopped after apply. + [Timeout] 120s + ${result}= Run Process ${PYTHON} ${HELPER} wf17-apply-from-container cwd=${WORKSPACE} timeout=120s on_timeout=kill + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} wf17-apply-ok -- 2.52.0