autobots-devtools-shared-lib 0.9.0__tar.gz → 0.10.0__tar.gz
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.
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/PKG-INFO +1 -1
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/pyproject.toml +1 -1
- autobots_devtools_shared_lib-0.10.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__init__.py +23 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__main__.py +10 -1
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/app.py +136 -67
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/config.py +24 -6
- autobots_devtools_shared_lib-0.10.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/exceptions.py +66 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/models.py +11 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/noderedmanager_client_tools.py +9 -3
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/noderedmanager_client_utils.py +38 -9
- autobots_devtools_shared_lib-0.9.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__init__.py +0 -5
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/README.md +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/config/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/config/jenkins_config.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/config/jenkins_constants.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/config/jenkins_loader.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/logging_utils.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/otel_fastapi.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/trace_metadata.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/trace_propagation.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/observability/tracing.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/README.md +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/app.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/config.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/models.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/README.md +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/README.md +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/cache_backed.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/db_repository.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/factory.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/in_memory.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/redis_store.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/services/context/store.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/context_tools.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/format_tools.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/fserver_client_tools.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/jenkins_builtin_tools.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/tools/jenkins_pipeline_tools.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/context_utils.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/format_utils.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/fserver_client_utils.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_builtin_utils.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_http_utils.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_pipeline_utils.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/agent_config_utils.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/agent_meta.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/base_agent.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/batch.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/invocation_utils.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/agents/middleware.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/config/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/config/dynagent_settings.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/llm/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/llm/llm.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/models/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/models/state.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/services/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/services/structured_converter.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/tools/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/tools/state_tools.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/tools/tool_registry.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/ui/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/ui/default_ui.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/ui/ui_utils.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/utils/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/dynagent/utils/schema_directive_resolver.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/deterministic.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/golden.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/llm_judge.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/registry.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/assertions/written_file.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/core/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/core/cost_tracker.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/core/loader.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/core/runner.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/core/workspace.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/models/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/models/eval_case.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/models/result.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/fixtures.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/plugin.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/reporting.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/scoring/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/eval/scoring/langfuse_scorer.py +0 -0
- {autobots_devtools_shared_lib-0.9.0 → autobots_devtools_shared_lib-0.10.0}/src/autobots_devtools_shared_lib/py.typed +0 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Node-RED instance manager server package."""
|
|
2
|
+
|
|
3
|
+
from .app import app # re-export for convenience
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
FlowsFileNotFoundError,
|
|
6
|
+
InstanceNotFoundError,
|
|
7
|
+
InvalidWorkspacePathError,
|
|
8
|
+
NoAvailablePortError,
|
|
9
|
+
NodeRedLaunchError,
|
|
10
|
+
NodeRedManagerError,
|
|
11
|
+
UnknownEnvironmentError,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"FlowsFileNotFoundError",
|
|
16
|
+
"InstanceNotFoundError",
|
|
17
|
+
"InvalidWorkspacePathError",
|
|
18
|
+
"NoAvailablePortError",
|
|
19
|
+
"NodeRedLaunchError",
|
|
20
|
+
"NodeRedManagerError",
|
|
21
|
+
"UnknownEnvironmentError",
|
|
22
|
+
"app",
|
|
23
|
+
]
|
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
"""Entry point: reads host/port from node-red-config.yaml and starts the server."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
|
|
3
6
|
import uvicorn
|
|
4
7
|
|
|
5
8
|
from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.config import (
|
|
6
9
|
NodeRedManagerServerConfig,
|
|
7
10
|
)
|
|
8
11
|
|
|
12
|
+
# asyncio.create_subprocess_exec requires ProactorEventLoop on Windows.
|
|
13
|
+
# Python 3.12+ defaults to it, but set explicitly to guard against uvicorn
|
|
14
|
+
# or dependency changes that could reset the event loop policy.
|
|
15
|
+
if sys.platform == "win32":
|
|
16
|
+
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
|
|
17
|
+
|
|
9
18
|
if __name__ == "__main__":
|
|
10
19
|
cfg = NodeRedManagerServerConfig()
|
|
11
20
|
uvicorn.run(
|
|
12
21
|
"autobots_devtools_shared_lib.common.servers.noderedmanagerserver.app:app",
|
|
13
22
|
host=cfg.node_red_manager_server_host,
|
|
14
23
|
port=cfg.node_red_manager_server_port,
|
|
15
|
-
reload=
|
|
24
|
+
reload=False,
|
|
16
25
|
)
|
|
@@ -16,18 +16,31 @@ Or: make node-red-server (from autobots-devtools-shared-lib)
|
|
|
16
16
|
import asyncio
|
|
17
17
|
import contextlib
|
|
18
18
|
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
19
21
|
from contextlib import asynccontextmanager
|
|
20
|
-
from datetime import UTC, datetime
|
|
22
|
+
from datetime import UTC, datetime, timedelta
|
|
21
23
|
from pathlib import Path
|
|
22
24
|
from typing import Any
|
|
23
25
|
|
|
24
|
-
from fastapi import FastAPI
|
|
26
|
+
from fastapi import FastAPI
|
|
27
|
+
from fastapi.requests import Request
|
|
28
|
+
from fastapi.responses import JSONResponse
|
|
25
29
|
|
|
26
30
|
from autobots_devtools_shared_lib.common.observability.logging_utils import get_logger
|
|
27
31
|
from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.config import (
|
|
28
32
|
NodeRedManagerServerConfig,
|
|
29
33
|
TemplateConfig,
|
|
30
34
|
)
|
|
35
|
+
from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.exceptions import (
|
|
36
|
+
FlowsFileNotFoundError,
|
|
37
|
+
InstanceNotFoundError,
|
|
38
|
+
InvalidWorkspacePathError,
|
|
39
|
+
NoAvailablePortError,
|
|
40
|
+
NodeRedLaunchError,
|
|
41
|
+
NodeRedManagerError,
|
|
42
|
+
UnknownEnvironmentError,
|
|
43
|
+
)
|
|
31
44
|
from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.models import (
|
|
32
45
|
CreateInstanceRequest,
|
|
33
46
|
CreateInstanceResponse,
|
|
@@ -39,8 +52,8 @@ from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.models imp
|
|
|
39
52
|
logger = get_logger(__name__)
|
|
40
53
|
config = NodeRedManagerServerConfig()
|
|
41
54
|
|
|
42
|
-
# In-memory registry: instance_id -> (InstanceInfo, subprocess handle)
|
|
43
|
-
_registry: dict[str, tuple[InstanceInfo, asyncio.subprocess.Process]] = {}
|
|
55
|
+
# In-memory registry: instance_id -> (InstanceInfo, subprocess handle, TTL task)
|
|
56
|
+
_registry: dict[str, tuple[InstanceInfo, asyncio.subprocess.Process, asyncio.Task[None]]] = {}
|
|
44
57
|
|
|
45
58
|
|
|
46
59
|
# ---------------------------------------------------------------------------
|
|
@@ -62,13 +75,13 @@ async def _is_port_available(port: int) -> bool:
|
|
|
62
75
|
|
|
63
76
|
async def _find_available_port(template: TemplateConfig) -> int:
|
|
64
77
|
"""Scan sequentially within the template's port range; skip ports used by tracked instances."""
|
|
65
|
-
used_ports = {info.port for info, _ in _registry.values()}
|
|
78
|
+
used_ports = {info.port for info, _, _ in _registry.values()}
|
|
66
79
|
for port in range(template.min_port, template.max_port + 1):
|
|
67
80
|
if port in used_ports:
|
|
68
81
|
continue
|
|
69
82
|
if await _is_port_available(port):
|
|
70
83
|
return port
|
|
71
|
-
raise
|
|
84
|
+
raise NoAvailablePortError(
|
|
72
85
|
f"No available ports in range [{template.min_port}, {template.max_port}] "
|
|
73
86
|
f"for environment '{template.name}'. All ports are occupied."
|
|
74
87
|
)
|
|
@@ -86,26 +99,54 @@ async def _launch_node_red(
|
|
|
86
99
|
|
|
87
100
|
INSTANCE_ID is passed as an env var so the environment's settings.js can set
|
|
88
101
|
httpAdminRoot and httpNodeRoot to '/<instance_id>' for URL isolation.
|
|
102
|
+
|
|
103
|
+
On Windows, node-red is installed as a .cmd batch file and cannot be executed
|
|
104
|
+
directly by create_subprocess_exec — it must be routed through cmd.exe.
|
|
105
|
+
CREATE_NEW_PROCESS_GROUP assigns a new process group so taskkill /T can target
|
|
106
|
+
the entire tree (cmd.exe + node-red child) during cleanup.
|
|
89
107
|
"""
|
|
90
108
|
env = {**os.environ, "FLOW": flows_json_path, "INSTANCE_ID": instance_id}
|
|
109
|
+
node_red_args = ["-u", str(template.path), "--port", str(port)]
|
|
110
|
+
if sys.platform == "win32":
|
|
111
|
+
cmd = ["cmd", "/c", config.node_red_executable, *node_red_args]
|
|
112
|
+
extra_kwargs: dict = {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP}
|
|
113
|
+
else:
|
|
114
|
+
cmd = [config.node_red_executable, *node_red_args]
|
|
115
|
+
extra_kwargs = {}
|
|
91
116
|
return await asyncio.create_subprocess_exec(
|
|
92
|
-
|
|
93
|
-
"-u",
|
|
94
|
-
str(template.path),
|
|
95
|
-
"--port",
|
|
96
|
-
str(port),
|
|
117
|
+
*cmd,
|
|
97
118
|
env=env,
|
|
98
119
|
stdout=asyncio.subprocess.PIPE,
|
|
99
120
|
stderr=asyncio.subprocess.PIPE,
|
|
121
|
+
**extra_kwargs,
|
|
100
122
|
)
|
|
101
123
|
|
|
102
124
|
|
|
103
125
|
async def _kill_instance(instance_id: str, process: asyncio.subprocess.Process) -> None:
|
|
104
|
-
"""
|
|
126
|
+
"""Terminate the process and its children.
|
|
127
|
+
|
|
128
|
+
On Unix: SIGTERM with a 5s grace period, then SIGKILL.
|
|
129
|
+
On Windows: taskkill /F /T kills the entire process tree (cmd.exe + node-red child)
|
|
130
|
+
because process.terminate() only kills cmd.exe, leaving node-red orphaned.
|
|
131
|
+
"""
|
|
105
132
|
try:
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
133
|
+
if sys.platform == "win32":
|
|
134
|
+
killer = await asyncio.create_subprocess_exec(
|
|
135
|
+
"taskkill",
|
|
136
|
+
"/F",
|
|
137
|
+
"/T",
|
|
138
|
+
"/PID",
|
|
139
|
+
str(process.pid),
|
|
140
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
141
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
142
|
+
)
|
|
143
|
+
await killer.wait()
|
|
144
|
+
await process.wait()
|
|
145
|
+
logger.info("Instance %s terminated (Windows taskkill)", instance_id)
|
|
146
|
+
else:
|
|
147
|
+
process.terminate() # SIGTERM on Unix
|
|
148
|
+
await asyncio.wait_for(process.wait(), timeout=5.0)
|
|
149
|
+
logger.info("Instance %s terminated gracefully", instance_id)
|
|
109
150
|
except ProcessLookupError:
|
|
110
151
|
logger.info("Instance %s process already gone (pid=%s)", instance_id, process.pid)
|
|
111
152
|
except TimeoutError:
|
|
@@ -115,6 +156,20 @@ async def _kill_instance(instance_id: str, process: asyncio.subprocess.Process)
|
|
|
115
156
|
await process.wait()
|
|
116
157
|
|
|
117
158
|
|
|
159
|
+
async def _ttl_kill(instance_id: str, ttl_seconds: int) -> None:
|
|
160
|
+
"""Background task: auto-kill an instance after its TTL expires."""
|
|
161
|
+
try:
|
|
162
|
+
await asyncio.sleep(ttl_seconds)
|
|
163
|
+
except asyncio.CancelledError:
|
|
164
|
+
return # killed manually before TTL; nothing to do
|
|
165
|
+
entry = _registry.pop(instance_id, None)
|
|
166
|
+
if entry is None:
|
|
167
|
+
return # already removed (e.g. manual kill lost the race)
|
|
168
|
+
_, process, _ = entry
|
|
169
|
+
logger.info("TTL expired for instance %s, killing", instance_id)
|
|
170
|
+
await _kill_instance(instance_id, process)
|
|
171
|
+
|
|
172
|
+
|
|
118
173
|
# ---------------------------------------------------------------------------
|
|
119
174
|
# Lifespan
|
|
120
175
|
# ---------------------------------------------------------------------------
|
|
@@ -139,7 +194,9 @@ async def lifespan(_app: FastAPI):
|
|
|
139
194
|
yield
|
|
140
195
|
|
|
141
196
|
logger.info("Node-RED server shutting down, terminating %d instance(s)", len(_registry))
|
|
142
|
-
|
|
197
|
+
for _, _, ttl_task in _registry.values():
|
|
198
|
+
ttl_task.cancel()
|
|
199
|
+
tasks = [_kill_instance(iid, proc) for iid, (_, proc, _) in _registry.items()]
|
|
143
200
|
if tasks:
|
|
144
201
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
145
202
|
_registry.clear()
|
|
@@ -163,6 +220,37 @@ app = FastAPI(
|
|
|
163
220
|
)
|
|
164
221
|
|
|
165
222
|
|
|
223
|
+
@app.exception_handler(NodeRedManagerError)
|
|
224
|
+
async def _node_red_manager_error_handler(
|
|
225
|
+
_request: Request, exc: NodeRedManagerError
|
|
226
|
+
) -> JSONResponse:
|
|
227
|
+
"""Convert domain exceptions to HTTP JSON responses."""
|
|
228
|
+
return JSONResponse(
|
|
229
|
+
status_code=exc.status_code,
|
|
230
|
+
content={"detail": exc.detail, "error_code": exc.ERROR_CODE},
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# ---------------------------------------------------------------------------
|
|
235
|
+
# Helpers
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _resolve_instance_id(workspace_context: dict, environment_name: str) -> str:
|
|
240
|
+
"""Validate workspace_base_path and return the scoped instance ID.
|
|
241
|
+
|
|
242
|
+
Raises InvalidWorkspacePathError if the path is missing or contains '..'.
|
|
243
|
+
"""
|
|
244
|
+
workspace_base_path = (workspace_context.get("workspace_base_path") or "").strip()
|
|
245
|
+
if not workspace_base_path:
|
|
246
|
+
raise InvalidWorkspacePathError(
|
|
247
|
+
"workspace_context.workspace_base_path is required and cannot be empty."
|
|
248
|
+
)
|
|
249
|
+
if ".." in workspace_base_path:
|
|
250
|
+
raise InvalidWorkspacePathError("workspace_base_path cannot contain '..'")
|
|
251
|
+
return f"{environment_name}/{workspace_base_path}"
|
|
252
|
+
|
|
253
|
+
|
|
166
254
|
# ---------------------------------------------------------------------------
|
|
167
255
|
# Endpoints
|
|
168
256
|
# ---------------------------------------------------------------------------
|
|
@@ -206,8 +294,10 @@ def list_instances() -> dict[str, Any]:
|
|
|
206
294
|
"environment_name": info.environment_name,
|
|
207
295
|
"url": info.url,
|
|
208
296
|
"pid": info.pid,
|
|
297
|
+
"created_at": info.created_at.isoformat(),
|
|
298
|
+
"expires_at": info.expires_at.isoformat(),
|
|
209
299
|
}
|
|
210
|
-
for info, _ in _registry.values()
|
|
300
|
+
for info, _, _ in _registry.values()
|
|
211
301
|
]
|
|
212
302
|
return {"instances": instances, "count": len(instances)}
|
|
213
303
|
|
|
@@ -227,66 +317,48 @@ async def create_instance(body: CreateInstanceRequest) -> CreateInstanceResponse
|
|
|
227
317
|
body.workspace_context,
|
|
228
318
|
)
|
|
229
319
|
|
|
230
|
-
# 1.
|
|
231
|
-
|
|
232
|
-
if not workspace_base_path:
|
|
233
|
-
raise HTTPException(
|
|
234
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
235
|
-
detail="workspace_context.workspace_base_path is required and cannot be empty.",
|
|
236
|
-
)
|
|
237
|
-
if ".." in workspace_base_path:
|
|
238
|
-
raise HTTPException(
|
|
239
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
240
|
-
detail="workspace_base_path cannot contain '..'",
|
|
241
|
-
)
|
|
242
|
-
# Instance ID scoped per environment so the same workspace can run multiple environments
|
|
243
|
-
instance_id = f"{body.environment_name}/{workspace_base_path}"
|
|
320
|
+
# 1. Validate workspace path and derive scoped instance ID
|
|
321
|
+
instance_id = _resolve_instance_id(body.workspace_context, body.environment_name)
|
|
244
322
|
|
|
245
323
|
# 2. Return existing instance if one is already running for this workspace
|
|
246
324
|
if instance_id in _registry:
|
|
247
|
-
existing_info, _ = _registry[instance_id]
|
|
325
|
+
existing_info, _, _ = _registry[instance_id]
|
|
248
326
|
logger.info(
|
|
249
327
|
"create-instance reusing existing id=%s url=%s", existing_info.id, existing_info.url
|
|
250
328
|
)
|
|
251
|
-
return CreateInstanceResponse(
|
|
329
|
+
return CreateInstanceResponse(
|
|
330
|
+
id=existing_info.id, url=existing_info.url, expires_at=existing_info.expires_at
|
|
331
|
+
)
|
|
252
332
|
|
|
253
333
|
# 3. Validate environment name
|
|
254
334
|
environment = config.environments.get(body.environment_name)
|
|
255
335
|
if environment is None:
|
|
256
336
|
logger.warning("create-instance unknown environment=%s", body.environment_name)
|
|
257
|
-
raise
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
f"Unknown environment '{body.environment_name}'. "
|
|
261
|
-
f"Available: {list(config.environments.keys())}"
|
|
262
|
-
),
|
|
337
|
+
raise UnknownEnvironmentError(
|
|
338
|
+
f"Unknown environment '{body.environment_name}'. "
|
|
339
|
+
f"Available: {list(config.environments.keys())}"
|
|
263
340
|
)
|
|
264
341
|
|
|
265
342
|
# 4. Resolve full flows path: base_path / workspace_base_path / flows_json_path
|
|
343
|
+
workspace_base_path = instance_id.split("/", 1)[1]
|
|
266
344
|
flows_path = Path(config.base_path) / workspace_base_path / body.flows_json_path
|
|
267
345
|
if not flows_path.exists():
|
|
268
|
-
raise
|
|
269
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
270
|
-
detail=f"flows.json not found at resolved path: {flows_path}",
|
|
271
|
-
)
|
|
346
|
+
raise FlowsFileNotFoundError(f"flows.json not found at resolved path: {flows_path}")
|
|
272
347
|
|
|
273
|
-
# 5. Find next available port within this environment's port range
|
|
274
|
-
|
|
275
|
-
port = await _find_available_port(environment)
|
|
276
|
-
except RuntimeError as e:
|
|
277
|
-
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) from e
|
|
348
|
+
# 5. Find next available port within this environment's port range (raises NoAvailablePortError)
|
|
349
|
+
port = await _find_available_port(environment)
|
|
278
350
|
|
|
279
351
|
# 6. Launch subprocess — INSTANCE_ID env var picked up by the environment's settings.js
|
|
280
352
|
try:
|
|
281
353
|
process = await _launch_node_red(environment, str(flows_path), port, instance_id)
|
|
282
354
|
except Exception as e:
|
|
283
355
|
logger.exception("create-instance failed to launch node-red")
|
|
284
|
-
raise
|
|
285
|
-
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
286
|
-
detail=f"Failed to launch Node-RED: {e!s}",
|
|
287
|
-
) from e
|
|
356
|
+
raise NodeRedLaunchError(f"Failed to launch Node-RED: {e!s}") from e
|
|
288
357
|
|
|
289
358
|
# 7. Register and return
|
|
359
|
+
ttl = body.ttl_seconds if body.ttl_seconds is not None else config.instance_ttl_seconds
|
|
360
|
+
created_at = datetime.now(UTC)
|
|
361
|
+
expires_at = created_at + timedelta(seconds=ttl)
|
|
290
362
|
url = f"http://{config.node_red_manager_server_host}:{port}/{instance_id}"
|
|
291
363
|
info = InstanceInfo(
|
|
292
364
|
id=instance_id,
|
|
@@ -294,32 +366,29 @@ async def create_instance(body: CreateInstanceRequest) -> CreateInstanceResponse
|
|
|
294
366
|
environment_name=body.environment_name,
|
|
295
367
|
url=url,
|
|
296
368
|
pid=process.pid or 0,
|
|
369
|
+
created_at=created_at,
|
|
370
|
+
expires_at=expires_at,
|
|
297
371
|
)
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
372
|
+
ttl_task = asyncio.create_task(_ttl_kill(instance_id, ttl))
|
|
373
|
+
_registry[instance_id] = (info, process, ttl_task)
|
|
374
|
+
logger.info(
|
|
375
|
+
"create-instance success id=%s url=%s pid=%s ttl=%ss", instance_id, url, process.pid, ttl
|
|
376
|
+
)
|
|
377
|
+
return CreateInstanceResponse(id=instance_id, url=url, expires_at=expires_at)
|
|
301
378
|
|
|
302
379
|
|
|
303
380
|
@app.post("/kill-instance")
|
|
304
381
|
async def kill_instance(body: KillInstanceRequest) -> KillInstanceResponse:
|
|
305
382
|
"""Kill a running Node-RED instance by workspace_base_path."""
|
|
306
|
-
|
|
307
|
-
if not workspace_base_path:
|
|
308
|
-
raise HTTPException(
|
|
309
|
-
status_code=status.HTTP_400_BAD_REQUEST,
|
|
310
|
-
detail="workspace_context.workspace_base_path is required and cannot be empty.",
|
|
311
|
-
)
|
|
312
|
-
instance_id = f"{body.environment_name}/{workspace_base_path}"
|
|
383
|
+
instance_id = _resolve_instance_id(body.workspace_context, body.environment_name)
|
|
313
384
|
logger.info("kill-instance called id=%s", instance_id)
|
|
314
385
|
|
|
315
386
|
entry = _registry.get(instance_id)
|
|
316
387
|
if entry is None:
|
|
317
|
-
raise
|
|
318
|
-
status_code=status.HTTP_404_NOT_FOUND,
|
|
319
|
-
detail=f"Instance '{instance_id}' not found",
|
|
320
|
-
)
|
|
388
|
+
raise InstanceNotFoundError(f"Instance '{instance_id}' not found")
|
|
321
389
|
|
|
322
|
-
_, process = entry
|
|
390
|
+
_, process, ttl_task = entry
|
|
391
|
+
ttl_task.cancel()
|
|
323
392
|
await _kill_instance(instance_id, process)
|
|
324
393
|
del _registry[instance_id]
|
|
325
394
|
|
|
@@ -22,10 +22,10 @@ class TemplateConfig:
|
|
|
22
22
|
|
|
23
23
|
def _load_yaml_config(
|
|
24
24
|
config_file: Path,
|
|
25
|
-
) -> tuple[str, str, str, int, dict[str, TemplateConfig]]:
|
|
25
|
+
) -> tuple[str, str, str, int, int, dict[str, TemplateConfig]]:
|
|
26
26
|
"""
|
|
27
27
|
Load node-red-config.yaml and return
|
|
28
|
-
(node_red_executable, base_path, manager_host, manager_port, environments_by_name).
|
|
28
|
+
(node_red_executable, base_path, manager_host, manager_port, instance_ttl_seconds, environments_by_name).
|
|
29
29
|
|
|
30
30
|
Expected YAML structure::
|
|
31
31
|
|
|
@@ -38,6 +38,9 @@ def _load_yaml_config(
|
|
|
38
38
|
node_red_manager_server_host: 0.0.0.0 # optional, defaults to "0.0.0.0"
|
|
39
39
|
node_red_manager_server_port: 9003 # optional, defaults to 9003
|
|
40
40
|
|
|
41
|
+
# Seconds before an idle instance is auto-killed. Optional, defaults to 2700 (45 min).
|
|
42
|
+
instance_ttl_seconds: 2700
|
|
43
|
+
|
|
41
44
|
environments:
|
|
42
45
|
- name: compose-engine-template
|
|
43
46
|
path: /path/to/compose-engine-template
|
|
@@ -64,6 +67,7 @@ def _load_yaml_config(
|
|
|
64
67
|
data.get("node_red_manager_server_host", "0.0.0.0") or "0.0.0.0" # noqa: S104
|
|
65
68
|
)
|
|
66
69
|
manager_port: int = int(data.get("node_red_manager_server_port", 9003))
|
|
70
|
+
instance_ttl_seconds: int = int(data.get("instance_ttl_seconds", 2700))
|
|
67
71
|
|
|
68
72
|
raw_environments: list[dict] = data.get("environments") or []
|
|
69
73
|
environments: dict[str, TemplateConfig] = {}
|
|
@@ -83,7 +87,14 @@ def _load_yaml_config(
|
|
|
83
87
|
max_port=max_port,
|
|
84
88
|
)
|
|
85
89
|
|
|
86
|
-
return
|
|
90
|
+
return (
|
|
91
|
+
node_red_executable,
|
|
92
|
+
base_path,
|
|
93
|
+
manager_host,
|
|
94
|
+
manager_port,
|
|
95
|
+
instance_ttl_seconds,
|
|
96
|
+
environments,
|
|
97
|
+
)
|
|
87
98
|
|
|
88
99
|
|
|
89
100
|
# Resolve config file path from env var; default to node-red-config.yaml in cwd.
|
|
@@ -92,14 +103,20 @@ _config_file = Path(os.getenv("NODE_RED_CONFIG_FILE", "node-red-config.yaml"))
|
|
|
92
103
|
# Load at module import time so config is available immediately.
|
|
93
104
|
# If the file is absent the server will fail fast during lifespan startup (validate() call).
|
|
94
105
|
try:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
(
|
|
107
|
+
_node_red_executable,
|
|
108
|
+
_base_path,
|
|
109
|
+
_manager_host,
|
|
110
|
+
_manager_port,
|
|
111
|
+
_instance_ttl_seconds,
|
|
112
|
+
_environments,
|
|
113
|
+
) = _load_yaml_config(_config_file)
|
|
98
114
|
except FileNotFoundError:
|
|
99
115
|
_node_red_executable = "node-red"
|
|
100
116
|
_base_path = ""
|
|
101
117
|
_manager_host = "0.0.0.0" # noqa: S104
|
|
102
118
|
_manager_port = 9003
|
|
119
|
+
_instance_ttl_seconds = 2700
|
|
103
120
|
_environments = {}
|
|
104
121
|
|
|
105
122
|
|
|
@@ -111,6 +128,7 @@ class NodeRedManagerServerConfig:
|
|
|
111
128
|
base_path: str = _base_path
|
|
112
129
|
node_red_manager_server_host: str = _manager_host
|
|
113
130
|
node_red_manager_server_port: int = _manager_port
|
|
131
|
+
instance_ttl_seconds: int = _instance_ttl_seconds
|
|
114
132
|
environments: dict[str, TemplateConfig] = _environments
|
|
115
133
|
|
|
116
134
|
@classmethod
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Domain exceptions for the Node-RED instance manager server."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class NodeRedManagerError(Exception):
|
|
5
|
+
"""Base exception for Node-RED instance manager errors.
|
|
6
|
+
|
|
7
|
+
Subclasses declare a status_code so the central FastAPI exception handler
|
|
8
|
+
can convert them to HTTP responses without any per-endpoint boilerplate.
|
|
9
|
+
|
|
10
|
+
ERROR_CODE is a stable string constant included in HTTP responses and utils
|
|
11
|
+
error strings so consumers can distinguish error types without re-raising:
|
|
12
|
+
|
|
13
|
+
from ...exceptions import FlowsFileNotFoundError
|
|
14
|
+
result = create_instance(...)
|
|
15
|
+
if FlowsFileNotFoundError.ERROR_CODE in result:
|
|
16
|
+
...
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
status_code: int = 500
|
|
20
|
+
ERROR_CODE: str = "NODE_RED_MANAGER_ERROR"
|
|
21
|
+
|
|
22
|
+
def __init__(self, detail: str) -> None:
|
|
23
|
+
super().__init__(detail)
|
|
24
|
+
self.detail = detail
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class InvalidWorkspacePathError(NodeRedManagerError):
|
|
28
|
+
"""Raised when workspace_base_path is empty or contains invalid path segments."""
|
|
29
|
+
|
|
30
|
+
status_code = 400
|
|
31
|
+
ERROR_CODE = "INVALID_WORKSPACE_PATH"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class UnknownEnvironmentError(NodeRedManagerError):
|
|
35
|
+
"""Raised when the requested environment name is not in the server config."""
|
|
36
|
+
|
|
37
|
+
status_code = 400
|
|
38
|
+
ERROR_CODE = "UNKNOWN_ENVIRONMENT"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FlowsFileNotFoundError(NodeRedManagerError):
|
|
42
|
+
"""Raised when flows.json does not exist at the resolved workspace path."""
|
|
43
|
+
|
|
44
|
+
status_code = 404
|
|
45
|
+
ERROR_CODE = "FLOWS_FILE_NOT_FOUND"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class NoAvailablePortError(NodeRedManagerError):
|
|
49
|
+
"""Raised when no free port exists within the environment's configured range."""
|
|
50
|
+
|
|
51
|
+
status_code = 503
|
|
52
|
+
ERROR_CODE = "NO_AVAILABLE_PORT"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class NodeRedLaunchError(NodeRedManagerError):
|
|
56
|
+
"""Raised when the node-red subprocess fails to start."""
|
|
57
|
+
|
|
58
|
+
status_code = 500
|
|
59
|
+
ERROR_CODE = "NODE_RED_LAUNCH_ERROR"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class InstanceNotFoundError(NodeRedManagerError):
|
|
63
|
+
"""Raised when the requested instance ID is not in the registry."""
|
|
64
|
+
|
|
65
|
+
status_code = 404
|
|
66
|
+
ERROR_CODE = "INSTANCE_NOT_FOUND"
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"""Pydantic request/response models for the Node-RED instance manager API."""
|
|
2
2
|
|
|
3
|
+
from datetime import datetime
|
|
3
4
|
from typing import Any
|
|
4
5
|
|
|
5
6
|
from pydantic import BaseModel, Field, field_validator
|
|
@@ -20,6 +21,11 @@ class CreateInstanceRequest(BaseModel):
|
|
|
20
21
|
description="Relative path to flows.json within the workspace directory.",
|
|
21
22
|
)
|
|
22
23
|
environment_name: str = Field(..., description="Name of the Node-RED environment to use.")
|
|
24
|
+
ttl_seconds: int | None = Field(
|
|
25
|
+
default=None,
|
|
26
|
+
description="TTL in seconds before the instance is auto-killed. Uses server default if not provided.",
|
|
27
|
+
ge=1,
|
|
28
|
+
)
|
|
23
29
|
|
|
24
30
|
@field_validator("flows_json_path")
|
|
25
31
|
@classmethod
|
|
@@ -41,6 +47,9 @@ class CreateInstanceRequest(BaseModel):
|
|
|
41
47
|
class CreateInstanceResponse(BaseModel):
|
|
42
48
|
id: str = Field(..., description="Instance ID (workspace_base_path).")
|
|
43
49
|
url: str = Field(..., description="URL to access the Node-RED instance (includes port).")
|
|
50
|
+
expires_at: datetime = Field(
|
|
51
|
+
..., description="UTC datetime when this instance will be auto-killed."
|
|
52
|
+
)
|
|
44
53
|
|
|
45
54
|
|
|
46
55
|
class KillInstanceRequest(BaseModel):
|
|
@@ -62,3 +71,5 @@ class InstanceInfo(BaseModel):
|
|
|
62
71
|
environment_name: str
|
|
63
72
|
url: str
|
|
64
73
|
pid: int
|
|
74
|
+
created_at: datetime
|
|
75
|
+
expires_at: datetime
|
|
@@ -72,24 +72,30 @@ def create_instance_tool(
|
|
|
72
72
|
workspace_base_path: str = "",
|
|
73
73
|
flows_json_path: str = "",
|
|
74
74
|
environment_name: str = "",
|
|
75
|
+
ttl_seconds: int | None = None,
|
|
75
76
|
) -> str:
|
|
76
77
|
"""
|
|
77
78
|
Launch a new Node-RED instance for the given workspace.
|
|
78
79
|
|
|
79
|
-
Returns the instance id and
|
|
80
|
-
the existing one is returned without launching a new process.
|
|
80
|
+
Returns the instance id, URL, and expiry time. If an instance already exists for this
|
|
81
|
+
workspace, the existing one is returned without launching a new process.
|
|
82
|
+
|
|
83
|
+
ttl_seconds overrides the server default TTL (45 min). The instance is auto-killed after
|
|
84
|
+
the TTL expires.
|
|
81
85
|
"""
|
|
82
86
|
logger.info(
|
|
83
|
-
"[tools] Creating Node-RED instance workspace=%r flows=%r environment=%r",
|
|
87
|
+
"[tools] Creating Node-RED instance workspace=%r flows=%r environment=%r ttl=%s",
|
|
84
88
|
workspace_base_path,
|
|
85
89
|
flows_json_path,
|
|
86
90
|
environment_name,
|
|
91
|
+
ttl_seconds,
|
|
87
92
|
)
|
|
88
93
|
try:
|
|
89
94
|
result = create_instance(
|
|
90
95
|
workspace_base_path,
|
|
91
96
|
flows_json_path,
|
|
92
97
|
environment_name,
|
|
98
|
+
ttl_seconds=ttl_seconds,
|
|
93
99
|
session_id=_session_id_from_runtime(runtime),
|
|
94
100
|
)
|
|
95
101
|
_check_result(result, "create_instance")
|
|
@@ -10,6 +10,27 @@ from autobots_devtools_shared_lib.common.observability.trace_propagation import
|
|
|
10
10
|
logger = get_logger(__name__)
|
|
11
11
|
|
|
12
12
|
NODE_RED_MANAGER_HOST = os.getenv("NODE_RED_MANAGER_HOST", "localhost")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _http_error_str(prefix: str, e: httpx.HTTPStatusError) -> str:
|
|
16
|
+
"""Format an HTTPStatusError into a standardised error string.
|
|
17
|
+
|
|
18
|
+
Parses the JSON body to include the server's error_code (if present) so
|
|
19
|
+
consumers can compare against exception constants without re-raising:
|
|
20
|
+
|
|
21
|
+
if FlowsFileNotFoundError.ERROR_CODE in result: ...
|
|
22
|
+
"""
|
|
23
|
+
try:
|
|
24
|
+
body = e.response.json()
|
|
25
|
+
error_code = body.get("error_code", "")
|
|
26
|
+
detail = body.get("detail", e.response.text)
|
|
27
|
+
except Exception:
|
|
28
|
+
error_code = ""
|
|
29
|
+
detail = e.response.text
|
|
30
|
+
code_part = f" [{error_code}]" if error_code else ""
|
|
31
|
+
return f"Error {prefix}: HTTP {e.response.status_code}{code_part} - {detail}"
|
|
32
|
+
|
|
33
|
+
|
|
13
34
|
NODE_RED_MANAGER_PORT = os.getenv("NODE_RED_MANAGER_PORT", "9003")
|
|
14
35
|
NODE_RED_MANAGER_BASE_URL = f"http://{NODE_RED_MANAGER_HOST}:{NODE_RED_MANAGER_PORT}"
|
|
15
36
|
|
|
@@ -43,7 +64,7 @@ def get_health(session_id: str | None = None) -> str:
|
|
|
43
64
|
e.response.status_code,
|
|
44
65
|
e.response.text,
|
|
45
66
|
)
|
|
46
|
-
return
|
|
67
|
+
return _http_error_str("getting health", e)
|
|
47
68
|
except Exception as e:
|
|
48
69
|
logger.exception("Error getting Node-RED manager health")
|
|
49
70
|
return f"Error getting health: {e!s}"
|
|
@@ -81,7 +102,7 @@ def list_instances(session_id: str | None = None) -> str:
|
|
|
81
102
|
e.response.status_code,
|
|
82
103
|
e.response.text,
|
|
83
104
|
)
|
|
84
|
-
return
|
|
105
|
+
return _http_error_str("listing instances", e)
|
|
85
106
|
except Exception as e:
|
|
86
107
|
logger.exception("Error listing Node-RED instances")
|
|
87
108
|
return f"Error listing instances: {e!s}"
|
|
@@ -95,6 +116,7 @@ def create_instance(
|
|
|
95
116
|
workspace_base_path: str,
|
|
96
117
|
flows_json_path: str,
|
|
97
118
|
environment_name: str,
|
|
119
|
+
ttl_seconds: int | None = None,
|
|
98
120
|
session_id: str | None = None,
|
|
99
121
|
) -> str:
|
|
100
122
|
"""
|
|
@@ -107,23 +129,27 @@ def create_instance(
|
|
|
107
129
|
workspace_base_path: Workspace path (e.g. 'user/repo-JIRA-42').
|
|
108
130
|
flows_json_path: Relative path to flows.json within the workspace directory.
|
|
109
131
|
environment_name: Name of the Node-RED environment template to use.
|
|
132
|
+
ttl_seconds: TTL in seconds before the instance is auto-killed. Uses server default if None.
|
|
110
133
|
session_id: Optional session ID for trace correlation.
|
|
111
134
|
|
|
112
135
|
Returns:
|
|
113
|
-
Success message with instance id and
|
|
136
|
+
Success message with instance id, url, and expiry time.
|
|
114
137
|
"""
|
|
115
138
|
logger.info(
|
|
116
|
-
"Creating Node-RED instance workspace=%r flows=%r environment=%r",
|
|
139
|
+
"Creating Node-RED instance workspace=%r flows=%r environment=%r ttl=%s",
|
|
117
140
|
workspace_base_path,
|
|
118
141
|
flows_json_path,
|
|
119
142
|
environment_name,
|
|
143
|
+
ttl_seconds,
|
|
120
144
|
)
|
|
121
145
|
try:
|
|
122
|
-
payload = {
|
|
146
|
+
payload: dict = {
|
|
123
147
|
"workspace_context": {"workspace_base_path": workspace_base_path},
|
|
124
148
|
"flows_json_path": flows_json_path,
|
|
125
149
|
"environment_name": environment_name,
|
|
126
150
|
}
|
|
151
|
+
if ttl_seconds is not None:
|
|
152
|
+
payload["ttl_seconds"] = ttl_seconds
|
|
127
153
|
with (
|
|
128
154
|
traced_http_call(
|
|
129
155
|
"noderedManagerCreateInstance", session_id=session_id
|
|
@@ -145,15 +171,18 @@ def create_instance(
|
|
|
145
171
|
e.response.status_code,
|
|
146
172
|
e.response.text,
|
|
147
173
|
)
|
|
148
|
-
return
|
|
174
|
+
return _http_error_str("creating instance", e)
|
|
149
175
|
except Exception as e:
|
|
150
176
|
logger.exception("Error creating Node-RED instance workspace=%r", workspace_base_path)
|
|
151
177
|
return f"Error creating instance: {e!s}"
|
|
152
178
|
else:
|
|
153
179
|
logger.info(
|
|
154
|
-
"Node-RED instance created/reused: id=%s url=%s
|
|
180
|
+
"Node-RED instance created/reused: id=%s url=%s expires_at=%s",
|
|
181
|
+
result.get("id"),
|
|
182
|
+
result.get("url"),
|
|
183
|
+
result.get("expires_at"),
|
|
155
184
|
)
|
|
156
|
-
return f"Instance created: id={result['id']} url={result['url']}"
|
|
185
|
+
return f"Instance created: id={result['id']} url={result['url']} expires_at={result['expires_at']}"
|
|
157
186
|
|
|
158
187
|
|
|
159
188
|
def kill_instance(
|
|
@@ -201,7 +230,7 @@ def kill_instance(
|
|
|
201
230
|
e.response.status_code,
|
|
202
231
|
e.response.text,
|
|
203
232
|
)
|
|
204
|
-
return
|
|
233
|
+
return _http_error_str("killing instance", e)
|
|
205
234
|
except Exception as e:
|
|
206
235
|
logger.exception(
|
|
207
236
|
"Error killing Node-RED instance workspace=%r environment=%r",
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|