agentenv-framework 0.9.1254__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent_env/__init__.py +0 -0
- agent_env/a2a_agent/__init__.py +23 -0
- agent_env/a2a_agent/a2a_agent.py +600 -0
- agent_env/a2a_agent/conversation_store.py +356 -0
- agent_env/a2a_agent/object_transfer.py +609 -0
- agent_env/a2a_agent/protocol.py +238 -0
- agent_env/a2a_agent/store.py +201 -0
- agent_env/a2a_agent/validator.py +901 -0
- agent_env/artifact/__init__.py +48 -0
- agent_env/artifact/artifact.py +74 -0
- agent_env/artifact/artifacts/__init__.py +0 -0
- agent_env/artifact/artifacts/cli.py +83 -0
- agent_env/artifact/artifacts/docker_image.py +457 -0
- agent_env/artifact/artifacts/environment.py +89 -0
- agent_env/artifact/artifacts/environment_universe.py +156 -0
- agent_env/artifact/artifacts/file.py +214 -0
- agent_env/artifact/artifacts/file_artifact_universe.py +206 -0
- agent_env/artifact/artifacts/skill.py +206 -0
- agent_env/artifact/artifacts/vm_image.py +95 -0
- agent_env/artifact/ref.py +15 -0
- agent_env/artifact/registry.py +251 -0
- agent_env/artifact/store.py +295 -0
- agent_env/artifact/universe.py +38 -0
- agent_env/attribution.py +41 -0
- agent_env/bundle/__init__.py +4 -0
- agent_env/bundle/_fs.py +128 -0
- agent_env/bundle/authoring.py +239 -0
- agent_env/bundle/installed.py +175 -0
- agent_env/bundle/ledger.py +269 -0
- agent_env/bundle/materialize.py +204 -0
- agent_env/bundle/parse.py +594 -0
- agent_env/bundle/plan.py +414 -0
- agent_env/bundle/resolve.py +404 -0
- agent_env/bundle/run.py +361 -0
- agent_env/cli/__init__.py +69 -0
- agent_env/cli/__main__.py +10 -0
- agent_env/cli/_installers.py +576 -0
- agent_env/cli/_plugin_changes.py +947 -0
- agent_env/cli/a2a_agent/__init__.py +22 -0
- agent_env/cli/a2a_agent/add_skill.py +97 -0
- agent_env/cli/a2a_agent/deploy.py +60 -0
- agent_env/cli/a2a_agent/get.py +15 -0
- agent_env/cli/a2a_agent/get_instance.py +26 -0
- agent_env/cli/a2a_agent/put.py +78 -0
- agent_env/cli/a2a_agent/validate.py +56 -0
- agent_env/cli/artifact/__init__.py +20 -0
- agent_env/cli/artifact/cli.py +83 -0
- agent_env/cli/artifact/environment.py +44 -0
- agent_env/cli/artifact/environment_universe.py +163 -0
- agent_env/cli/artifact/file_artifact_universe.py +281 -0
- agent_env/cli/artifact/skill.py +128 -0
- agent_env/cli/banner.py +25 -0
- agent_env/cli/config.py +263 -0
- agent_env/cli/env/__init__.py +31 -0
- agent_env/cli/env/deploy.py +112 -0
- agent_env/cli/env/gateway.py +57 -0
- agent_env/cli/env/get_instance.py +40 -0
- agent_env/cli/env/mcp_server.py +410 -0
- agent_env/cli/env/multi.py +282 -0
- agent_env/cli/env/service_db.py +99 -0
- agent_env/cli/env/snapshot.py +35 -0
- agent_env/cli/env/state/__init__.py +6 -0
- agent_env/cli/env/state/init.py +108 -0
- agent_env/cli/env/state/teardown.py +44 -0
- agent_env/cli/env/website.py +216 -0
- agent_env/cli/env/website_browser.py +67 -0
- agent_env/cli/eval/__init__.py +16 -0
- agent_env/cli/eval/add_tasks.py +63 -0
- agent_env/cli/eval/create.py +49 -0
- agent_env/cli/eval/run.py +194 -0
- agent_env/cli/identity/__init__.py +5 -0
- agent_env/cli/identity/client_identity.py +37 -0
- agent_env/cli/plugin.py +438 -0
- agent_env/cli/run.py +285 -0
- agent_env/cli/task/__init__.py +21 -0
- agent_env/cli/task/create.py +109 -0
- agent_env/cli/task/get.py +17 -0
- agent_env/cli/task/get_instance.py +34 -0
- agent_env/cli/task/run.py +615 -0
- agent_env/cli/task/validate.py +31 -0
- agent_env/cli/up.py +123 -0
- agent_env/cli/utils.py +183 -0
- agent_env/config/__init__.py +50 -0
- agent_env/config/describe.py +1067 -0
- agent_env/config/errors.py +13 -0
- agent_env/config/loader.py +245 -0
- agent_env/config/model.py +154 -0
- agent_env/config/paths.py +25 -0
- agent_env/config/plugin_tables.py +101 -0
- agent_env/config/provenance.py +134 -0
- agent_env/config/runtime.py +1064 -0
- agent_env/config/snapshot.py +81 -0
- agent_env/entity_refs.py +186 -0
- agent_env/env/__init__.py +49 -0
- agent_env/env/env.py +433 -0
- agent_env/env/env_artifact_store.py +87 -0
- agent_env/env/envs/__init__.py +7 -0
- agent_env/env/envs/_deployment.py +281 -0
- agent_env/env/envs/gateway_server.py +32 -0
- agent_env/env/envs/mcp_server.py +387 -0
- agent_env/env/envs/multi_env.py +780 -0
- agent_env/env/envs/service_db/Dockerfile +20 -0
- agent_env/env/envs/service_db/Dockerfile.db-mcp +42 -0
- agent_env/env/envs/service_db/Dockerfile.db-web +8 -0
- agent_env/env/envs/service_db.py +92 -0
- agent_env/env/envs/website.py +294 -0
- agent_env/env/envs/website_browser/Dockerfile +40 -0
- agent_env/env/envs/website_browser/__init__.py +17 -0
- agent_env/env/envs/website_browser/entrypoint.sh +13 -0
- agent_env/env/gateway/Dockerfile +14 -0
- agent_env/env/gateway/__init__.py +37 -0
- agent_env/env/gateway/clock.py +184 -0
- agent_env/env/gateway/constants.py +190 -0
- agent_env/env/gateway/entrypoint.py +156 -0
- agent_env/env/gateway/gateway.py +1095 -0
- agent_env/env/gateway/get_time.py +60 -0
- agent_env/env/gateway/requirements.txt +4 -0
- agent_env/env/gateway/triggers.py +1193 -0
- agent_env/env/legacy_protocol.py +146 -0
- agent_env/env/registry.py +93 -0
- agent_env/env/snapshot_store.py +372 -0
- agent_env/env/store.py +411 -0
- agent_env/eval/__init__.py +14 -0
- agent_env/eval/eval.py +68 -0
- agent_env/eval/store.py +116 -0
- agent_env/examples/hello/README.md +9 -0
- agent_env/examples/hello/artifacts/greeting/check.sh +1 -0
- agent_env/examples/hello/artifacts/greeting/hello.txt +1 -0
- agent_env/examples/hello/tasks/hello.json +10 -0
- agent_env/explorer/__init__.py +12 -0
- agent_env/explorer/app.py +277 -0
- agent_env/explorer/openapi_docs.py +242 -0
- agent_env/explorer/plugin.py +95 -0
- agent_env/explorer/routers/__init__.py +1 -0
- agent_env/explorer/routers/common.py +174 -0
- agent_env/explorer/routers/conversations.py +25 -0
- agent_env/explorer/routers/objects.py +112 -0
- agent_env/explorer/routers/runs.py +475 -0
- agent_env/plugins/__init__.py +59 -0
- agent_env/plugins/_cli.py +244 -0
- agent_env/plugins/_discovery.py +97 -0
- agent_env/plugins/_inventory.py +262 -0
- agent_env/plugins/_registration.py +268 -0
- agent_env/plugins/_report.py +47 -0
- agent_env/plugins/_requirements.py +55 -0
- agent_env/providers/__init__.py +37 -0
- agent_env/providers/env_providers/__init__.py +12 -0
- agent_env/providers/env_providers/constants.py +13 -0
- agent_env/providers/env_providers/env_gateway_provider.py +1354 -0
- agent_env/providers/env_providers/env_provider.py +237 -0
- agent_env/providers/env_providers/env_server_provider.py +133 -0
- agent_env/providers/env_state/__init__.py +49 -0
- agent_env/providers/env_state/env_state_provider.py +554 -0
- agent_env/providers/env_state/local_postgres.py +390 -0
- agent_env/providers/env_state/store.py +112 -0
- agent_env/providers/sandbox_providers/__init__.py +29 -0
- agent_env/providers/sandbox_providers/chained_sandbox_provider.py +93 -0
- agent_env/providers/sandbox_providers/e2b/__init__.py +6 -0
- agent_env/providers/sandbox_providers/e2b/provider.py +376 -0
- agent_env/providers/sandbox_providers/e2b/sandbox.py +342 -0
- agent_env/providers/sandbox_providers/e2b/template.py +158 -0
- agent_env/providers/sandbox_providers/local_sandbox.py +408 -0
- agent_env/providers/sandbox_providers/modal_sandbox.py +500 -0
- agent_env/providers/sandbox_providers/modal_vm_sandbox.py +466 -0
- agent_env/providers/sandbox_providers/sandbox.py +387 -0
- agent_env/providers/sandbox_providers/sandbox_provider.py +614 -0
- agent_env/py.typed +0 -0
- agent_env/runner/__init__.py +16 -0
- agent_env/runner/local_runner.py +190 -0
- agent_env/runner/runner.py +127 -0
- agent_env/runner/store.py +94 -0
- agent_env/store/__init__.py +126 -0
- agent_env/store/_google.py +68 -0
- agent_env/store/base.py +33 -0
- agent_env/store/document_store/__init__.py +60 -0
- agent_env/store/document_store/document_store.py +519 -0
- agent_env/store/document_store/dynamodb_document_store.py +306 -0
- agent_env/store/document_store/evaluation.py +199 -0
- agent_env/store/document_store/firestore_mongo_document_store.py +242 -0
- agent_env/store/document_store/mongo_document_store.py +259 -0
- agent_env/store/document_store/sqlite_document_store.py +314 -0
- agent_env/store/ids.py +123 -0
- agent_env/store/image_store/__init__.py +34 -0
- agent_env/store/image_store/ecr_image_store.py +131 -0
- agent_env/store/image_store/google_credentials.py +113 -0
- agent_env/store/image_store/image_store.py +113 -0
- agent_env/store/image_store/local_registry_image_store.py +106 -0
- agent_env/store/image_store/oci_registry_credentials.py +196 -0
- agent_env/store/local_state.py +27 -0
- agent_env/store/object_store/__init__.py +24 -0
- agent_env/store/object_store/gcs_object_store.py +415 -0
- agent_env/store/object_store/local_object_store.py +143 -0
- agent_env/store/object_store/object_store.py +198 -0
- agent_env/store/object_store/s3_object_store.py +354 -0
- agent_env/store/query.py +112 -0
- agent_env/store/routing.py +609 -0
- agent_env/store/secret_store/__init__.py +19 -0
- agent_env/store/secret_store/aws_secrets_manager_secret_store.py +293 -0
- agent_env/store/secret_store/gcp_secret_manager_secret_store.py +319 -0
- agent_env/store/secret_store/local_secret_store.py +47 -0
- agent_env/store/secret_store/secret_store.py +19 -0
- agent_env/task/__init__.py +27 -0
- agent_env/task/interrupts.py +109 -0
- agent_env/task/registry.py +27 -0
- agent_env/task/step_journal.py +99 -0
- agent_env/task/store.py +940 -0
- agent_env/task/task.py +815 -0
- agent_env/task/teardown.py +142 -0
- agent_env/task_step/__init__.py +47 -0
- agent_env/task_step/context.py +195 -0
- agent_env/task_step/context_ops.py +224 -0
- agent_env/task_step/registry.py +182 -0
- agent_env/task_step/review_store.py +118 -0
- agent_env/task_step/snapshot_utils/__init__.py +2 -0
- agent_env/task_step/snapshot_utils/agent_state_capture.py +261 -0
- agent_env/task_step/snapshot_utils/snapshot_series.py +631 -0
- agent_env/task_step/store.py +129 -0
- agent_env/task_step/task_step.py +173 -0
- agent_env/task_step/task_steps/__init__.py +27 -0
- agent_env/task_step/task_steps/a2a_agent_validator/__init__.py +31 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/README.md +158 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.m4a +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp3 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.mp4 +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.ogg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/clip.wav +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/document.pdf +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.gif +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.jpg +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/fixtures/red.png +0 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_card.py +103 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_config_identity.py +69 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_agent_mcp.py +178 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_core_protocol.py +67 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_install.py +115 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution.py +124 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_litellm_attribution_runtime.py +173 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_modalities.py +270 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_peer_agents.py +102 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_role.py +151 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_skill_config.py +194 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_snapshot.py +111 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_system_prompt.py +215 -0
- agent_env/task_step/task_steps/a2a_agent_validator/verify_a2a_trajectory.py +164 -0
- agent_env/task_step/task_steps/add_skills.py +240 -0
- agent_env/task_step/task_steps/apply_server_config.py +288 -0
- agent_env/task_step/task_steps/collect_artifacts.py +940 -0
- agent_env/task_step/task_steps/deploy_agent.py +626 -0
- agent_env/task_step/task_steps/deploy_env.py +197 -0
- agent_env/task_step/task_steps/deploy_human_agent.py +100 -0
- agent_env/task_step/task_steps/deploy_sandbox.py +170 -0
- agent_env/task_step/task_steps/env_card_validator/__init__.py +4 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_card.py +109 -0
- agent_env/task_step/task_steps/env_card_validator/verify_env_core_protocol.py +108 -0
- agent_env/task_step/task_steps/install_agent.py +376 -0
- agent_env/task_step/task_steps/load_artifact.py +775 -0
- agent_env/task_step/task_steps/mcp_cli_builder/__init__.py +4 -0
- agent_env/task_step/task_steps/mcp_cli_builder/build_mcp_cli.py +201 -0
- agent_env/task_step/task_steps/mcp_cli_builder/codegen.py +627 -0
- agent_env/task_step/task_steps/mcp_env_validator/__init__.py +44 -0
- agent_env/task_step/task_steps/mcp_env_validator/validation_gate_aggregator.py +102 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_env_assessment.py +78 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_mcp_tool_schema.py +152 -0
- agent_env/task_step/task_steps/mcp_env_validator/verify_spec_conformance.py +486 -0
- agent_env/task_step/task_steps/modify_env_tool_access.py +80 -0
- agent_env/task_step/task_steps/multienv_validator/__init__.py +3 -0
- agent_env/task_step/task_steps/multienv_validator/combine_universe_verdicts.py +107 -0
- agent_env/task_step/task_steps/multienv_validator/universe_comparison.py +253 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_agent_judge.py +180 -0
- agent_env/task_step/task_steps/multienv_validator/verify_universe_roundtrip.py +241 -0
- agent_env/task_step/task_steps/peer_agents.py +92 -0
- agent_env/task_step/task_steps/prompt_agent.py +900 -0
- agent_env/task_step/task_steps/register_agent_triggers.py +113 -0
- agent_env/task_step/task_steps/register_env_triggers.py +97 -0
- agent_env/task_step/task_steps/reset_env.py +71 -0
- agent_env/task_step/task_steps/review.py +152 -0
- agent_env/task_step/task_steps/run_code.py +461 -0
- agent_env/task_step/task_steps/run_code_runner.py +52 -0
- agent_env/task_step/task_steps/run_docker_container.py +380 -0
- agent_env/task_step/task_steps/sandbox_utils/__init__.py +0 -0
- agent_env/task_step/task_steps/sandbox_utils/sandbox_utils.py +79 -0
- agent_env/task_step/task_steps/snapshot_agent_state.py +205 -0
- agent_env/task_step/task_steps/snapshot_env.py +637 -0
- agent_env/task_step/task_steps/sync_env_clock.py +132 -0
- agent_env/task_step/task_steps/teardown_sandboxes.py +170 -0
- agent_env/task_step/task_steps/verifiers/__init__.py +23 -0
- agent_env/task_step/task_steps/verifiers/agent_prompt_response_verifier.py +166 -0
- agent_env/task_step/task_steps/verifiers/aggregate_verifiers.py +90 -0
- agent_env/task_step/task_steps/verifiers/env_outcome_verifier.py +127 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/__init__.py +2 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/frame_selection.py +377 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/judge_output_format.py +1293 -0
- agent_env/task_step/task_steps/verifiers/judge_utils/trajectory_filter.py +514 -0
- agent_env/task_step/task_steps/verifiers/rubrics_verifier.py +1318 -0
- agent_env/task_step/task_steps/verifiers/run_container_unit_tests_verifier.py +434 -0
- agent_env/task_step/task_steps/verifiers/scoring.py +40 -0
- agent_env/task_step/task_steps/verifiers/verify_sandbox.py +308 -0
- agent_env/task_step/thread_work.py +61 -0
- agent_env/utils/__init__.py +0 -0
- agent_env/utils/card_naming.py +108 -0
- agent_env/utils/deprecation.py +109 -0
- agent_env/utils/docker_build.py +29 -0
- agent_env/utils/exec_retry.py +77 -0
- agent_env/utils/litellm_attribution.py +59 -0
- agent_env/utils/paths.py +20 -0
- agentenv_framework-0.9.1254.dist-info/METADATA +394 -0
- agentenv_framework-0.9.1254.dist-info/RECORD +312 -0
- agentenv_framework-0.9.1254.dist-info/WHEEL +4 -0
- agentenv_framework-0.9.1254.dist-info/entry_points.txt +5 -0
- agentenv_framework-0.9.1254.dist-info/licenses/LICENSE +202 -0
- agentenv_framework-0.9.1254.dist-info/licenses/NOTICE +4 -0
- agentenv_framework-0.9.1254.dist-info/licenses/THIRD_PARTY_NOTICES.md +7665 -0
|
@@ -0,0 +1,1354 @@
|
|
|
1
|
+
"""The gateway environment provider: MCP servers and websites behind one agent-env gateway, on a VM or on Modal containers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import functools
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from typing import TYPE_CHECKING, Optional
|
|
14
|
+
|
|
15
|
+
from agent_env.attribution import Attribution
|
|
16
|
+
from agent_env.env.env import DeployedGatewayEnv, _mcp_url
|
|
17
|
+
from agent_env.env.gateway import AGENT_ENV_GATEWAY_MCP_PORT, GatewayMode
|
|
18
|
+
from agent_env.env.gateway.constants import DEFAULT_MCP_SERVER_NAME, WELL_KNOWN_PATH, random_mcp_server_name
|
|
19
|
+
from agent_env.env.envs.service_db import (
|
|
20
|
+
DATABASE_SERVICE_NAME,
|
|
21
|
+
DB_MCP_CONTAINER_PORT,
|
|
22
|
+
DB_MCP_PORT,
|
|
23
|
+
DB_MCP_SERVICE_NAME,
|
|
24
|
+
DB_WEB_PORT,
|
|
25
|
+
PGWEB_SERVICE_NAME,
|
|
26
|
+
)
|
|
27
|
+
from agent_env.providers.env_providers.constants import (
|
|
28
|
+
AGENT_ENV_WEBSITE_BACKEND_PORT,
|
|
29
|
+
AGENT_ENV_WEBSITE_BACKEND_SUFFIX,
|
|
30
|
+
AGENT_ENV_WEBSITE_FRONTEND_SUFFIX,
|
|
31
|
+
DOCKER_COMPOSE_PATH,
|
|
32
|
+
GATEWAY_APP_DIR,
|
|
33
|
+
GATEWAY_SERVICE_NAME,
|
|
34
|
+
)
|
|
35
|
+
from agent_env.providers.env_providers.env_provider import _SandboxEnvironmentProvider, _size_kwargs, _tool_names
|
|
36
|
+
from agent_env.providers.sandbox_providers.modal_sandbox import ModalSandboxProvider
|
|
37
|
+
from agent_env.providers.sandbox_providers.sandbox import Sandbox, VmSandbox
|
|
38
|
+
from agent_env.providers.sandbox_providers.sandbox_provider import SANDBOX_MODE_CONTAINER, SandboxProvider
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from collections.abc import Awaitable, Callable
|
|
42
|
+
|
|
43
|
+
from agent_env.artifact import DockerImageArtifact
|
|
44
|
+
from agent_env.env.env import DeployedEnv, Env
|
|
45
|
+
from agent_env.providers.env_state import DatabaseStateProvider, EnvStateInstance, LocalPostgresStoreSpec
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
# MCP server health-check budget. Some servers wait on an upstream before they bind; a server
|
|
50
|
+
# that is up passes its first probe, so the long start period only delays failing a broken one.
|
|
51
|
+
MCP_HC_RETRIES = 30
|
|
52
|
+
|
|
53
|
+
MCP_HC_START_PERIOD = "60s"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class MCPServerConfig:
|
|
58
|
+
"""Configuration for an MCP server in a gateway deployment."""
|
|
59
|
+
|
|
60
|
+
image: str
|
|
61
|
+
environment_name: str
|
|
62
|
+
extra_env_vars: dict[str, str] | None = None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass
|
|
66
|
+
class DeployedGateway:
|
|
67
|
+
"""Result of a gateway deployment."""
|
|
68
|
+
|
|
69
|
+
gateway_url: str
|
|
70
|
+
mcp_url: str
|
|
71
|
+
db_web_url: str
|
|
72
|
+
db_mcp_url: str | None = None
|
|
73
|
+
website_frontend_urls: dict[str, str] | None = None
|
|
74
|
+
mcp_server_name: str | None = None
|
|
75
|
+
# Ids of the EnvStateInstance record(s) this deploy acquired; the caller records them
|
|
76
|
+
# on DeployedGatewayEnv.env_state_instance_ids. One entry today (one backend per deploy).
|
|
77
|
+
env_state_instance_ids: list[str] = field(default_factory=list)
|
|
78
|
+
# The env card the readiness probe read, and when (ISO 8601, UTC).
|
|
79
|
+
environment_card: dict | None = None
|
|
80
|
+
environment_card_read_at_utc: str | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class WebsiteConfig:
|
|
85
|
+
"""Configuration for a website (frontend + backend) in a gateway deployment."""
|
|
86
|
+
|
|
87
|
+
backend_image: str
|
|
88
|
+
frontend_image: str
|
|
89
|
+
environment_name: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass
|
|
93
|
+
class SidecarConfig:
|
|
94
|
+
"""A plain co-deployed container on the gateway that is *not* an MCP server.
|
|
95
|
+
|
|
96
|
+
Rendered into the gateway's docker-compose as its own service, with its env
|
|
97
|
+
(secrets included) injected via the compose ``environment:`` block — so nothing
|
|
98
|
+
lands on a ``docker run`` command line / the host process table — and its port
|
|
99
|
+
published on the VM host so callers can reach it over the sandbox tunnel.
|
|
100
|
+
|
|
101
|
+
``image_artifact`` is side-loaded onto the VM from the object store exactly like the
|
|
102
|
+
MCP server images (``load_docker_images``): no external-registry pull happens at
|
|
103
|
+
``docker compose up`` time, so the deploy has no registry dependency or ``docker login``.
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
environment_name: str
|
|
107
|
+
image_artifact: "DockerImageArtifact"
|
|
108
|
+
container_port: int
|
|
109
|
+
host_port: int
|
|
110
|
+
env: dict[str, str] | None = None
|
|
111
|
+
restart: str = "unless-stopped"
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def image(self) -> str:
|
|
115
|
+
"""The local image tag compose references — the same tag ``load_docker_images``
|
|
116
|
+
restores on the VM (the artifact's agent-env image name)."""
|
|
117
|
+
return self.image_artifact.image_name
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class EnvironmentGatewayProvider(_SandboxEnvironmentProvider):
|
|
121
|
+
"""Provider for creating gateway deployments."""
|
|
122
|
+
|
|
123
|
+
type = "gateway"
|
|
124
|
+
record_class = DeployedGatewayEnv
|
|
125
|
+
|
|
126
|
+
def __init__(self):
|
|
127
|
+
super().__init__()
|
|
128
|
+
self._sandbox: Sandbox | None = None
|
|
129
|
+
self._db_sandbox: Sandbox | None = None
|
|
130
|
+
self._pgweb_sandbox: Sandbox | None = None
|
|
131
|
+
self._db_mcp_sandbox: Sandbox | None = None
|
|
132
|
+
# Set by _deploy_gateway (external instance) or _build_local_store (local) on deploy, and by _reattach
|
|
133
|
+
self._state_provider: "DatabaseStateProvider | None" = None
|
|
134
|
+
self._state_instance: "EnvStateInstance | None" = None
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def sandbox(self) -> Sandbox | None:
|
|
138
|
+
return self._sandbox
|
|
139
|
+
|
|
140
|
+
async def close(self) -> None:
|
|
141
|
+
if self._state_provider is not None and self._state_instance is not None:
|
|
142
|
+
try:
|
|
143
|
+
await self._state_provider.teardown(self._state_instance)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.warning(f"Failed to tear down state backend {type(self._state_provider).__name__}: {e}")
|
|
146
|
+
finally:
|
|
147
|
+
self._state_provider = None
|
|
148
|
+
self._state_instance = None
|
|
149
|
+
await super().close()
|
|
150
|
+
self._db_sandbox = None
|
|
151
|
+
self._pgweb_sandbox = None
|
|
152
|
+
self._db_mcp_sandbox = None
|
|
153
|
+
|
|
154
|
+
async def install_changelog_triggers(self, environment_name: str) -> None:
|
|
155
|
+
"""Install audit triggers on the given service schema.
|
|
156
|
+
|
|
157
|
+
Thin shim over the state provider (which owns the changelog SQL). The provider +
|
|
158
|
+
instance are set by ``_build_local_store``/``_deploy_gateway`` (deploy) or ``_reattach``
|
|
159
|
+
(reattach); when neither ran (no state store wired — e.g. a standalone env reattached without one) this is
|
|
160
|
+
a no-op, skipping rather than guessing a local backend.
|
|
161
|
+
"""
|
|
162
|
+
from agent_env.providers.env_state import DatabaseStateProvider
|
|
163
|
+
|
|
164
|
+
state_provider, state_instance = self._state_provider, self._state_instance
|
|
165
|
+
if state_provider is None or state_instance is None:
|
|
166
|
+
logger.debug("install_changelog_triggers: no state provider/instance set; skipping")
|
|
167
|
+
return
|
|
168
|
+
if not isinstance(state_provider, DatabaseStateProvider):
|
|
169
|
+
return
|
|
170
|
+
|
|
171
|
+
if self._db_sandbox is not None:
|
|
172
|
+
async def store_exec(argv: list[str]) -> tuple[int, str, str]:
|
|
173
|
+
return await self._db_sandbox.exec_with_output(*argv)
|
|
174
|
+
else:
|
|
175
|
+
store_name = state_provider.env_state_docker_service_name()
|
|
176
|
+
|
|
177
|
+
async def store_exec(argv: list[str]) -> tuple[int, str, str]:
|
|
178
|
+
return await self._sandbox.exec_with_output(
|
|
179
|
+
"sudo", "docker", "compose", "-f", DOCKER_COMPOSE_PATH,
|
|
180
|
+
"exec", "-T", store_name, *argv,
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
await state_provider.install_changelog_triggers(
|
|
185
|
+
environment_name, instance=state_instance, store_exec=store_exec,
|
|
186
|
+
)
|
|
187
|
+
logger.info(f"Changelog triggers installed for schema '{environment_name}'")
|
|
188
|
+
except Exception as e:
|
|
189
|
+
logger.warning(f"Failed to install changelog triggers for schema '{environment_name}': {type(e).__name__}: {e}")
|
|
190
|
+
|
|
191
|
+
def _all_environments(self, mcp_servers, website_configs) -> list[str]:
|
|
192
|
+
"""The DB-client services (one schema each) for a deploy: MCP servers + website backends,
|
|
193
|
+
deduped. This is the authoritative list — it reflects any servers the gateway auto-adds (e.g.
|
|
194
|
+
website_browser) — and drives both the rendered compose and the state provider's ``prepare``."""
|
|
195
|
+
return list(dict.fromkeys(
|
|
196
|
+
[s.environment_name for s in mcp_servers]
|
|
197
|
+
+ [wc.environment_name for wc in (website_configs or [])]
|
|
198
|
+
))
|
|
199
|
+
|
|
200
|
+
def create_docker_compose(
|
|
201
|
+
self,
|
|
202
|
+
mcp_servers: list[MCPServerConfig],
|
|
203
|
+
gateway_image: str = "env-gateway",
|
|
204
|
+
gateway_port: int = AGENT_ENV_GATEWAY_MCP_PORT,
|
|
205
|
+
website_configs: list[WebsiteConfig] | None = None,
|
|
206
|
+
expose_gateway_port: bool = True,
|
|
207
|
+
gateway_mode: GatewayMode = GatewayMode.PERFORMANCE,
|
|
208
|
+
sidecars: list[SidecarConfig] | None = None,
|
|
209
|
+
*,
|
|
210
|
+
state_provider: "DatabaseStateProvider",
|
|
211
|
+
state_instance: "EnvStateInstance",
|
|
212
|
+
host_port: Optional[Callable[[int], int]] = None,
|
|
213
|
+
mcp_server_name: str | None = None,
|
|
214
|
+
) -> str:
|
|
215
|
+
"""Generate docker-compose.yml content for a gateway deployment onto a VM/laptop/arbitrary machine.
|
|
216
|
+
|
|
217
|
+
Supports MCP servers, websites, or both. Website containers (backend + frontend) are added
|
|
218
|
+
to the same docker network. The gateway receives WEBSITE_URLS env var for discovery.
|
|
219
|
+
|
|
220
|
+
Args:
|
|
221
|
+
mcp_servers: List of MCP server configurations for internal servers.
|
|
222
|
+
gateway_image: Docker image for the gateway.
|
|
223
|
+
gateway_port: Port to expose for the gateway.
|
|
224
|
+
website_configs: Optional list of website configurations (frontend + backend pairs).
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
docker-compose.yml content as a string.
|
|
228
|
+
|
|
229
|
+
Example output for mcp_servers=[MCPServerConfig("mcp-slack", "slack")]
|
|
230
|
+
and website_configs=[WebsiteConfig("slack-web-be", "slack-web-fe", "slack")]
|
|
231
|
+
(using AGENT_ENV_GATEWAY_MCP_PORT=18765):
|
|
232
|
+
|
|
233
|
+
services:
|
|
234
|
+
servicedb:
|
|
235
|
+
image: public.ecr.aws/docker/library/postgres:16-alpine
|
|
236
|
+
environment:
|
|
237
|
+
- POSTGRES_USER=agentenv
|
|
238
|
+
- POSTGRES_PASSWORD=agentenv
|
|
239
|
+
- POSTGRES_DB=agentenv
|
|
240
|
+
healthcheck:
|
|
241
|
+
test: ["CMD-SHELL", "pg_isready -U agentenv"]
|
|
242
|
+
interval: 5s
|
|
243
|
+
timeout: 5s
|
|
244
|
+
retries: 5
|
|
245
|
+
volumes:
|
|
246
|
+
- ./init-schemas.sql:/docker-entrypoint-initdb.d/init-schemas.sql
|
|
247
|
+
networks:
|
|
248
|
+
- env-network
|
|
249
|
+
|
|
250
|
+
slack:
|
|
251
|
+
image: mcp-slack
|
|
252
|
+
depends_on:
|
|
253
|
+
servicedb:
|
|
254
|
+
condition: service_healthy
|
|
255
|
+
environment:
|
|
256
|
+
- MCP_HOST=0.0.0.0
|
|
257
|
+
- MCP_PORT=<gateway_port>
|
|
258
|
+
- SERVICE_NAME=slack
|
|
259
|
+
- ENVIRONMENT_NAME=slack
|
|
260
|
+
- DATABASE_URL=postgresql://agentenv:agentenv@servicedb:5432/agentenv?options=-c%20search_path%3D%22slack%22,public
|
|
261
|
+
networks:
|
|
262
|
+
- env-network
|
|
263
|
+
|
|
264
|
+
gateway:
|
|
265
|
+
image: env-gateway
|
|
266
|
+
depends_on:
|
|
267
|
+
- slack
|
|
268
|
+
environment:
|
|
269
|
+
- MCP_HOST=0.0.0.0
|
|
270
|
+
- MCP_PORT=<gateway_port>
|
|
271
|
+
- MCP_SERVER_NAME=crm
|
|
272
|
+
- INTERNAL_MCP_SERVERS=slack
|
|
273
|
+
- INTERNAL_MCP_PORT=<gateway_port>
|
|
274
|
+
- WEBSITE_URLS=http://slack-website-frontend:80
|
|
275
|
+
- REST_PROXY_URLS=slack=http://slack-website-backend:8000
|
|
276
|
+
ports:
|
|
277
|
+
- "<gateway_port>:<gateway_port>"
|
|
278
|
+
networks:
|
|
279
|
+
- env-network
|
|
280
|
+
|
|
281
|
+
slack-website-backend:
|
|
282
|
+
image: slack-web-be
|
|
283
|
+
depends_on:
|
|
284
|
+
servicedb:
|
|
285
|
+
condition: service_healthy
|
|
286
|
+
environment:
|
|
287
|
+
- DATABASE_URL=postgresql://agentenv:agentenv@servicedb:5432/agentenv?options=-c%20search_path%3D%22slack%22,public
|
|
288
|
+
- SERVICE_NAME=slack
|
|
289
|
+
- ENVIRONMENT_NAME=slack
|
|
290
|
+
- PYTHONUNBUFFERED=1
|
|
291
|
+
healthcheck:
|
|
292
|
+
test: ["CMD", "curl", "-f", "http://localhost:8000/api/health"]
|
|
293
|
+
interval: 10s
|
|
294
|
+
timeout: 5s
|
|
295
|
+
start_period: 30s
|
|
296
|
+
retries: 3
|
|
297
|
+
networks:
|
|
298
|
+
- env-network
|
|
299
|
+
|
|
300
|
+
slack-website-frontend:
|
|
301
|
+
image: slack-web-fe
|
|
302
|
+
depends_on:
|
|
303
|
+
slack-website-backend:
|
|
304
|
+
condition: service_healthy
|
|
305
|
+
networks:
|
|
306
|
+
- env-network
|
|
307
|
+
|
|
308
|
+
networks:
|
|
309
|
+
env-network:
|
|
310
|
+
driver: bridge
|
|
311
|
+
"""
|
|
312
|
+
lines = ["services:"]
|
|
313
|
+
|
|
314
|
+
# MCP servers and website backends are both DB clients (one schema each).
|
|
315
|
+
environment_names = self._all_environments(mcp_servers, website_configs)
|
|
316
|
+
|
|
317
|
+
lines.extend(
|
|
318
|
+
state_provider.render_compose_containers(
|
|
319
|
+
environment_names, instance=state_instance, host_port=host_port
|
|
320
|
+
)
|
|
321
|
+
)
|
|
322
|
+
store_dep_lines = state_provider.docker_service_dependency()
|
|
323
|
+
|
|
324
|
+
# Add internal server services
|
|
325
|
+
website_configs = website_configs or []
|
|
326
|
+
website_backend_servers = {
|
|
327
|
+
wc.environment_name: f"{wc.environment_name}-{AGENT_ENV_WEBSITE_BACKEND_SUFFIX}"
|
|
328
|
+
for wc in website_configs
|
|
329
|
+
}
|
|
330
|
+
for server in mcp_servers:
|
|
331
|
+
db_url = state_provider.url_for_environment(server.environment_name, instance=state_instance)
|
|
332
|
+
lines.extend([
|
|
333
|
+
f" {server.environment_name}:",
|
|
334
|
+
f" image: {server.image}",
|
|
335
|
+
])
|
|
336
|
+
dep_lines = list(store_dep_lines)
|
|
337
|
+
if server.environment_name in website_backend_servers:
|
|
338
|
+
dep_lines += [
|
|
339
|
+
f" {website_backend_servers[server.environment_name]}:",
|
|
340
|
+
" condition: service_healthy",
|
|
341
|
+
]
|
|
342
|
+
if dep_lines:
|
|
343
|
+
lines.append(" depends_on:")
|
|
344
|
+
lines.extend(dep_lines)
|
|
345
|
+
lines.extend([
|
|
346
|
+
" environment:",
|
|
347
|
+
" - MCP_HOST=0.0.0.0",
|
|
348
|
+
f" - MCP_PORT={gateway_port}",
|
|
349
|
+
# Both names are injected during the service_name -> environment_name
|
|
350
|
+
# migration. The SDK resolves identity from ENVIRONMENT_NAME
|
|
351
|
+
# (then the card's own name) and no longer consults SERVICE_NAME; it stays
|
|
352
|
+
# only for images that read it themselves, and a later pass drops it.
|
|
353
|
+
f" - SERVICE_NAME={server.environment_name}",
|
|
354
|
+
f" - ENVIRONMENT_NAME={server.environment_name}",
|
|
355
|
+
f" - DATABASE_URL={db_url}",
|
|
356
|
+
])
|
|
357
|
+
if server.extra_env_vars:
|
|
358
|
+
for key, value in server.extra_env_vars.items():
|
|
359
|
+
lines.append(f" - {key}={value}")
|
|
360
|
+
lines.extend([
|
|
361
|
+
" healthcheck:",
|
|
362
|
+
f' test: ["CMD-SHELL", "python3 -c \\"import socket; s=socket.create_connection((\'localhost\',{gateway_port}),2); s.close()\\""]',
|
|
363
|
+
" interval: 5s",
|
|
364
|
+
" timeout: 5s",
|
|
365
|
+
f" retries: {MCP_HC_RETRIES}",
|
|
366
|
+
f" start_period: {MCP_HC_START_PERIOD}",
|
|
367
|
+
" networks:",
|
|
368
|
+
" - env-network",
|
|
369
|
+
"",
|
|
370
|
+
])
|
|
371
|
+
|
|
372
|
+
# Add gateway service
|
|
373
|
+
mcp_server_names = [s.environment_name for s in mcp_servers]
|
|
374
|
+
lines.extend([
|
|
375
|
+
f" {GATEWAY_SERVICE_NAME}:",
|
|
376
|
+
f" image: {gateway_image}",
|
|
377
|
+
])
|
|
378
|
+
dep_lines = list(store_dep_lines)
|
|
379
|
+
for name in mcp_server_names:
|
|
380
|
+
dep_lines += [f" {name}:", " condition: service_healthy"]
|
|
381
|
+
for backend_name in website_backend_servers.values():
|
|
382
|
+
dep_lines += [f" {backend_name}:", " condition: service_healthy"]
|
|
383
|
+
if dep_lines:
|
|
384
|
+
lines.append(" depends_on:")
|
|
385
|
+
lines.extend(dep_lines)
|
|
386
|
+
server_name = (mcp_server_name or DEFAULT_MCP_SERVER_NAME).replace("$", "$$") # compose would interpolate a bare $
|
|
387
|
+
gateway_env = [
|
|
388
|
+
" - MCP_HOST=0.0.0.0",
|
|
389
|
+
f" - MCP_PORT={gateway_port}",
|
|
390
|
+
f" - MCP_SERVER_NAME={server_name}",
|
|
391
|
+
f" - INTERNAL_MCP_SERVERS={','.join(mcp_server_names)}",
|
|
392
|
+
f" - INTERNAL_MCP_PORT={gateway_port}",
|
|
393
|
+
" - GATEWAY_TRAJECTORY_FILE=/var/log/agentenv/trajectory.jsonl",
|
|
394
|
+
]
|
|
395
|
+
# Build REST_PROXY_URLS: mcp-{name} for MCP servers + {name} for website backends
|
|
396
|
+
rest_proxy_urls = []
|
|
397
|
+
for server in mcp_servers:
|
|
398
|
+
rest_proxy_urls.append(
|
|
399
|
+
f"mcp-{server.environment_name}=http://{server.environment_name}:{gateway_port}"
|
|
400
|
+
)
|
|
401
|
+
if website_configs:
|
|
402
|
+
website_urls = ",".join(
|
|
403
|
+
f"{wc.environment_name}=http://{wc.environment_name}-{AGENT_ENV_WEBSITE_FRONTEND_SUFFIX}:80"
|
|
404
|
+
for wc in website_configs
|
|
405
|
+
)
|
|
406
|
+
gateway_env.append(f" - WEBSITE_URLS={website_urls}")
|
|
407
|
+
for wc in website_configs:
|
|
408
|
+
rest_proxy_urls.append(
|
|
409
|
+
f"{wc.environment_name}=http://{wc.environment_name}-{AGENT_ENV_WEBSITE_BACKEND_SUFFIX}:{AGENT_ENV_WEBSITE_BACKEND_PORT}"
|
|
410
|
+
)
|
|
411
|
+
if rest_proxy_urls:
|
|
412
|
+
gateway_env.append(f" - REST_PROXY_URLS={','.join(rest_proxy_urls)}")
|
|
413
|
+
gateway_env.append(f" - GATEWAY_MODE={gateway_mode.value}")
|
|
414
|
+
gateway_env.append(f" - SERVICE_DB_URL={state_provider.base_url(state_instance)}")
|
|
415
|
+
publish = host_port or (lambda port: port)
|
|
416
|
+
exposed_gateway_ports = []
|
|
417
|
+
if expose_gateway_port:
|
|
418
|
+
exposed_gateway_ports.extend([
|
|
419
|
+
" ports:",
|
|
420
|
+
f' - "{publish(gateway_port)}:{gateway_port}"',
|
|
421
|
+
])
|
|
422
|
+
lines.extend([
|
|
423
|
+
" environment:",
|
|
424
|
+
*gateway_env,
|
|
425
|
+
*exposed_gateway_ports,
|
|
426
|
+
" restart: on-failure",
|
|
427
|
+
" healthcheck:",
|
|
428
|
+
f' test: ["CMD-SHELL", "python3 -c \\"import socket; s=socket.create_connection((\'localhost\',{gateway_port}),2); s.close()\\""]',
|
|
429
|
+
" interval: 15s",
|
|
430
|
+
" timeout: 5s",
|
|
431
|
+
" retries: 10",
|
|
432
|
+
" start_period: 15s",
|
|
433
|
+
" networks:",
|
|
434
|
+
" - env-network",
|
|
435
|
+
"",
|
|
436
|
+
])
|
|
437
|
+
|
|
438
|
+
# Add website services
|
|
439
|
+
for wc in website_configs:
|
|
440
|
+
backend_name = f"{wc.environment_name}-{AGENT_ENV_WEBSITE_BACKEND_SUFFIX}"
|
|
441
|
+
frontend_name = f"{wc.environment_name}-{AGENT_ENV_WEBSITE_FRONTEND_SUFFIX}"
|
|
442
|
+
db_url = state_provider.url_for_environment(wc.environment_name, instance=state_instance)
|
|
443
|
+
lines.extend([
|
|
444
|
+
f" {backend_name}:",
|
|
445
|
+
f" image: {wc.backend_image}",
|
|
446
|
+
])
|
|
447
|
+
# depends_on the backend's readiness service.
|
|
448
|
+
lines.append(" depends_on:")
|
|
449
|
+
lines.extend(store_dep_lines)
|
|
450
|
+
lines.extend([
|
|
451
|
+
" environment:",
|
|
452
|
+
f" - DATABASE_URL={db_url}",
|
|
453
|
+
f" - SERVICE_NAME={wc.environment_name}",
|
|
454
|
+
f" - ENVIRONMENT_NAME={wc.environment_name}",
|
|
455
|
+
f" - PORT={AGENT_ENV_WEBSITE_BACKEND_PORT}",
|
|
456
|
+
" - PYTHONUNBUFFERED=1",
|
|
457
|
+
" healthcheck:",
|
|
458
|
+
f' test: ["CMD", "curl", "-f", "http://localhost:{AGENT_ENV_WEBSITE_BACKEND_PORT}/api/health"]',
|
|
459
|
+
" interval: 10s",
|
|
460
|
+
" timeout: 5s",
|
|
461
|
+
" start_period: 30s",
|
|
462
|
+
" retries: 3",
|
|
463
|
+
" networks:",
|
|
464
|
+
" - env-network",
|
|
465
|
+
"",
|
|
466
|
+
f" {frontend_name}:",
|
|
467
|
+
f" image: {wc.frontend_image}",
|
|
468
|
+
" depends_on:",
|
|
469
|
+
f" {backend_name}:",
|
|
470
|
+
" condition: service_healthy",
|
|
471
|
+
" networks:",
|
|
472
|
+
" - env-network",
|
|
473
|
+
"",
|
|
474
|
+
])
|
|
475
|
+
|
|
476
|
+
# Add sidecar services.
|
|
477
|
+
# Secrets go through the compose environment block, never a docker-run command line.
|
|
478
|
+
for sc in sidecars or []:
|
|
479
|
+
lines.extend([
|
|
480
|
+
f" {sc.environment_name}:",
|
|
481
|
+
f" image: {sc.image}",
|
|
482
|
+
])
|
|
483
|
+
if sc.env:
|
|
484
|
+
lines.append(" environment:")
|
|
485
|
+
for key, value in sc.env.items():
|
|
486
|
+
lines.append(f" - {key}={value}")
|
|
487
|
+
lines.extend([
|
|
488
|
+
" ports:",
|
|
489
|
+
f' - "{publish(sc.host_port)}:{sc.container_port}"',
|
|
490
|
+
f" restart: {sc.restart}",
|
|
491
|
+
" networks:",
|
|
492
|
+
" - env-network",
|
|
493
|
+
"",
|
|
494
|
+
])
|
|
495
|
+
|
|
496
|
+
# Add networks
|
|
497
|
+
lines.extend([
|
|
498
|
+
"networks:",
|
|
499
|
+
" env-network:",
|
|
500
|
+
" driver: bridge",
|
|
501
|
+
])
|
|
502
|
+
return "\n".join(lines)
|
|
503
|
+
|
|
504
|
+
async def deploy(
|
|
505
|
+
self,
|
|
506
|
+
env: Env,
|
|
507
|
+
sandbox_provider: SandboxProvider,
|
|
508
|
+
*,
|
|
509
|
+
gateway_mode: GatewayMode = GatewayMode.PERFORMANCE,
|
|
510
|
+
env_state_type: str | None = None,
|
|
511
|
+
env_state_instance_id: str | None = None,
|
|
512
|
+
ttl_seconds: int = 10800,
|
|
513
|
+
disk_size_gb: float = 10,
|
|
514
|
+
cpu: float | None = None,
|
|
515
|
+
memory_mb: int | None = None,
|
|
516
|
+
priority: Optional[int] = None,
|
|
517
|
+
attribution: Optional[Attribution] = None,
|
|
518
|
+
) -> DeployedGatewayEnv:
|
|
519
|
+
"""A gateway in front of the env, with the state store it acquires (local Postgres by default); returns the record.
|
|
520
|
+
|
|
521
|
+
A MultiEnv's gateway fronts its MCP servers and websites, a WebsiteEnv's its website, and any other env's its one MCP server.
|
|
522
|
+
"""
|
|
523
|
+
from agent_env.config import get_config
|
|
524
|
+
from agent_env.env.env import Env
|
|
525
|
+
from agent_env.providers.env_state import LOCAL_POSTGRES_STATE_TYPE, acquire_state_for_deploy
|
|
526
|
+
|
|
527
|
+
topology = _gateway_topology(env)
|
|
528
|
+
state_instance = await acquire_state_for_deploy(
|
|
529
|
+
env_state_type=env_state_type, ttl_seconds=ttl_seconds, name_hint=env.id, env_state_instance_id=env_state_instance_id,
|
|
530
|
+
)
|
|
531
|
+
if state_instance is None or state_instance.state_type == LOCAL_POSTGRES_STATE_TYPE:
|
|
532
|
+
Env.get(get_config().default_service_db_env_id) # the local store runs from it: check it before any sandbox
|
|
533
|
+
deployed = await self._launch(
|
|
534
|
+
sandbox_provider, env.id, state_instance=state_instance, mcp_servers=topology.mcp_servers,
|
|
535
|
+
mcp_server_images=topology.mcp_server_images, website_configs=topology.website_configs,
|
|
536
|
+
website_images=topology.website_images, mcp_server_name=topology.mcp_server_name, gateway_mode=gateway_mode,
|
|
537
|
+
ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb, priority=priority, attribution=attribution,
|
|
538
|
+
)
|
|
539
|
+
return DeployedGatewayEnv(
|
|
540
|
+
env_id=env.id,
|
|
541
|
+
env_version=env.version,
|
|
542
|
+
env_provider_type=self.type,
|
|
543
|
+
gateway_url=deployed.gateway_url,
|
|
544
|
+
db_web_url=deployed.db_web_url,
|
|
545
|
+
sandbox_id=self._sandbox.sandbox_id, sandbox_type=self._sandbox.type,
|
|
546
|
+
sandbox_ids=self._sandbox_ids(always_name_gateway=topology.names_gateway_on_vm),
|
|
547
|
+
db_mcp_url=deployed.db_mcp_url,
|
|
548
|
+
environment_card_url=f"{deployed.gateway_url}{WELL_KNOWN_PATH}",
|
|
549
|
+
environment_card=deployed.environment_card,
|
|
550
|
+
environment_card_read_at_utc=deployed.environment_card_read_at_utc,
|
|
551
|
+
website_frontend_urls=deployed.website_frontend_urls,
|
|
552
|
+
gateway_mode=gateway_mode.value,
|
|
553
|
+
env_state_instance_ids=deployed.env_state_instance_ids,
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
async def create_gateway(
|
|
557
|
+
self,
|
|
558
|
+
sandbox_provider: SandboxProvider,
|
|
559
|
+
mcp_servers: list[MCPServerConfig],
|
|
560
|
+
mcp_server_images: list[DockerImageArtifact],
|
|
561
|
+
gateway_port: int = AGENT_ENV_GATEWAY_MCP_PORT,
|
|
562
|
+
website_configs: list[WebsiteConfig] | None = None,
|
|
563
|
+
website_images: list[DockerImageArtifact] | None = None,
|
|
564
|
+
gateway_mode: GatewayMode = GatewayMode.PERFORMANCE,
|
|
565
|
+
ttl_seconds: int = 10800,
|
|
566
|
+
disk_size_gb: float = 10,
|
|
567
|
+
cpu: float | None = None,
|
|
568
|
+
memory_mb: int | None = None,
|
|
569
|
+
priority: Optional[int] = None,
|
|
570
|
+
existing_sandbox: Sandbox | None = None,
|
|
571
|
+
env_id: str | None = None,
|
|
572
|
+
state_instance: "EnvStateInstance | None" = None,
|
|
573
|
+
sidecars: list[SidecarConfig] | None = None,
|
|
574
|
+
mcp_server_name: str | None = None,
|
|
575
|
+
*,
|
|
576
|
+
attribution: Optional[Attribution] = None,
|
|
577
|
+
) -> DeployedGateway:
|
|
578
|
+
"""Create and start a gateway from explicit topology, for a caller that builds its own record rather than calling ``deploy()``.
|
|
579
|
+
|
|
580
|
+
``state_instance`` is a pre-acquired *external* store handle (e.g. remote Postgres),
|
|
581
|
+
provisioned upstream by the caller. ``None`` => local Postgres, which the gateway self-provisions
|
|
582
|
+
(its store container's host doesn't exist until deploy). See ``_build_local_store``.
|
|
583
|
+
|
|
584
|
+
``existing_sandbox`` lets callers pre-allocate the gateway VM in parallel
|
|
585
|
+
with other work and pass the handle in. VM-mode only — Modal container
|
|
586
|
+
deploys manage their own sandbox lifecycle and reject this kwarg.
|
|
587
|
+
|
|
588
|
+
``mcp_server_name`` is the name the gateway presents on its card and as its MCP server; drawn as ``env`` +
|
|
589
|
+
4 random digits when the env declares none; the record takes it from the gateway's card.
|
|
590
|
+
"""
|
|
591
|
+
return await self._launch(
|
|
592
|
+
sandbox_provider, env_id, state_instance=state_instance, mcp_servers=mcp_servers, mcp_server_images=mcp_server_images,
|
|
593
|
+
gateway_port=gateway_port, website_configs=website_configs, website_images=website_images,
|
|
594
|
+
gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb, cpu=cpu, memory_mb=memory_mb,
|
|
595
|
+
priority=priority, existing_sandbox=existing_sandbox, sidecars=sidecars, mcp_server_name=mcp_server_name,
|
|
596
|
+
attribution=attribution,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
async def _launch(self, sandbox_provider: SandboxProvider, env_id: str | None, *, mcp_server_name: str | None = None, **gateway) -> DeployedGateway:
|
|
600
|
+
"""One gateway, deployed on the sandbox provider or each chained member in turn, then its tools probed."""
|
|
601
|
+
attempt = functools.partial(
|
|
602
|
+
self._deploy_gateway, **gateway,
|
|
603
|
+
mcp_server_name=mcp_server_name or random_mcp_server_name(), # drawn once, so every chained attempt presents the same name
|
|
604
|
+
)
|
|
605
|
+
result = await self._run(sandbox_provider, env_id, attempt)
|
|
606
|
+
await _probe_tools(env_id, result)
|
|
607
|
+
return result
|
|
608
|
+
|
|
609
|
+
async def _deploy_via_vm(
|
|
610
|
+
self,
|
|
611
|
+
sandbox_provider: SandboxProvider,
|
|
612
|
+
mcp_servers: list[MCPServerConfig],
|
|
613
|
+
mcp_server_images: list[DockerImageArtifact],
|
|
614
|
+
gateway_port: int,
|
|
615
|
+
website_configs: list[WebsiteConfig] | None,
|
|
616
|
+
website_images: list[DockerImageArtifact] | None,
|
|
617
|
+
gateway_mode: GatewayMode,
|
|
618
|
+
ttl_seconds: int,
|
|
619
|
+
disk_size_gb: float,
|
|
620
|
+
cpu: float | None = None,
|
|
621
|
+
memory_mb: int | None = None,
|
|
622
|
+
attribution: Optional[Attribution] = None,
|
|
623
|
+
priority: Optional[int] = None,
|
|
624
|
+
existing_sandbox: Sandbox | None = None,
|
|
625
|
+
sidecars: list[SidecarConfig] | None = None,
|
|
626
|
+
mcp_server_name: str | None = None,
|
|
627
|
+
) -> DeployedGateway:
|
|
628
|
+
from agent_env.env.env import Env
|
|
629
|
+
from agent_env.config import get_config
|
|
630
|
+
|
|
631
|
+
website_configs = website_configs or []
|
|
632
|
+
website_images = website_images or []
|
|
633
|
+
sidecars = sidecars or []
|
|
634
|
+
|
|
635
|
+
if existing_sandbox is not None:
|
|
636
|
+
self._sandbox = existing_sandbox
|
|
637
|
+
else:
|
|
638
|
+
vm_kwargs: dict = {}
|
|
639
|
+
if cpu is not None:
|
|
640
|
+
vm_kwargs["cpu"] = cpu
|
|
641
|
+
if memory_mb is not None:
|
|
642
|
+
vm_kwargs["memory"] = memory_mb
|
|
643
|
+
self._sandbox = await sandbox_provider.create_vm(
|
|
644
|
+
exposed_ports=[gateway_port, DB_WEB_PORT, DB_MCP_PORT] + [sc.host_port for sc in sidecars],
|
|
645
|
+
timeout=ttl_seconds,
|
|
646
|
+
disk_size_gb=disk_size_gb,
|
|
647
|
+
attribution=attribution,
|
|
648
|
+
priority=priority,
|
|
649
|
+
**vm_kwargs,
|
|
650
|
+
)
|
|
651
|
+
sandbox = self._sandbox
|
|
652
|
+
|
|
653
|
+
config = get_config()
|
|
654
|
+
gateway_env = Env.get(config.default_gateway_env_id)
|
|
655
|
+
|
|
656
|
+
# Auto-include website_browser MCP server when websites are present
|
|
657
|
+
if website_configs:
|
|
658
|
+
website_browser_env = Env.get(config.default_website_browser_env_id)
|
|
659
|
+
browser_server = MCPServerConfig(
|
|
660
|
+
image=website_browser_env.docker_image_artifact.image_name,
|
|
661
|
+
environment_name=website_browser_env.environment_name,
|
|
662
|
+
)
|
|
663
|
+
mcp_servers = list(mcp_servers) + [browser_server]
|
|
664
|
+
mcp_server_images = list(mcp_server_images) + [website_browser_env.docker_image_artifact]
|
|
665
|
+
|
|
666
|
+
# Local Postgres also preloads its servicedb image set (servicedb + pgweb + db-mcp); an
|
|
667
|
+
# external store has no co-deployed containers, and image pull dominates VM deploy latency.
|
|
668
|
+
all_images = [
|
|
669
|
+
gateway_env.docker_image_artifact,
|
|
670
|
+
*mcp_server_images,
|
|
671
|
+
*website_images,
|
|
672
|
+
*[sc.image_artifact for sc in sidecars], # sidecars side-load like MCP images (no registry pull)
|
|
673
|
+
]
|
|
674
|
+
if self._needs_local_postgres:
|
|
675
|
+
all_images += self._state_provider.store_images_to_load()
|
|
676
|
+
await sandbox.load_docker_images(all_images)
|
|
677
|
+
|
|
678
|
+
# Compose declares the local_state service by name, so "standing up" is just naming the host;
|
|
679
|
+
# the container is rendered into the compose and started on deploy.
|
|
680
|
+
async def _stand_up(_spec):
|
|
681
|
+
return DATABASE_SERVICE_NAME
|
|
682
|
+
|
|
683
|
+
local_store_spec = (
|
|
684
|
+
await self._build_local_store(
|
|
685
|
+
mcp_servers, _stand_up, website_configs=website_configs, ttl_seconds=ttl_seconds
|
|
686
|
+
)
|
|
687
|
+
if self._needs_local_postgres else None
|
|
688
|
+
)
|
|
689
|
+
state_instance = self._state_instance
|
|
690
|
+
|
|
691
|
+
# Stage 2 (prepare): create per-service schemas now that the final service list is known —
|
|
692
|
+
# it includes the auto-added website_browser above. Local no-ops (its servicedb container
|
|
693
|
+
# runs the init script declaratively); remote/external stores create schemas here.
|
|
694
|
+
if self._state_provider is not None:
|
|
695
|
+
await self._state_provider.prepare(
|
|
696
|
+
self._all_environments(mcp_servers, website_configs), instance=state_instance
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
compose_content = self.create_docker_compose(
|
|
700
|
+
mcp_servers=mcp_servers,
|
|
701
|
+
gateway_image=gateway_env.docker_image_artifact.image_name,
|
|
702
|
+
gateway_port=gateway_port,
|
|
703
|
+
website_configs=website_configs,
|
|
704
|
+
gateway_mode=gateway_mode,
|
|
705
|
+
sidecars=sidecars,
|
|
706
|
+
state_provider=self._state_provider,
|
|
707
|
+
state_instance=state_instance,
|
|
708
|
+
host_port=sandbox.host_port,
|
|
709
|
+
mcp_server_name=mcp_server_name,
|
|
710
|
+
)
|
|
711
|
+
logger.info(f"Generated docker-compose.yml:\n{_redact_compose_secrets(compose_content)}")
|
|
712
|
+
|
|
713
|
+
# Write files to VM
|
|
714
|
+
logger.info("Writing files to VM...")
|
|
715
|
+
await sandbox.exec_script(f"mkdir -p {GATEWAY_APP_DIR}", max_retries=2)
|
|
716
|
+
|
|
717
|
+
# Only local postgres needs its init script to be directly run by the gateway's servicedb
|
|
718
|
+
# container; remote/external stores are already initialized upstream.
|
|
719
|
+
if self._needs_local_postgres:
|
|
720
|
+
init_script = local_store_spec.init_sql
|
|
721
|
+
logger.info(f"Generated init-schemas.sql:\n{init_script}")
|
|
722
|
+
init_script_path = f"{GATEWAY_APP_DIR}/init-schemas.sql"
|
|
723
|
+
write_init_script = f'''cat > {init_script_path} << 'INIT_EOF'
|
|
724
|
+
{init_script}
|
|
725
|
+
INIT_EOF'''
|
|
726
|
+
await sandbox.exec_script(write_init_script, max_retries=2)
|
|
727
|
+
|
|
728
|
+
# Write docker-compose.yml
|
|
729
|
+
write_compose_script = f'''cat > {DOCKER_COMPOSE_PATH} << 'COMPOSE_EOF'
|
|
730
|
+
{compose_content}
|
|
731
|
+
COMPOSE_EOF'''
|
|
732
|
+
await sandbox.exec_script(write_compose_script, max_retries=2)
|
|
733
|
+
|
|
734
|
+
# Start MCP services with docker compose
|
|
735
|
+
logger.info("Starting MCP services...")
|
|
736
|
+
await sandbox.exec_script(f"cd {GATEWAY_APP_DIR} && docker compose up -d", max_retries=2)
|
|
737
|
+
|
|
738
|
+
# Wait for containers to start
|
|
739
|
+
await asyncio.sleep(10)
|
|
740
|
+
|
|
741
|
+
# Verify containers are running
|
|
742
|
+
logger.info("Verifying containers...")
|
|
743
|
+
exit_code, stdout, stderr = await sandbox.exec_with_output( "sudo", "docker", "ps", "-a")
|
|
744
|
+
logger.info(f" docker ps -a:\n{stdout}")
|
|
745
|
+
if exit_code != 0:
|
|
746
|
+
raise RuntimeError(f"docker ps failed: {stderr}")
|
|
747
|
+
|
|
748
|
+
# Check gateway container is running
|
|
749
|
+
exit_code, stdout, stderr = await sandbox.exec_with_output( "sudo", "docker", "ps")
|
|
750
|
+
if GATEWAY_SERVICE_NAME not in stdout:
|
|
751
|
+
raise RuntimeError(f"Gateway container not running. docker ps: {stdout}")
|
|
752
|
+
|
|
753
|
+
# Wait for gateway to be fully ready (needs time to discover internal servers)
|
|
754
|
+
# Host-side port: exec_with_output runs inside the VM on per-VM backends but on
|
|
755
|
+
# the host for LocalSandbox, where the gateway is published elsewhere.
|
|
756
|
+
gateway_ready = await self._wait_for_gateway(sandbox, sandbox.host_port(gateway_port))
|
|
757
|
+
|
|
758
|
+
# Print gateway logs for debugging
|
|
759
|
+
logger.info("Gateway logs:")
|
|
760
|
+
gateway_container_id = await self._get_container_id(sandbox, GATEWAY_SERVICE_NAME)
|
|
761
|
+
if gateway_container_id:
|
|
762
|
+
exit_code, logs, stderr = await sandbox.exec_with_output( "sudo", "docker", "logs", gateway_container_id)
|
|
763
|
+
logger.info(f" stdout:\n{logs}")
|
|
764
|
+
if stderr:
|
|
765
|
+
logger.info(f" stderr:\n{stderr}")
|
|
766
|
+
if not gateway_ready:
|
|
767
|
+
raise RuntimeError("Gateway did not become ready in time")
|
|
768
|
+
|
|
769
|
+
# Check container status only on the sidecars the provider actually rendered
|
|
770
|
+
sidecar_names = (
|
|
771
|
+
self._state_provider.rendered_sidecar_service_names()
|
|
772
|
+
if self._needs_local_postgres else []
|
|
773
|
+
)
|
|
774
|
+
for name in sidecar_names:
|
|
775
|
+
container_id = await self._get_container_id(sandbox, name)
|
|
776
|
+
if container_id:
|
|
777
|
+
exit_code, logs, stderr = await sandbox.exec_with_output("sudo", "docker", "logs", container_id)
|
|
778
|
+
logger.info(f"{name} logs:\n stdout:\n{logs}")
|
|
779
|
+
if stderr:
|
|
780
|
+
logger.info(f" stderr:\n{stderr}")
|
|
781
|
+
else:
|
|
782
|
+
logger.warning(f"{name} container not found")
|
|
783
|
+
|
|
784
|
+
gateway_url = sandbox.tunnel_urls.get(gateway_port)
|
|
785
|
+
db_web_base_url = sandbox.tunnel_urls.get(DB_WEB_PORT) if PGWEB_SERVICE_NAME in sidecar_names else None
|
|
786
|
+
db_web_url = f"{db_web_base_url}/" if db_web_base_url else None
|
|
787
|
+
db_mcp_base_url = sandbox.tunnel_urls.get(DB_MCP_PORT) if DB_MCP_SERVICE_NAME in sidecar_names else None
|
|
788
|
+
db_mcp_url = f"{db_mcp_base_url}/mcp" if db_mcp_base_url else None
|
|
789
|
+
logger.info(f"pgweb URL: {db_web_url}")
|
|
790
|
+
if db_mcp_url:
|
|
791
|
+
logger.info(f"DB MCP endpoint: {db_mcp_url}")
|
|
792
|
+
|
|
793
|
+
# Wait for the tunnel to serve the env card, which the deploy records
|
|
794
|
+
card = await self._wait_for_tunnel(gateway_url, timeout=120)
|
|
795
|
+
if card is None:
|
|
796
|
+
raise RuntimeError("Tunnel did not become ready in time")
|
|
797
|
+
card_read_at_utc = datetime.now(timezone.utc).isoformat()
|
|
798
|
+
mcp_url = _mcp_url(gateway_url, card)
|
|
799
|
+
logger.info(f"MCP endpoint: {mcp_url}")
|
|
800
|
+
|
|
801
|
+
# Build frontend URLs proxied through the gateway
|
|
802
|
+
website_frontend_urls: dict[str, str] | None = None
|
|
803
|
+
if website_configs:
|
|
804
|
+
website_frontend_urls = {
|
|
805
|
+
wc.environment_name: f"{gateway_url}/website/{wc.environment_name}/"
|
|
806
|
+
for wc in website_configs
|
|
807
|
+
}
|
|
808
|
+
for svc_name, url in website_frontend_urls.items():
|
|
809
|
+
logger.info(f"Website frontend ({svc_name}): {url}")
|
|
810
|
+
|
|
811
|
+
logger.info(f"Gateway URL: {gateway_url}")
|
|
812
|
+
return DeployedGateway(
|
|
813
|
+
gateway_url=gateway_url, mcp_url=mcp_url, db_web_url=db_web_url, db_mcp_url=db_mcp_url,
|
|
814
|
+
website_frontend_urls=website_frontend_urls, mcp_server_name=mcp_server_name,
|
|
815
|
+
env_state_instance_ids=[state_instance.instance_id],
|
|
816
|
+
environment_card=card, environment_card_read_at_utc=card_read_at_utc,
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
async def _deploy_via_containers(
|
|
820
|
+
self,
|
|
821
|
+
sandbox_provider: SandboxProvider,
|
|
822
|
+
mcp_servers: list[MCPServerConfig],
|
|
823
|
+
mcp_server_images: list[DockerImageArtifact],
|
|
824
|
+
gateway_port: int,
|
|
825
|
+
website_configs: list[WebsiteConfig] | None,
|
|
826
|
+
gateway_mode: GatewayMode,
|
|
827
|
+
ttl_seconds: int,
|
|
828
|
+
disk_size_gb: float,
|
|
829
|
+
cpu: float | None = None,
|
|
830
|
+
memory_mb: int | None = None,
|
|
831
|
+
attribution: Optional[Attribution] = None,
|
|
832
|
+
priority: Optional[int] = None,
|
|
833
|
+
mcp_server_name: str | None = None,
|
|
834
|
+
) -> DeployedGateway:
|
|
835
|
+
from agent_env.env.env import Env
|
|
836
|
+
from agent_env.config import get_config
|
|
837
|
+
|
|
838
|
+
if website_configs:
|
|
839
|
+
raise NotImplementedError(
|
|
840
|
+
"Container-mode gateway deploy does not support websites yet; use a VM-mode sandbox provider."
|
|
841
|
+
)
|
|
842
|
+
|
|
843
|
+
config = get_config()
|
|
844
|
+
gateway_env = Env.get(config.default_gateway_env_id)
|
|
845
|
+
|
|
846
|
+
i6pn_kwargs = {"i6pn": True, "region": config.modal_default_region} if isinstance(sandbox_provider, ModalSandboxProvider) else {}
|
|
847
|
+
deploy = _ContainerDeploy(
|
|
848
|
+
sandbox_provider=sandbox_provider, cpu=cpu, disk_size_gb=disk_size_gb, ttl_seconds=ttl_seconds,
|
|
849
|
+
attribution=attribution, priority=priority, i6pn_kwargs=i6pn_kwargs,
|
|
850
|
+
)
|
|
851
|
+
|
|
852
|
+
try:
|
|
853
|
+
if self._needs_local_postgres:
|
|
854
|
+
await self._build_local_store(
|
|
855
|
+
mcp_servers, functools.partial(self._stand_up_servicedb, deploy), ttl_seconds=ttl_seconds
|
|
856
|
+
)
|
|
857
|
+
state_instance = self._state_instance
|
|
858
|
+
|
|
859
|
+
# Stage 2 (prepare): create per-service schemas before the MCP servers connect. Local
|
|
860
|
+
# no-ops (its servicedb init runs above); remote/external stores create schemas here.
|
|
861
|
+
if self._state_provider is not None:
|
|
862
|
+
await self._state_provider.prepare(
|
|
863
|
+
self._all_environments(mcp_servers, None), instance=state_instance
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
sidecar_specs = (
|
|
867
|
+
self._state_provider.sidecar_specs(instance=state_instance)
|
|
868
|
+
if self._needs_local_postgres else []
|
|
869
|
+
)
|
|
870
|
+
# Step 2: MCP server sandboxes (HTTPS), provisioned in parallel.
|
|
871
|
+
mcp_coros = [
|
|
872
|
+
self._provision_mcp(deploy, cfg, art, state_instance)
|
|
873
|
+
for cfg, art in zip(mcp_servers, mcp_server_images)
|
|
874
|
+
]
|
|
875
|
+
aux_coros = [self._provision_sidecar(deploy, spec) for spec in sidecar_specs]
|
|
876
|
+
all_results = await asyncio.gather(*mcp_coros, *aux_coros, return_exceptions=True)
|
|
877
|
+
|
|
878
|
+
mcp_results: list[tuple[str, "Sandbox"]] = []
|
|
879
|
+
pgweb_sb = None
|
|
880
|
+
db_mcp_sb = None
|
|
881
|
+
errors: list[BaseException] = []
|
|
882
|
+
for i, r in enumerate(all_results):
|
|
883
|
+
if isinstance(r, BaseException):
|
|
884
|
+
logger.warning(f"sandbox batch child failed [{type(r).__name__}]: {(str(r) or repr(r))[-500:]}")
|
|
885
|
+
errors.append(r)
|
|
886
|
+
continue
|
|
887
|
+
if i < len(mcp_coros):
|
|
888
|
+
environment_name, mcp_sb = r
|
|
889
|
+
self._container_sandboxes.append(mcp_sb)
|
|
890
|
+
self._environment_sandboxes[environment_name] = mcp_sb
|
|
891
|
+
mcp_results.append((environment_name, mcp_sb))
|
|
892
|
+
else:
|
|
893
|
+
spec = sidecar_specs[i - len(mcp_coros)]
|
|
894
|
+
self._container_sandboxes.append(r)
|
|
895
|
+
if spec.name == PGWEB_SERVICE_NAME:
|
|
896
|
+
pgweb_sb = r
|
|
897
|
+
self._pgweb_sandbox = r
|
|
898
|
+
elif spec.name == DB_MCP_SERVICE_NAME:
|
|
899
|
+
db_mcp_sb = r
|
|
900
|
+
self._db_mcp_sandbox = r
|
|
901
|
+
|
|
902
|
+
if errors:
|
|
903
|
+
raise RuntimeError(f"{len(errors)} of {len(all_results)} sandbox provisioning tasks failed") from errors[0]
|
|
904
|
+
|
|
905
|
+
mcp_url_public_by_name: dict[str, str] = {}
|
|
906
|
+
mcp_url_i6pn_by_name: dict[str, str] = {}
|
|
907
|
+
for environment_name, mcp_sb in mcp_results:
|
|
908
|
+
mcp_url_public_by_name[environment_name] = f"{mcp_sb.tunnel_urls[AGENT_ENV_GATEWAY_MCP_PORT]}/mcp"
|
|
909
|
+
mcp_i6pn = getattr(mcp_sb, "i6pn_address", None)
|
|
910
|
+
if mcp_i6pn:
|
|
911
|
+
mcp_url_i6pn_by_name[environment_name] = f"http://[{mcp_i6pn}]:{AGENT_ENV_GATEWAY_MCP_PORT}/mcp"
|
|
912
|
+
|
|
913
|
+
if i6pn_kwargs:
|
|
914
|
+
missing_i6pn = [n for n in mcp_url_public_by_name if n not in mcp_url_i6pn_by_name]
|
|
915
|
+
if missing_i6pn:
|
|
916
|
+
raise RuntimeError(f"i6pn requested but resolution failed for MCPs: {missing_i6pn}")
|
|
917
|
+
internal_servers_by_name = mcp_url_i6pn_by_name
|
|
918
|
+
else:
|
|
919
|
+
internal_servers_by_name = mcp_url_public_by_name
|
|
920
|
+
|
|
921
|
+
# Step 3: gateway sandbox (HTTPS) with full MCP URLs in env
|
|
922
|
+
logger.info(f"Provisioning gateway from {gateway_env.docker_image_artifact.image_name}...")
|
|
923
|
+
gateway_env_vars = {
|
|
924
|
+
"INTERNAL_MCP_SERVERS": ",".join(f"{name}={url}" for name, url in internal_servers_by_name.items()),
|
|
925
|
+
"REST_PROXY_URLS": ",".join(f"mcp-{name}={url.removesuffix('/mcp')}" for name, url in internal_servers_by_name.items()),
|
|
926
|
+
"GATEWAY_MODE": gateway_mode.value,
|
|
927
|
+
"SERVICE_DB_URL": self._state_provider.base_url(state_instance),
|
|
928
|
+
"MCP_SERVER_NAME": mcp_server_name or DEFAULT_MCP_SERVER_NAME,
|
|
929
|
+
}
|
|
930
|
+
gateway_sb = await sandbox_provider.create_container(
|
|
931
|
+
image_name=gateway_env.docker_image_artifact.image_name,
|
|
932
|
+
port=gateway_port,
|
|
933
|
+
env=gateway_env_vars,
|
|
934
|
+
**_size_kwargs(cpu, memory_mb),
|
|
935
|
+
disk_size_gb=disk_size_gb, timeout=ttl_seconds,
|
|
936
|
+
attribution=attribution,
|
|
937
|
+
priority=priority,
|
|
938
|
+
**i6pn_kwargs,
|
|
939
|
+
)
|
|
940
|
+
self._container_sandboxes.append(gateway_sb)
|
|
941
|
+
self._sandbox = gateway_sb
|
|
942
|
+
gateway_url = gateway_sb.tunnel_urls[gateway_port]
|
|
943
|
+
logger.info(f"Gateway URL: {gateway_url}")
|
|
944
|
+
|
|
945
|
+
card = await self._wait_for_tunnel(gateway_url, timeout=120)
|
|
946
|
+
if card is None:
|
|
947
|
+
raise RuntimeError("Gateway tunnel did not become ready in time")
|
|
948
|
+
card_read_at_utc = datetime.now(timezone.utc).isoformat()
|
|
949
|
+
|
|
950
|
+
db_web_url = pgweb_sb.tunnel_urls[DB_WEB_PORT] + "/" if pgweb_sb else None
|
|
951
|
+
db_mcp_url = db_mcp_sb.tunnel_urls[DB_MCP_CONTAINER_PORT] + "/mcp" if db_mcp_sb else None
|
|
952
|
+
return DeployedGateway(
|
|
953
|
+
gateway_url=gateway_url,
|
|
954
|
+
mcp_url=_mcp_url(gateway_url, card),
|
|
955
|
+
db_web_url=db_web_url,
|
|
956
|
+
db_mcp_url=db_mcp_url,
|
|
957
|
+
website_frontend_urls=None,
|
|
958
|
+
mcp_server_name=mcp_server_name,
|
|
959
|
+
env_state_instance_ids=[state_instance.instance_id],
|
|
960
|
+
environment_card=card,
|
|
961
|
+
environment_card_read_at_utc=card_read_at_utc,
|
|
962
|
+
)
|
|
963
|
+
except Exception:
|
|
964
|
+
await self.close()
|
|
965
|
+
raise
|
|
966
|
+
|
|
967
|
+
async def _wait_for_gateway(self, sandbox: VmSandbox, port: int, timeout: int = 300) -> bool:
|
|
968
|
+
"""Wait for the gateway to answer on ``port``.
|
|
969
|
+
|
|
970
|
+
Probes the well-known document, not ``/mcp``: a healthy MCP endpoint replies with
|
|
971
|
+
an open event-stream, which a readiness check has no business holding. Any status
|
|
972
|
+
below 500 means the app is up and routing.
|
|
973
|
+
|
|
974
|
+
Each attempt is bounded so one hung request cannot consume the whole budget --
|
|
975
|
+
``exec_with_output`` runs on the host for some backends, where a stream can stall.
|
|
976
|
+
"""
|
|
977
|
+
logger.info(f"Waiting for gateway to be ready (timeout={timeout}s)...")
|
|
978
|
+
probe_url = f"http://localhost:{port}{WELL_KNOWN_PATH}"
|
|
979
|
+
for i in range(timeout):
|
|
980
|
+
exit_code, stdout, stderr = await sandbox.exec_with_output(
|
|
981
|
+
"curl", "-s", "--max-time", "5", "-o", "/dev/null", "-w", "%{http_code}", probe_url
|
|
982
|
+
)
|
|
983
|
+
http_code = stdout.strip()
|
|
984
|
+
if http_code.isdigit() and int(http_code) > 0 and int(http_code) < 500:
|
|
985
|
+
logger.info(f" Gateway ready (HTTP {http_code}) after {i+1}s")
|
|
986
|
+
return True
|
|
987
|
+
if i % 10 == 0:
|
|
988
|
+
logger.info(f" Still waiting... (HTTP {http_code})")
|
|
989
|
+
await asyncio.sleep(1)
|
|
990
|
+
logger.info(f" Gateway not ready after {timeout}s")
|
|
991
|
+
return False
|
|
992
|
+
|
|
993
|
+
async def read_trajectory(self, sandbox: VmSandbox) -> list[dict]:
|
|
994
|
+
"""Read trajectory JSONL from gateway container."""
|
|
995
|
+
container_id = await self._get_container_id(sandbox, GATEWAY_SERVICE_NAME)
|
|
996
|
+
if not container_id:
|
|
997
|
+
return []
|
|
998
|
+
|
|
999
|
+
exit_code, stdout, stderr = await sandbox.exec_with_output(
|
|
1000
|
+
"sudo", "docker", "exec", container_id, "cat", "/var/log/agentenv/trajectory.jsonl"
|
|
1001
|
+
)
|
|
1002
|
+
events = []
|
|
1003
|
+
for line in stdout.strip().split("\n"):
|
|
1004
|
+
if line:
|
|
1005
|
+
events.append(json.loads(line))
|
|
1006
|
+
return events
|
|
1007
|
+
|
|
1008
|
+
async def _get_container_id(self, sandbox: VmSandbox, compose_service: str) -> str | None:
|
|
1009
|
+
"""Get container ID for a docker-compose service (including exited containers)."""
|
|
1010
|
+
exit_code, stdout, stderr = await sandbox.exec_with_output(
|
|
1011
|
+
"sudo", "docker", "compose", "-f", DOCKER_COMPOSE_PATH, "ps", "-a", "-q", compose_service
|
|
1012
|
+
)
|
|
1013
|
+
container_id = stdout.strip()
|
|
1014
|
+
return container_id if container_id else None
|
|
1015
|
+
|
|
1016
|
+
async def _provision_sidecar(self, deploy: _ContainerDeploy, spec) -> Sandbox:
|
|
1017
|
+
logger.info(f"Provisioning {spec.name} from {spec.image}...")
|
|
1018
|
+
return await deploy.sandbox_provider.create_container(
|
|
1019
|
+
image_name=spec.image,
|
|
1020
|
+
port=spec.port,
|
|
1021
|
+
env=spec.env,
|
|
1022
|
+
command=spec.command,
|
|
1023
|
+
**_size_kwargs(deploy.cpu, None),
|
|
1024
|
+
disk_size_gb=deploy.disk_size_gb, timeout=deploy.ttl_seconds,
|
|
1025
|
+
attribution=deploy.attribution,
|
|
1026
|
+
priority=deploy.priority,
|
|
1027
|
+
**deploy.i6pn_kwargs,
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
@property
|
|
1031
|
+
def _needs_local_postgres(self) -> bool:
|
|
1032
|
+
from agent_env.providers.env_state import LocalPostgresStateProvider
|
|
1033
|
+
|
|
1034
|
+
return isinstance(self._state_provider, LocalPostgresStateProvider)
|
|
1035
|
+
|
|
1036
|
+
async def _build_local_store(
|
|
1037
|
+
self,
|
|
1038
|
+
mcp_servers: list[MCPServerConfig],
|
|
1039
|
+
stand_up: Callable[[LocalPostgresStoreSpec], Awaitable[str]],
|
|
1040
|
+
website_configs: list[WebsiteConfig] | None = None,
|
|
1041
|
+
ttl_seconds: int = 10800,
|
|
1042
|
+
) -> LocalPostgresStoreSpec:
|
|
1043
|
+
"""A **local** Postgres store, which can only be built within the gateway."""
|
|
1044
|
+
from agent_env.providers.env_state import (
|
|
1045
|
+
LocalPostgresStateContext,
|
|
1046
|
+
LocalPostgresStateProvider,
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
# MCP servers and websites are both DB clients (one schema each); the container path
|
|
1050
|
+
# passes no websites, so normalize to a list once.
|
|
1051
|
+
configs = [*mcp_servers, *(website_configs or [])]
|
|
1052
|
+
environment_names = list(dict.fromkeys(c.environment_name for c in configs))
|
|
1053
|
+
provider = self._state_provider # set by _deploy_gateway
|
|
1054
|
+
assert isinstance(provider, LocalPostgresStateProvider)
|
|
1055
|
+
spec = provider.store_spec(environment_names)
|
|
1056
|
+
host = await stand_up(spec)
|
|
1057
|
+
self._state_instance = await provider.acquire(
|
|
1058
|
+
LocalPostgresStateContext(
|
|
1059
|
+
environment_names=environment_names, host=host, ttl_seconds=ttl_seconds
|
|
1060
|
+
)
|
|
1061
|
+
)
|
|
1062
|
+
return spec
|
|
1063
|
+
|
|
1064
|
+
async def _init_service_db_via_exec(self, db_sb: Sandbox, init_sql: str) -> None:
|
|
1065
|
+
from agent_env.env.envs.service_db import DB_NAME, DB_USER
|
|
1066
|
+
|
|
1067
|
+
logger.info("Initializing service-db schemas + changelog functions via sandbox exec...")
|
|
1068
|
+
for attempt in range(60):
|
|
1069
|
+
exit_code, _, stderr = await db_sb.exec_with_output("pg_isready", "-U", DB_USER, "-d", DB_NAME)
|
|
1070
|
+
if exit_code == 0:
|
|
1071
|
+
break
|
|
1072
|
+
if attempt % 10 == 0:
|
|
1073
|
+
logger.info(f" Waiting for postgres to be ready... (last stderr: {stderr.strip()[-200:]})")
|
|
1074
|
+
await asyncio.sleep(1)
|
|
1075
|
+
else:
|
|
1076
|
+
raise RuntimeError("Postgres not ready after 60s")
|
|
1077
|
+
|
|
1078
|
+
await db_sb.write_file_from_text(init_sql, "/tmp/init-schemas.sql")
|
|
1079
|
+
exit_code, stdout, stderr = await db_sb.exec_with_output(
|
|
1080
|
+
"psql", "-U", DB_USER, "-d", DB_NAME, "-v", "ON_ERROR_STOP=1", "-f", "/tmp/init-schemas.sql",
|
|
1081
|
+
)
|
|
1082
|
+
if exit_code != 0:
|
|
1083
|
+
raise RuntimeError(f"Service-db init failed: {stderr[-1000:]}")
|
|
1084
|
+
logger.info("Service-db schemas initialized.")
|
|
1085
|
+
|
|
1086
|
+
async def _stand_up_servicedb(self, deploy: _ContainerDeploy, spec: "LocalPostgresStoreSpec") -> str:
|
|
1087
|
+
"""Container mode stands the servicedb up as its own i6pn-only sandbox; returns its host."""
|
|
1088
|
+
logger.info("Provisioning service-db sandbox (Modal, i6pn-only)...")
|
|
1089
|
+
# Local backend only (external skips standup); the provider is set by _deploy_gateway.
|
|
1090
|
+
db_sb = await deploy.sandbox_provider.create_container(
|
|
1091
|
+
image_name=self._state_provider.container_store_image(),
|
|
1092
|
+
port=spec.port,
|
|
1093
|
+
env=spec.env,
|
|
1094
|
+
**_size_kwargs(deploy.cpu, None),
|
|
1095
|
+
disk_size_gb=deploy.disk_size_gb, timeout=deploy.ttl_seconds,
|
|
1096
|
+
expose_externally=False,
|
|
1097
|
+
attribution=deploy.attribution,
|
|
1098
|
+
priority=deploy.priority,
|
|
1099
|
+
**deploy.i6pn_kwargs,
|
|
1100
|
+
)
|
|
1101
|
+
self._container_sandboxes.append(db_sb)
|
|
1102
|
+
self._db_sandbox = db_sb
|
|
1103
|
+
db_i6pn_address = getattr(db_sb, "i6pn_address", None)
|
|
1104
|
+
if not db_i6pn_address:
|
|
1105
|
+
raise RuntimeError("service-db has no i6pn address; cannot route postgres traffic")
|
|
1106
|
+
host = f"[{db_i6pn_address}]"
|
|
1107
|
+
await self._init_service_db_via_exec(db_sb, spec.init_sql)
|
|
1108
|
+
logger.info(f"Service-db ready at {host}:{spec.port} (i6pn-only)")
|
|
1109
|
+
return host
|
|
1110
|
+
|
|
1111
|
+
async def _provision_mcp(
|
|
1112
|
+
self,
|
|
1113
|
+
deploy: _ContainerDeploy,
|
|
1114
|
+
cfg: MCPServerConfig,
|
|
1115
|
+
image_artifact: "DockerImageArtifact",
|
|
1116
|
+
state_instance: "EnvStateInstance | None",
|
|
1117
|
+
) -> tuple[str, Sandbox]:
|
|
1118
|
+
logger.info(f"Provisioning MCP server '{cfg.environment_name}' from {image_artifact.image_name}...")
|
|
1119
|
+
env = dict(cfg.extra_env_vars or {})
|
|
1120
|
+
env["DATABASE_URL"] = self._state_provider.url_for_environment(cfg.environment_name, instance=state_instance)
|
|
1121
|
+
env["ENVIRONMENT_NAME"] = cfg.environment_name
|
|
1122
|
+
if deploy.i6pn_kwargs:
|
|
1123
|
+
env.setdefault("MCP_HOST", "::")
|
|
1124
|
+
sb = await deploy.sandbox_provider.create_container(
|
|
1125
|
+
image_name=image_artifact.image_name,
|
|
1126
|
+
port=AGENT_ENV_GATEWAY_MCP_PORT,
|
|
1127
|
+
env=env,
|
|
1128
|
+
**_size_kwargs(deploy.cpu, None),
|
|
1129
|
+
disk_size_gb=deploy.disk_size_gb, timeout=deploy.ttl_seconds,
|
|
1130
|
+
attribution=deploy.attribution,
|
|
1131
|
+
priority=deploy.priority,
|
|
1132
|
+
**deploy.i6pn_kwargs,
|
|
1133
|
+
)
|
|
1134
|
+
return cfg.environment_name, sb
|
|
1135
|
+
|
|
1136
|
+
async def _deploy_gateway(
|
|
1137
|
+
self,
|
|
1138
|
+
sandbox_provider: SandboxProvider,
|
|
1139
|
+
*,
|
|
1140
|
+
state_instance: "EnvStateInstance | None",
|
|
1141
|
+
mcp_servers: list[MCPServerConfig],
|
|
1142
|
+
mcp_server_images: list[DockerImageArtifact],
|
|
1143
|
+
gateway_port: int = AGENT_ENV_GATEWAY_MCP_PORT,
|
|
1144
|
+
website_configs: list[WebsiteConfig] | None = None,
|
|
1145
|
+
website_images: list[DockerImageArtifact] | None = None,
|
|
1146
|
+
gateway_mode: GatewayMode = GatewayMode.PERFORMANCE,
|
|
1147
|
+
ttl_seconds: int = 10800,
|
|
1148
|
+
disk_size_gb: float = 10,
|
|
1149
|
+
cpu: float | None = None,
|
|
1150
|
+
memory_mb: int | None = None,
|
|
1151
|
+
priority: Optional[int] = None,
|
|
1152
|
+
existing_sandbox: Sandbox | None = None,
|
|
1153
|
+
sidecars: list[SidecarConfig] | None = None,
|
|
1154
|
+
mcp_server_name: str | None = None,
|
|
1155
|
+
attribution: Optional[Attribution] = None,
|
|
1156
|
+
) -> DeployedGateway:
|
|
1157
|
+
"""One gateway deploy on one (unchained) sandbox provider: Modal containers for a ``ModalSandboxProvider``, else a VM."""
|
|
1158
|
+
from agent_env.providers.env_state import LocalPostgresStateProvider, build_state_provider
|
|
1159
|
+
|
|
1160
|
+
# Every attempt starts from the caller's store: a failed attempt's close() cleared it.
|
|
1161
|
+
self._state_instance = state_instance
|
|
1162
|
+
self._state_provider = (
|
|
1163
|
+
build_state_provider(state_instance.state_type)
|
|
1164
|
+
if state_instance is not None
|
|
1165
|
+
else LocalPostgresStateProvider()
|
|
1166
|
+
)
|
|
1167
|
+
deploy_kwargs = {"attribution": dict(attribution or {}), "priority": priority}
|
|
1168
|
+
if isinstance(sandbox_provider, ModalSandboxProvider):
|
|
1169
|
+
if existing_sandbox is not None:
|
|
1170
|
+
raise ValueError("existing_sandbox is only supported for VM-mode providers")
|
|
1171
|
+
if sidecars:
|
|
1172
|
+
raise NotImplementedError("sidecars are only supported for VM-mode gateway deploys")
|
|
1173
|
+
return await self._deploy_via_containers(
|
|
1174
|
+
sandbox_provider, mcp_servers, mcp_server_images,
|
|
1175
|
+
gateway_port=gateway_port, website_configs=website_configs, gateway_mode=gateway_mode,
|
|
1176
|
+
ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb,
|
|
1177
|
+
cpu=cpu, memory_mb=memory_mb, mcp_server_name=mcp_server_name, **deploy_kwargs,
|
|
1178
|
+
)
|
|
1179
|
+
return await self._deploy_via_vm(
|
|
1180
|
+
sandbox_provider, mcp_servers, mcp_server_images,
|
|
1181
|
+
gateway_port=gateway_port, website_configs=website_configs, website_images=website_images,
|
|
1182
|
+
gateway_mode=gateway_mode, ttl_seconds=ttl_seconds, disk_size_gb=disk_size_gb,
|
|
1183
|
+
cpu=cpu, memory_mb=memory_mb, existing_sandbox=existing_sandbox, sidecars=sidecars,
|
|
1184
|
+
mcp_server_name=mcp_server_name, **deploy_kwargs,
|
|
1185
|
+
)
|
|
1186
|
+
|
|
1187
|
+
def _sandbox_ids(self, always_name_gateway: bool = False) -> dict[str, str | dict[str, str]]:
|
|
1188
|
+
"""The containers a container-mode deploy created, in the record's ``sandbox_ids`` shape; ``{}`` on a VM, or only the
|
|
1189
|
+
gateway where ``always_name_gateway``, as a MultiEnv's records have always named it."""
|
|
1190
|
+
from agent_env.env.envs.gateway_server import GatewayEnv
|
|
1191
|
+
from agent_env.env.envs.mcp_server import MCPServerEnv
|
|
1192
|
+
from agent_env.env.envs.service_db import ServiceDBEnv
|
|
1193
|
+
|
|
1194
|
+
if not self._environment_sandboxes and not always_name_gateway:
|
|
1195
|
+
return {}
|
|
1196
|
+
sandbox_ids: dict[str, str | dict[str, str]] = {GatewayEnv.type: self._sandbox.sandbox_id}
|
|
1197
|
+
if self._environment_sandboxes:
|
|
1198
|
+
sandbox_ids[MCPServerEnv.type] = {name: sb.sandbox_id for name, sb in self._environment_sandboxes.items()}
|
|
1199
|
+
service_db = {role: sb.sandbox_id for role, sb in [
|
|
1200
|
+
(DATABASE_SERVICE_NAME, self._db_sandbox), (PGWEB_SERVICE_NAME, self._pgweb_sandbox), (DB_MCP_SERVICE_NAME, self._db_mcp_sandbox),
|
|
1201
|
+
] if sb is not None}
|
|
1202
|
+
if service_db:
|
|
1203
|
+
sandbox_ids[ServiceDBEnv.type] = service_db
|
|
1204
|
+
return sandbox_ids
|
|
1205
|
+
|
|
1206
|
+
async def _reattach(self, env: Env, deployed: DeployedEnv) -> Sandbox:
|
|
1207
|
+
"""A record this provider wrote, reattached: a MultiEnv's through its own rebuild, any other env's as the base reattaches it."""
|
|
1208
|
+
from agent_env.env.envs.multi_env import MultiEnv
|
|
1209
|
+
|
|
1210
|
+
if isinstance(env, MultiEnv):
|
|
1211
|
+
return await self._reattach_multi_env(deployed)
|
|
1212
|
+
return await super()._reattach(env, deployed)
|
|
1213
|
+
|
|
1214
|
+
async def _reattach_multi_env(self, deployed: DeployedEnv) -> Sandbox:
|
|
1215
|
+
"""A MultiEnv's record reattached: every sandbox it names and its state store, for its children's loads and close().
|
|
1216
|
+
|
|
1217
|
+
Unlike a single server's reattach, which skips a dead database or sidecar, any dead sandbox the record names raises.
|
|
1218
|
+
"""
|
|
1219
|
+
from agent_env.env.envs.mcp_server import MCPServerEnv
|
|
1220
|
+
from agent_env.env.envs.service_db import ServiceDBEnv
|
|
1221
|
+
from agent_env.providers import build_sandbox_provider, get_env_sandbox_provider
|
|
1222
|
+
from agent_env.providers.env_state import LocalPostgresStateProvider, build_state_provider
|
|
1223
|
+
from agent_env.providers.env_state.store import get_env_state_instance_store
|
|
1224
|
+
from agent_env.store.base import NotFoundError
|
|
1225
|
+
|
|
1226
|
+
provider = build_sandbox_provider(deployed.sandbox_type) if deployed.sandbox_type else get_env_sandbox_provider()
|
|
1227
|
+
sandbox_ids = deployed.sandbox_ids or {}
|
|
1228
|
+
|
|
1229
|
+
self._sandbox = await provider.get_sandbox(deployed.sandbox_id)
|
|
1230
|
+
|
|
1231
|
+
for environment_name, sb_id in (sandbox_ids.get(MCPServerEnv.type) or {}).items():
|
|
1232
|
+
sb = await provider.get_sandbox(sb_id)
|
|
1233
|
+
self._environment_sandboxes[environment_name] = sb
|
|
1234
|
+
self._container_sandboxes.append(sb)
|
|
1235
|
+
|
|
1236
|
+
db_ids = sandbox_ids.get(ServiceDBEnv.type) or {}
|
|
1237
|
+
for role, slot_name in [
|
|
1238
|
+
(DATABASE_SERVICE_NAME, "_db_sandbox"),
|
|
1239
|
+
(PGWEB_SERVICE_NAME, "_pgweb_sandbox"),
|
|
1240
|
+
(DB_MCP_SERVICE_NAME, "_db_mcp_sandbox"),
|
|
1241
|
+
]:
|
|
1242
|
+
sb_id = db_ids.get(role)
|
|
1243
|
+
if sb_id:
|
|
1244
|
+
sb = await provider.get_sandbox(sb_id)
|
|
1245
|
+
setattr(self, slot_name, sb)
|
|
1246
|
+
self._container_sandboxes.append(sb)
|
|
1247
|
+
|
|
1248
|
+
if self._sandbox.mode == SANDBOX_MODE_CONTAINER:
|
|
1249
|
+
self._container_sandboxes.append(self._sandbox)
|
|
1250
|
+
|
|
1251
|
+
state_instance = None
|
|
1252
|
+
ids = deployed.env_state_instance_ids if isinstance(deployed, DeployedGatewayEnv) else None
|
|
1253
|
+
if ids:
|
|
1254
|
+
try:
|
|
1255
|
+
state_instance = get_env_state_instance_store().get(ids[0])
|
|
1256
|
+
except NotFoundError:
|
|
1257
|
+
logger.warning(f"env_state_instance {ids[0]} not found on reattach; defaulting to local")
|
|
1258
|
+
# No record (env predates state instances, or it expired) → default to the local store.
|
|
1259
|
+
state_instance = state_instance or LocalPostgresStateProvider.default_instance()
|
|
1260
|
+
self._state_instance = state_instance
|
|
1261
|
+
self._state_provider = build_state_provider(state_instance.state_type)
|
|
1262
|
+
return self._sandbox
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
@dataclass(frozen=True)
|
|
1266
|
+
class _GatewayTopology:
|
|
1267
|
+
"""What a gateway fronts for one env, and the one way its record differs by env type."""
|
|
1268
|
+
|
|
1269
|
+
mcp_servers: list[MCPServerConfig]
|
|
1270
|
+
mcp_server_images: list[DockerImageArtifact]
|
|
1271
|
+
website_configs: list[WebsiteConfig] | None
|
|
1272
|
+
website_images: list[DockerImageArtifact] | None
|
|
1273
|
+
mcp_server_name: str | None # a MultiEnv's name; None draws one
|
|
1274
|
+
names_gateway_on_vm: bool # a MultiEnv's record names its gateway sandbox even on a VM
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def _gateway_topology(env: Env) -> _GatewayTopology:
|
|
1278
|
+
"""What a gateway fronts for env: a MultiEnv's MCP servers and websites (whatever their own env_provider_type) under its
|
|
1279
|
+
name, a WebsiteEnv's website, or else the env's one MCP server."""
|
|
1280
|
+
from agent_env.env.envs.multi_env import MultiEnv
|
|
1281
|
+
from agent_env.env.envs.website import WebsiteEnv
|
|
1282
|
+
|
|
1283
|
+
is_multi = isinstance(env, MultiEnv)
|
|
1284
|
+
if is_multi:
|
|
1285
|
+
servers, websites, name = env.mcp_server_envs, env.website_envs, env.name
|
|
1286
|
+
elif isinstance(env, WebsiteEnv):
|
|
1287
|
+
servers, websites, name = [], [env], None
|
|
1288
|
+
else:
|
|
1289
|
+
servers, websites, name = [env], [], None
|
|
1290
|
+
return _GatewayTopology(
|
|
1291
|
+
mcp_servers=[MCPServerConfig(image=s.docker_image_artifact.image_name, environment_name=s.environment_name) for s in servers],
|
|
1292
|
+
mcp_server_images=[s.docker_image_artifact for s in servers],
|
|
1293
|
+
website_configs=[
|
|
1294
|
+
WebsiteConfig(backend_image=w.backend_docker_image_artifact.image_name, frontend_image=w.frontend_docker_image_artifact.image_name,
|
|
1295
|
+
environment_name=w.environment_name)
|
|
1296
|
+
for w in websites
|
|
1297
|
+
] or None,
|
|
1298
|
+
website_images=[image for w in websites for image in (w.backend_docker_image_artifact, w.frontend_docker_image_artifact)] or None,
|
|
1299
|
+
mcp_server_name=name,
|
|
1300
|
+
names_gateway_on_vm=is_multi,
|
|
1301
|
+
)
|
|
1302
|
+
|
|
1303
|
+
|
|
1304
|
+
# Compose `environment:` keys whose values are secrets and must be masked before the
|
|
1305
|
+
# rendered compose is logged. Matches the key to the left of the first '=' on an
|
|
1306
|
+
# ` - KEY=VALUE` env line.
|
|
1307
|
+
_SECRET_ENV_KEY_RE = re.compile(r"^(\s*-\s*)([A-Za-z0-9_]*(?:SECRET|PASSWORD|TOKEN|API_KEY)[A-Za-z0-9_]*)=.*$")
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _redact_compose_secrets(compose_content: str) -> str:
|
|
1311
|
+
"""Mask secret-valued env lines so the logged compose never carries a live secret."""
|
|
1312
|
+
return "\n".join(
|
|
1313
|
+
_SECRET_ENV_KEY_RE.sub(r"\1\2=***", line) for line in compose_content.splitlines()
|
|
1314
|
+
)
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
# Cap on the post-readiness tools/list probe; it only logs, so a slow gateway costs deploy time, never the deploy.
|
|
1318
|
+
_TOOLS_PROBE_TIMEOUT_S = 10.0
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
async def _probe_tools(env_id: str | None, gateway: DeployedGateway) -> None:
|
|
1322
|
+
"""Log whether the gateway's MCP tools cover every tool its card's children advertise; never raises."""
|
|
1323
|
+
start = time.monotonic()
|
|
1324
|
+
head = f"env_deploy_tools_probe env_id={env_id or 'unknown'}"
|
|
1325
|
+
children = ""
|
|
1326
|
+
try:
|
|
1327
|
+
cards = (gateway.environment_card or {}).get("children_environments") or []
|
|
1328
|
+
children = ",".join(c.get("name") or "" for c in cards)
|
|
1329
|
+
async with asyncio.timeout(_TOOLS_PROBE_TIMEOUT_S):
|
|
1330
|
+
tools = set(await _tool_names(gateway.mcp_url))
|
|
1331
|
+
missing = [c.get("name") or "" for c in cards if not _card_tool_names(c) <= tools]
|
|
1332
|
+
except Exception as e:
|
|
1333
|
+
logger.warning(f"{head} status=failed duration_s={time.monotonic() - start:.1f} children={children} error_type={type(e).__name__} error={e!r}")
|
|
1334
|
+
return
|
|
1335
|
+
status = "ok" if tools and not missing else "missing"
|
|
1336
|
+
log = logger.info if status == "ok" else logger.warning
|
|
1337
|
+
log(f"{head} status={status} tools={len(tools)} duration_s={time.monotonic() - start:.1f} children={children} missing={','.join(missing)}")
|
|
1338
|
+
|
|
1339
|
+
|
|
1340
|
+
def _card_tool_names(card: dict) -> set[str]:
|
|
1341
|
+
return {tool.get("name") for tool in (card.get("capabilities") or {}).get("tools") or []}
|
|
1342
|
+
|
|
1343
|
+
|
|
1344
|
+
@dataclass(frozen=True)
|
|
1345
|
+
class _ContainerDeploy:
|
|
1346
|
+
"""What every container of one container-mode deploy is created with."""
|
|
1347
|
+
|
|
1348
|
+
sandbox_provider: SandboxProvider
|
|
1349
|
+
cpu: float | None
|
|
1350
|
+
disk_size_gb: float
|
|
1351
|
+
ttl_seconds: int
|
|
1352
|
+
attribution: Optional[Attribution]
|
|
1353
|
+
priority: Optional[int]
|
|
1354
|
+
i6pn_kwargs: dict
|