autobots-devtools-shared-lib 0.6.1__tar.gz → 0.8.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.6.1 → autobots_devtools_shared_lib-0.8.0}/PKG-INFO +1 -1
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/pyproject.toml +1 -1
- autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/README.md +180 -0
- autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__init__.py +5 -0
- autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__main__.py +16 -0
- autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/app.py +325 -0
- autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/config.py +146 -0
- autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/models.py +63 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/assertions/golden.py +22 -5
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/assertions/registry.py +2 -0
- autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/eval/assertions/written_file.py +233 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/core/runner.py +2 -0
- autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/eval/core/workspace.py +129 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/fixtures.py +2 -3
- autobots_devtools_shared_lib-0.6.1/src/autobots_devtools_shared_lib/eval/core/workspace.py +0 -51
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/README.md +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/config/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/config/jenkins_config.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/config/jenkins_constants.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/config/jenkins_loader.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/logging_utils.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/otel_fastapi.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/trace_metadata.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/trace_propagation.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/tracing.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/README.md +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/app.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/config.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/models.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/README.md +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/cache_backed.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/db_repository.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/factory.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/in_memory.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/redis_store.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/store.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/context_tools.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/format_tools.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/fserver_client_tools.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/jenkins_builtin_tools.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/jenkins_pipeline_tools.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/context_utils.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/format_utils.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/fserver_client_utils.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_builtin_utils.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_http_utils.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_pipeline_utils.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/agent_config_utils.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/agent_meta.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/base_agent.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/batch.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/invocation_utils.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/middleware.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/config/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/config/dynagent_settings.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/llm/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/llm/llm.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/models/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/models/state.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/services/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/services/structured_converter.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/tools/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/tools/state_tools.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/tools/tool_registry.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/ui/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/ui/default_ui.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/ui/ui_utils.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/utils/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/utils/schema_directive_resolver.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/assertions/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/assertions/deterministic.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/assertions/llm_judge.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/core/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/core/cost_tracker.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/core/loader.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/models/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/models/eval_case.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/models/result.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/plugin.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/reporting.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/scoring/__init__.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/scoring/langfuse_scorer.py +0 -0
- {autobots_devtools_shared_lib-0.6.1 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/py.typed +0 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# Node-RED Instance Manager Server
|
|
2
|
+
|
|
3
|
+
A FastAPI server that manages dynamic Node-RED instances. It launches instances on demand
|
|
4
|
+
using pre-configured templates, assigns them ports from a per-template range, and kills
|
|
5
|
+
them on request.
|
|
6
|
+
|
|
7
|
+
## Overview
|
|
8
|
+
|
|
9
|
+
Each Node-RED instance is launched as:
|
|
10
|
+
```
|
|
11
|
+
FLOW=<flows_json_path> node-red -u <template_path> --port <port>
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Templates are directories containing a `settings.js` and `package.json`, and each template
|
|
15
|
+
owns its own port range to avoid conflicts.
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
**Prerequisites:**
|
|
20
|
+
- `node-red` binary on `PATH` (or configure `node_red_executable` in the YAML)
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g node-red
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
**Install the shared lib:**
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e "autobots-devtools-shared-lib"
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Configuration
|
|
32
|
+
|
|
33
|
+
Create a `node-red-config.yaml` file:
|
|
34
|
+
|
|
35
|
+
```yaml
|
|
36
|
+
# Path to the node-red binary (optional, defaults to "node-red")
|
|
37
|
+
node_red_executable: /usr/local/bin/node-red
|
|
38
|
+
|
|
39
|
+
# Host and port for the manager server itself.
|
|
40
|
+
# server_host is also used to build instance URLs — set it to the VM IP or hostname
|
|
41
|
+
# if this server runs remotely so callers get the correct URL back.
|
|
42
|
+
server_host: 0.0.0.0 # optional, defaults to "0.0.0.0"
|
|
43
|
+
server_port: 9003 # optional, defaults to 9003
|
|
44
|
+
|
|
45
|
+
templates:
|
|
46
|
+
- name: compose-template
|
|
47
|
+
path: /path/to/compose-template
|
|
48
|
+
base_port: 1880
|
|
49
|
+
max_port: 1920
|
|
50
|
+
|
|
51
|
+
- name: basic-template
|
|
52
|
+
path: /path/to/basic-template
|
|
53
|
+
base_port: 1921
|
|
54
|
+
max_port: 1980
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Point the server to the file via env var (defaults to `node-red-config.yaml` in cwd):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export NODE_RED_CONFIG_FILE=/path/to/node-red-config.yaml
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Template Directory Format
|
|
64
|
+
|
|
65
|
+
Each template directory must contain at minimum:
|
|
66
|
+
```
|
|
67
|
+
my-template/
|
|
68
|
+
├── settings.js # Node-RED settings (required)
|
|
69
|
+
└── package.json # Node-RED package deps (required)
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Run
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
# From autobots-devtools-shared-lib (host/port taken from node-red-config.yaml)
|
|
76
|
+
make node-red-server
|
|
77
|
+
|
|
78
|
+
# Or directly
|
|
79
|
+
python -m autobots_devtools_shared_lib.common.servers.noderedmanagerserver
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Environment Variables
|
|
83
|
+
|
|
84
|
+
Only one env var remains — everything else is in the YAML:
|
|
85
|
+
|
|
86
|
+
| Variable | Default | Description |
|
|
87
|
+
|---|---|---|
|
|
88
|
+
| `NODE_RED_CONFIG_FILE` | `node-red-config.yaml` | Path to the YAML config file |
|
|
89
|
+
|
|
90
|
+
## API Endpoints
|
|
91
|
+
|
|
92
|
+
### `GET /health`
|
|
93
|
+
Returns server status, running instance count, and available template names.
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
curl http://localhost:9003/health
|
|
97
|
+
```
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"status": "healthy",
|
|
101
|
+
"timestamp": "2026-04-30T10:00:00+00:00",
|
|
102
|
+
"running_instances": 2,
|
|
103
|
+
"available_templates": ["compose-template", "basic-template"]
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
### `POST /create-instance`
|
|
110
|
+
Launch a new Node-RED instance.
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
curl -X POST http://localhost:9003/create-instance \
|
|
114
|
+
-H "Content-Type: application/json" \
|
|
115
|
+
-d '{"flows_json_path": "/projects/projectA/flows.json", "template_name": "compose-template"}'
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**Response (201):**
|
|
119
|
+
```json
|
|
120
|
+
{
|
|
121
|
+
"id": "3f1a2b4c-...",
|
|
122
|
+
"url": "http://192.168.1.100:1880"
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**Errors:**
|
|
127
|
+
- `400` — unknown `template_name` or `flows_json_path` does not exist
|
|
128
|
+
- `503` — no ports available in the template's configured range
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### `POST /kill-instance`
|
|
133
|
+
Kill a running Node-RED instance by its id.
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
curl -X POST http://localhost:9003/kill-instance \
|
|
137
|
+
-H "Content-Type: application/json" \
|
|
138
|
+
-d '{"id": "3f1a2b4c-..."}'
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Response (200):**
|
|
142
|
+
```json
|
|
143
|
+
{"message": "Instance 3f1a2b4c-... killed successfully"}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
**Errors:**
|
|
147
|
+
- `404` — instance id not found
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
### `GET /instances`
|
|
152
|
+
List all currently running instances.
|
|
153
|
+
|
|
154
|
+
```bash
|
|
155
|
+
curl http://localhost:9003/instances
|
|
156
|
+
```
|
|
157
|
+
```json
|
|
158
|
+
{
|
|
159
|
+
"instances": [
|
|
160
|
+
{
|
|
161
|
+
"id": "3f1a2b4c-...",
|
|
162
|
+
"port": 1880,
|
|
163
|
+
"template_name": "compose-template",
|
|
164
|
+
"url": "http://localhost:1880",
|
|
165
|
+
"pid": 12345
|
|
166
|
+
}
|
|
167
|
+
],
|
|
168
|
+
"count": 1
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
---
|
|
173
|
+
|
|
174
|
+
### `GET /docs`
|
|
175
|
+
Interactive Swagger UI.
|
|
176
|
+
|
|
177
|
+
## Shutdown Behaviour
|
|
178
|
+
|
|
179
|
+
When the manager server stops, it sends `SIGTERM` to all tracked Node-RED instances
|
|
180
|
+
(concurrently). Instances that do not exit within 5 seconds receive `SIGKILL`.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Entry point: reads host/port from node-red-config.yaml and starts the server."""
|
|
2
|
+
|
|
3
|
+
import uvicorn
|
|
4
|
+
|
|
5
|
+
from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.config import (
|
|
6
|
+
NodeRedManagerServerConfig,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
if __name__ == "__main__":
|
|
10
|
+
cfg = NodeRedManagerServerConfig()
|
|
11
|
+
uvicorn.run(
|
|
12
|
+
"autobots_devtools_shared_lib.common.servers.noderedmanagerserver.app:app",
|
|
13
|
+
host=cfg.node_red_manager_server_host,
|
|
14
|
+
port=cfg.node_red_manager_server_port,
|
|
15
|
+
reload=True,
|
|
16
|
+
)
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Node-RED instance manager server.
|
|
3
|
+
|
|
4
|
+
Manages dynamic Node-RED instances: launch on demand with a chosen template and flows file,
|
|
5
|
+
track them in memory, and kill on request.
|
|
6
|
+
|
|
7
|
+
Templates are configured in a YAML file (default: node-red-config.yaml in cwd):
|
|
8
|
+
NODE_RED_CONFIG_FILE=/path/to/node-red-config.yaml
|
|
9
|
+
|
|
10
|
+
Run:
|
|
11
|
+
uvicorn autobots_devtools_shared_lib.common.servers.noderedmanagerserver.app:app \
|
|
12
|
+
--reload --host 0.0.0.0 --port 9003
|
|
13
|
+
Or: make node-red-server (from autobots-devtools-shared-lib)
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import contextlib
|
|
18
|
+
import os
|
|
19
|
+
from contextlib import asynccontextmanager
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from fastapi import FastAPI, HTTPException, status
|
|
25
|
+
|
|
26
|
+
from autobots_devtools_shared_lib.common.observability.logging_utils import get_logger
|
|
27
|
+
from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.config import (
|
|
28
|
+
NodeRedManagerServerConfig,
|
|
29
|
+
TemplateConfig,
|
|
30
|
+
)
|
|
31
|
+
from autobots_devtools_shared_lib.common.servers.noderedmanagerserver.models import (
|
|
32
|
+
CreateInstanceRequest,
|
|
33
|
+
CreateInstanceResponse,
|
|
34
|
+
InstanceInfo,
|
|
35
|
+
KillInstanceRequest,
|
|
36
|
+
KillInstanceResponse,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
logger = get_logger(__name__)
|
|
40
|
+
config = NodeRedManagerServerConfig()
|
|
41
|
+
|
|
42
|
+
# In-memory registry: instance_id -> (InstanceInfo, subprocess handle)
|
|
43
|
+
_registry: dict[str, tuple[InstanceInfo, asyncio.subprocess.Process]] = {}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
# Port scanning
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
async def _is_port_available(port: int) -> bool:
|
|
52
|
+
"""Return True if nothing is listening on the given TCP port."""
|
|
53
|
+
try:
|
|
54
|
+
_, writer = await asyncio.wait_for(asyncio.open_connection("127.0.0.1", port), timeout=0.1)
|
|
55
|
+
except (ConnectionRefusedError, OSError, TimeoutError):
|
|
56
|
+
return True # refused / timeout → port is free
|
|
57
|
+
else:
|
|
58
|
+
writer.close()
|
|
59
|
+
await writer.wait_closed()
|
|
60
|
+
return False # connected → port is in use
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _find_available_port(template: TemplateConfig) -> int:
|
|
64
|
+
"""Scan sequentially within the template's port range; skip ports used by tracked instances."""
|
|
65
|
+
used_ports = {info.port for info, _ in _registry.values()}
|
|
66
|
+
for port in range(template.min_port, template.max_port + 1):
|
|
67
|
+
if port in used_ports:
|
|
68
|
+
continue
|
|
69
|
+
if await _is_port_available(port):
|
|
70
|
+
return port
|
|
71
|
+
raise RuntimeError(
|
|
72
|
+
f"No available ports in range [{template.min_port}, {template.max_port}] "
|
|
73
|
+
f"for environment '{template.name}'. All ports are occupied."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Process management
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _launch_node_red(
|
|
83
|
+
template: TemplateConfig, flows_json_path: str, port: int, instance_id: str
|
|
84
|
+
) -> asyncio.subprocess.Process:
|
|
85
|
+
"""Launch node-red as an async subprocess.
|
|
86
|
+
|
|
87
|
+
INSTANCE_ID is passed as an env var so the environment's settings.js can set
|
|
88
|
+
httpAdminRoot and httpNodeRoot to '/<instance_id>' for URL isolation.
|
|
89
|
+
"""
|
|
90
|
+
env = {**os.environ, "FLOW": flows_json_path, "INSTANCE_ID": instance_id}
|
|
91
|
+
return await asyncio.create_subprocess_exec(
|
|
92
|
+
config.node_red_executable,
|
|
93
|
+
"-u",
|
|
94
|
+
str(template.path),
|
|
95
|
+
"--port",
|
|
96
|
+
str(port),
|
|
97
|
+
env=env,
|
|
98
|
+
stdout=asyncio.subprocess.PIPE,
|
|
99
|
+
stderr=asyncio.subprocess.PIPE,
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _kill_instance(instance_id: str, process: asyncio.subprocess.Process) -> None:
|
|
104
|
+
"""SIGTERM the process; escalate to SIGKILL after 5 seconds."""
|
|
105
|
+
try:
|
|
106
|
+
process.terminate() # SIGTERM on Unix
|
|
107
|
+
await asyncio.wait_for(process.wait(), timeout=5.0)
|
|
108
|
+
logger.info("Instance %s terminated gracefully", instance_id)
|
|
109
|
+
except ProcessLookupError:
|
|
110
|
+
logger.info("Instance %s process already gone (pid=%s)", instance_id, process.pid)
|
|
111
|
+
except TimeoutError:
|
|
112
|
+
logger.warning("Instance %s did not terminate in 5s, sending SIGKILL", instance_id)
|
|
113
|
+
with contextlib.suppress(ProcessLookupError):
|
|
114
|
+
process.kill()
|
|
115
|
+
await process.wait()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
# Lifespan
|
|
120
|
+
# ---------------------------------------------------------------------------
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@asynccontextmanager
|
|
124
|
+
async def lifespan(_app: FastAPI):
|
|
125
|
+
"""Startup: validate config and log. Shutdown: kill all tracked instances."""
|
|
126
|
+
logger.info(
|
|
127
|
+
"Node-RED server starting (host=%s, port=%s, base_path=%s, environments=%s)",
|
|
128
|
+
config.node_red_manager_server_host,
|
|
129
|
+
config.node_red_manager_server_port,
|
|
130
|
+
config.base_path,
|
|
131
|
+
[f"{t.name}[{t.min_port}-{t.max_port}]" for t in config.environments.values()],
|
|
132
|
+
)
|
|
133
|
+
try:
|
|
134
|
+
NodeRedManagerServerConfig.validate()
|
|
135
|
+
except ValueError:
|
|
136
|
+
logger.exception("Node-RED server config invalid")
|
|
137
|
+
raise
|
|
138
|
+
|
|
139
|
+
yield
|
|
140
|
+
|
|
141
|
+
logger.info("Node-RED server shutting down, terminating %d instance(s)", len(_registry))
|
|
142
|
+
tasks = [_kill_instance(iid, proc) for iid, (_, proc) in _registry.items()]
|
|
143
|
+
if tasks:
|
|
144
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
145
|
+
_registry.clear()
|
|
146
|
+
logger.info("Node-RED server shutdown complete")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# ---------------------------------------------------------------------------
|
|
150
|
+
# FastAPI app
|
|
151
|
+
# ---------------------------------------------------------------------------
|
|
152
|
+
|
|
153
|
+
app = FastAPI(
|
|
154
|
+
title="Node-RED Instance Manager API",
|
|
155
|
+
description=(
|
|
156
|
+
"Manages dynamic Node-RED instances: launch with a chosen template and flows file, "
|
|
157
|
+
"track in memory, and kill on request."
|
|
158
|
+
),
|
|
159
|
+
version="1.0.0",
|
|
160
|
+
docs_url="/docs",
|
|
161
|
+
redoc_url="/redoc",
|
|
162
|
+
lifespan=lifespan,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
# Endpoints
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@app.get("/")
|
|
172
|
+
def root() -> dict[str, Any]:
|
|
173
|
+
"""Root endpoint with API information."""
|
|
174
|
+
return {
|
|
175
|
+
"name": "Node-RED Instance Manager API",
|
|
176
|
+
"version": "1.0.0",
|
|
177
|
+
"endpoints": {
|
|
178
|
+
"POST /create-instance": "Launch a Node-RED instance with a template and flows file",
|
|
179
|
+
"POST /kill-instance": "Kill a running Node-RED instance by id",
|
|
180
|
+
"GET /instances": "List all running instances",
|
|
181
|
+
"GET /health": "Health check",
|
|
182
|
+
},
|
|
183
|
+
"docs": "/docs",
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@app.get("/health")
|
|
188
|
+
def health() -> dict[str, Any]:
|
|
189
|
+
"""Health check; returns status, running instance count, and available templates."""
|
|
190
|
+
logger.info("health called")
|
|
191
|
+
return {
|
|
192
|
+
"status": "healthy",
|
|
193
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
194
|
+
"running_instances": len(_registry),
|
|
195
|
+
"available_environments": list(config.environments.keys()),
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@app.get("/instances")
|
|
200
|
+
def list_instances() -> dict[str, Any]:
|
|
201
|
+
"""List all currently running Node-RED instances."""
|
|
202
|
+
instances = [
|
|
203
|
+
{
|
|
204
|
+
"id": info.id,
|
|
205
|
+
"port": info.port,
|
|
206
|
+
"environment_name": info.environment_name,
|
|
207
|
+
"url": info.url,
|
|
208
|
+
"pid": info.pid,
|
|
209
|
+
}
|
|
210
|
+
for info, _ in _registry.values()
|
|
211
|
+
]
|
|
212
|
+
return {"instances": instances, "count": len(instances)}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@app.post("/create-instance")
|
|
216
|
+
async def create_instance(body: CreateInstanceRequest) -> CreateInstanceResponse:
|
|
217
|
+
"""
|
|
218
|
+
Launch a new Node-RED instance.
|
|
219
|
+
|
|
220
|
+
Finds the next available port, launches node-red with the given template and flows file,
|
|
221
|
+
registers the instance, and returns its id and URL.
|
|
222
|
+
"""
|
|
223
|
+
logger.info(
|
|
224
|
+
"create-instance called environment=%s flows=%s workspace=%s",
|
|
225
|
+
body.environment_name,
|
|
226
|
+
body.flows_json_path,
|
|
227
|
+
body.workspace_context,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# 1. Extract and validate workspace_base_path — used as the instance ID
|
|
231
|
+
workspace_base_path: str = (body.workspace_context.get("workspace_base_path") or "").strip()
|
|
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 = workspace_base_path
|
|
243
|
+
|
|
244
|
+
# 2. Return existing instance if one is already running for this workspace
|
|
245
|
+
if instance_id in _registry:
|
|
246
|
+
existing_info, _ = _registry[instance_id]
|
|
247
|
+
logger.info(
|
|
248
|
+
"create-instance reusing existing id=%s url=%s", existing_info.id, existing_info.url
|
|
249
|
+
)
|
|
250
|
+
return CreateInstanceResponse(id=existing_info.id, url=existing_info.url)
|
|
251
|
+
|
|
252
|
+
# 3. Validate environment name
|
|
253
|
+
environment = config.environments.get(body.environment_name)
|
|
254
|
+
if environment is None:
|
|
255
|
+
logger.warning("create-instance unknown environment=%s", body.environment_name)
|
|
256
|
+
raise HTTPException(
|
|
257
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
258
|
+
detail=(
|
|
259
|
+
f"Unknown environment '{body.environment_name}'. "
|
|
260
|
+
f"Available: {list(config.environments.keys())}"
|
|
261
|
+
),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# 4. Resolve full flows path: base_path / workspace_base_path / flows_json_path
|
|
265
|
+
flows_path = Path(config.base_path) / workspace_base_path / body.flows_json_path
|
|
266
|
+
if not flows_path.exists():
|
|
267
|
+
raise HTTPException(
|
|
268
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
269
|
+
detail=f"flows.json not found at resolved path: {flows_path}",
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# 5. Find next available port within this environment's port range
|
|
273
|
+
try:
|
|
274
|
+
port = await _find_available_port(environment)
|
|
275
|
+
except RuntimeError as e:
|
|
276
|
+
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(e)) from e
|
|
277
|
+
|
|
278
|
+
# 6. Launch subprocess — INSTANCE_ID env var picked up by the environment's settings.js
|
|
279
|
+
try:
|
|
280
|
+
process = await _launch_node_red(environment, str(flows_path), port, instance_id)
|
|
281
|
+
except Exception as e:
|
|
282
|
+
logger.exception("create-instance failed to launch node-red")
|
|
283
|
+
raise HTTPException(
|
|
284
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
|
285
|
+
detail=f"Failed to launch Node-RED: {e!s}",
|
|
286
|
+
) from e
|
|
287
|
+
|
|
288
|
+
# 7. Register and return
|
|
289
|
+
url = f"http://{config.node_red_manager_server_host}:{port}/{instance_id}"
|
|
290
|
+
info = InstanceInfo(
|
|
291
|
+
id=instance_id,
|
|
292
|
+
port=port,
|
|
293
|
+
environment_name=body.environment_name,
|
|
294
|
+
url=url,
|
|
295
|
+
pid=process.pid or 0,
|
|
296
|
+
)
|
|
297
|
+
_registry[instance_id] = (info, process)
|
|
298
|
+
logger.info("create-instance success id=%s url=%s pid=%s", instance_id, url, process.pid)
|
|
299
|
+
return CreateInstanceResponse(id=instance_id, url=url)
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@app.post("/kill-instance")
|
|
303
|
+
async def kill_instance(body: KillInstanceRequest) -> KillInstanceResponse:
|
|
304
|
+
"""Kill a running Node-RED instance by workspace_base_path."""
|
|
305
|
+
instance_id: str = (body.workspace_context.get("workspace_base_path") or "").strip()
|
|
306
|
+
if not instance_id:
|
|
307
|
+
raise HTTPException(
|
|
308
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
309
|
+
detail="workspace_context.workspace_base_path is required and cannot be empty.",
|
|
310
|
+
)
|
|
311
|
+
logger.info("kill-instance called id=%s", instance_id)
|
|
312
|
+
|
|
313
|
+
entry = _registry.get(instance_id)
|
|
314
|
+
if entry is None:
|
|
315
|
+
raise HTTPException(
|
|
316
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
317
|
+
detail=f"Instance '{instance_id}' not found",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
_, process = entry
|
|
321
|
+
await _kill_instance(instance_id, process)
|
|
322
|
+
del _registry[instance_id]
|
|
323
|
+
|
|
324
|
+
logger.info("kill-instance success id=%s", instance_id)
|
|
325
|
+
return KillInstanceResponse(message=f"Instance {instance_id} killed successfully")
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Configuration for the Node-RED instance manager server, loaded from a YAML file."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
from dotenv import find_dotenv, load_dotenv
|
|
9
|
+
|
|
10
|
+
load_dotenv(find_dotenv())
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class TemplateConfig:
|
|
15
|
+
"""Configuration for a single Node-RED environment/template."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
path: Path
|
|
19
|
+
min_port: int
|
|
20
|
+
max_port: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load_yaml_config(
|
|
24
|
+
config_file: Path,
|
|
25
|
+
) -> tuple[str, str, str, int, dict[str, TemplateConfig]]:
|
|
26
|
+
"""
|
|
27
|
+
Load node-red-config.yaml and return
|
|
28
|
+
(node_red_executable, base_path, manager_host, manager_port, environments_by_name).
|
|
29
|
+
|
|
30
|
+
Expected YAML structure::
|
|
31
|
+
|
|
32
|
+
node_red_executable: /usr/local/bin/node-red # optional, defaults to "node-red"
|
|
33
|
+
|
|
34
|
+
# Root directory prepended to all workspace paths when resolving flows.json.
|
|
35
|
+
base_path: /data/workspaces
|
|
36
|
+
|
|
37
|
+
# Host and port for the manager server itself and for building instance URLs.
|
|
38
|
+
node_red_manager_server_host: 0.0.0.0 # optional, defaults to "0.0.0.0"
|
|
39
|
+
node_red_manager_server_port: 9003 # optional, defaults to 9003
|
|
40
|
+
|
|
41
|
+
environments:
|
|
42
|
+
- name: compose-engine-template
|
|
43
|
+
path: /path/to/compose-engine-template
|
|
44
|
+
min_port: 1880
|
|
45
|
+
max_port: 1920
|
|
46
|
+
|
|
47
|
+
- name: basic-template
|
|
48
|
+
path: /path/to/basic-template
|
|
49
|
+
min_port: 1921
|
|
50
|
+
max_port: 1980
|
|
51
|
+
"""
|
|
52
|
+
if not config_file.exists():
|
|
53
|
+
raise FileNotFoundError(
|
|
54
|
+
f"Node-RED config file not found: {config_file}. "
|
|
55
|
+
"Set NODE_RED_CONFIG_FILE env var to point to your node-red-config.yaml."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
with config_file.open() as f:
|
|
59
|
+
data = yaml.safe_load(f) or {}
|
|
60
|
+
|
|
61
|
+
node_red_executable: str = data.get("node_red_executable", "node-red") or "node-red"
|
|
62
|
+
base_path: str = data.get("base_path", "") or ""
|
|
63
|
+
manager_host: str = (
|
|
64
|
+
data.get("node_red_manager_server_host", "0.0.0.0") or "0.0.0.0" # noqa: S104
|
|
65
|
+
)
|
|
66
|
+
manager_port: int = int(data.get("node_red_manager_server_port", 9003))
|
|
67
|
+
|
|
68
|
+
raw_environments: list[dict] = data.get("environments") or []
|
|
69
|
+
environments: dict[str, TemplateConfig] = {}
|
|
70
|
+
for entry in raw_environments:
|
|
71
|
+
name = str(entry.get("name", "")).strip()
|
|
72
|
+
raw_path = str(entry.get("path", "")).strip()
|
|
73
|
+
min_port = int(entry.get("min_port", 1880))
|
|
74
|
+
max_port = int(entry.get("max_port", 1980))
|
|
75
|
+
if not name:
|
|
76
|
+
raise ValueError("Each environment entry must have a non-empty 'name' field.")
|
|
77
|
+
if not raw_path:
|
|
78
|
+
raise ValueError(f"Environment '{name}' is missing the 'path' field.")
|
|
79
|
+
environments[name] = TemplateConfig(
|
|
80
|
+
name=name,
|
|
81
|
+
path=Path(raw_path).resolve(),
|
|
82
|
+
min_port=min_port,
|
|
83
|
+
max_port=max_port,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return node_red_executable, base_path, manager_host, manager_port, environments
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# Resolve config file path from env var; default to node-red-config.yaml in cwd.
|
|
90
|
+
_config_file = Path(os.getenv("NODE_RED_CONFIG_FILE", "node-red-config.yaml"))
|
|
91
|
+
|
|
92
|
+
# Load at module import time so config is available immediately.
|
|
93
|
+
# If the file is absent the server will fail fast during lifespan startup (validate() call).
|
|
94
|
+
try:
|
|
95
|
+
_node_red_executable, _base_path, _manager_host, _manager_port, _environments = (
|
|
96
|
+
_load_yaml_config(_config_file)
|
|
97
|
+
)
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
_node_red_executable = "node-red"
|
|
100
|
+
_base_path = ""
|
|
101
|
+
_manager_host = "0.0.0.0" # noqa: S104
|
|
102
|
+
_manager_port = 9003
|
|
103
|
+
_environments = {}
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class NodeRedManagerServerConfig:
|
|
107
|
+
"""Configuration for the Node-RED instance manager server."""
|
|
108
|
+
|
|
109
|
+
# All settings loaded from YAML
|
|
110
|
+
node_red_executable: str = _node_red_executable
|
|
111
|
+
base_path: str = _base_path
|
|
112
|
+
node_red_manager_server_host: str = _manager_host
|
|
113
|
+
node_red_manager_server_port: int = _manager_port
|
|
114
|
+
environments: dict[str, TemplateConfig] = _environments
|
|
115
|
+
|
|
116
|
+
@classmethod
|
|
117
|
+
def validate(cls) -> None:
|
|
118
|
+
"""Fail fast on startup if configuration is unusable."""
|
|
119
|
+
if not _config_file.exists():
|
|
120
|
+
raise ValueError(
|
|
121
|
+
f"Node-RED config file not found: {_config_file}. "
|
|
122
|
+
"Set NODE_RED_CONFIG_FILE env var to point to your node-red-config.yaml."
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if not cls.base_path:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
"base_path is required in node-red-config.yaml. "
|
|
128
|
+
"Set it to the root directory for workspace paths."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
if not cls.environments:
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"No environments defined in node-red-config.yaml. "
|
|
134
|
+
"Add at least one entry under 'environments:'."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
for name, tmpl in cls.environments.items():
|
|
138
|
+
if tmpl.min_port >= tmpl.max_port:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"Environment '{name}': min_port ({tmpl.min_port}) must be less than "
|
|
141
|
+
f"max_port ({tmpl.max_port})."
|
|
142
|
+
)
|
|
143
|
+
if not tmpl.path.exists():
|
|
144
|
+
raise ValueError(f"Environment '{name}' path does not exist: {tmpl.path}")
|
|
145
|
+
if not (tmpl.path / "settings.js").exists():
|
|
146
|
+
raise ValueError(f"Environment '{name}' is missing settings.js at: {tmpl.path}")
|