autobots-devtools-shared-lib 0.7.0__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.
Files changed (95) hide show
  1. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/PKG-INFO +1 -1
  2. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/pyproject.toml +1 -1
  3. autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/README.md +180 -0
  4. autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__init__.py +5 -0
  5. autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/__main__.py +16 -0
  6. autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/app.py +325 -0
  7. autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/config.py +146 -0
  8. autobots_devtools_shared_lib-0.8.0/src/autobots_devtools_shared_lib/common/servers/noderedmanagerserver/models.py +63 -0
  9. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/README.md +0 -0
  10. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/__init__.py +0 -0
  11. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/__init__.py +0 -0
  12. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/config/__init__.py +0 -0
  13. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/config/jenkins_config.py +0 -0
  14. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/config/jenkins_constants.py +0 -0
  15. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/config/jenkins_loader.py +0 -0
  16. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/__init__.py +0 -0
  17. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/logging_utils.py +0 -0
  18. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/otel_fastapi.py +0 -0
  19. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/trace_metadata.py +0 -0
  20. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/trace_propagation.py +0 -0
  21. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/observability/tracing.py +0 -0
  22. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/__init__.py +0 -0
  23. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/README.md +0 -0
  24. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/__init__.py +0 -0
  25. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/app.py +0 -0
  26. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/config.py +0 -0
  27. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/servers/fileserver/models.py +0 -0
  28. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/__init__.py +0 -0
  29. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/README.md +0 -0
  30. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/__init__.py +0 -0
  31. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/cache_backed.py +0 -0
  32. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/db_repository.py +0 -0
  33. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/factory.py +0 -0
  34. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/in_memory.py +0 -0
  35. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/redis_store.py +0 -0
  36. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/services/context/store.py +0 -0
  37. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/__init__.py +0 -0
  38. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/context_tools.py +0 -0
  39. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/format_tools.py +0 -0
  40. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/fserver_client_tools.py +0 -0
  41. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/jenkins_builtin_tools.py +0 -0
  42. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/tools/jenkins_pipeline_tools.py +0 -0
  43. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/__init__.py +0 -0
  44. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/context_utils.py +0 -0
  45. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/format_utils.py +0 -0
  46. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/fserver_client_utils.py +0 -0
  47. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_builtin_utils.py +0 -0
  48. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_http_utils.py +0 -0
  49. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/common/utils/jenkins_pipeline_utils.py +0 -0
  50. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/__init__.py +0 -0
  51. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/__init__.py +0 -0
  52. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/agent_config_utils.py +0 -0
  53. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/agent_meta.py +0 -0
  54. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/base_agent.py +0 -0
  55. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/batch.py +0 -0
  56. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/invocation_utils.py +0 -0
  57. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/agents/middleware.py +0 -0
  58. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/config/__init__.py +0 -0
  59. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/config/dynagent_settings.py +0 -0
  60. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/llm/__init__.py +0 -0
  61. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/llm/llm.py +0 -0
  62. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/models/__init__.py +0 -0
  63. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/models/state.py +0 -0
  64. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/services/__init__.py +0 -0
  65. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/services/structured_converter.py +0 -0
  66. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/tools/__init__.py +0 -0
  67. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/tools/state_tools.py +0 -0
  68. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/tools/tool_registry.py +0 -0
  69. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/ui/__init__.py +0 -0
  70. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/ui/default_ui.py +0 -0
  71. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/ui/ui_utils.py +0 -0
  72. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/utils/__init__.py +0 -0
  73. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/dynagent/utils/schema_directive_resolver.py +0 -0
  74. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/__init__.py +0 -0
  75. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/assertions/__init__.py +0 -0
  76. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/assertions/deterministic.py +0 -0
  77. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/assertions/golden.py +0 -0
  78. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/assertions/llm_judge.py +0 -0
  79. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/assertions/registry.py +0 -0
  80. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/assertions/written_file.py +0 -0
  81. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/core/__init__.py +0 -0
  82. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/core/cost_tracker.py +0 -0
  83. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/core/loader.py +0 -0
  84. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/core/runner.py +0 -0
  85. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/core/workspace.py +0 -0
  86. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/models/__init__.py +0 -0
  87. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/models/eval_case.py +0 -0
  88. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/models/result.py +0 -0
  89. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/__init__.py +0 -0
  90. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/fixtures.py +0 -0
  91. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/plugin.py +0 -0
  92. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/pytest_plugin/reporting.py +0 -0
  93. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/scoring/__init__.py +0 -0
  94. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/eval/scoring/langfuse_scorer.py +0 -0
  95. {autobots_devtools_shared_lib-0.7.0 → autobots_devtools_shared_lib-0.8.0}/src/autobots_devtools_shared_lib/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: autobots-devtools-shared-lib
3
- Version: 0.7.0
3
+ Version: 0.8.0
4
4
  Summary: Shared library functions to be used for all autobots projects
5
5
  License: MIT
6
6
  Author: Pralhad
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "autobots-devtools-shared-lib"
3
- version = "0.7.0"
3
+ version = "0.8.0"
4
4
  description = "Shared library functions to be used for all autobots projects"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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,5 @@
1
+ """Node-RED instance manager server package."""
2
+
3
+ from .app import app # re-export for convenience
4
+
5
+ __all__ = ["app"]
@@ -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}")
@@ -0,0 +1,63 @@
1
+ """Pydantic request/response models for the Node-RED instance manager API."""
2
+
3
+ from typing import Any
4
+
5
+ from pydantic import BaseModel, Field, field_validator
6
+
7
+
8
+ class CreateInstanceRequest(BaseModel):
9
+ workspace_context: dict[str, Any] = Field(
10
+ ...,
11
+ description=(
12
+ "Workspace scoping context. Must include `workspace_base_path` "
13
+ "(e.g. 'userName/repoName-jira'). Used as the instance ID and to resolve "
14
+ "the full flows.json path: base_path/workspace_base_path/flows_json_path."
15
+ ),
16
+ json_schema_extra={"examples": [{"workspace_base_path": "alice/my-project-JIRA-42"}]},
17
+ )
18
+ flows_json_path: str = Field(
19
+ ...,
20
+ description="Relative path to flows.json within the workspace directory.",
21
+ )
22
+ environment_name: str = Field(..., description="Name of the Node-RED environment to use.")
23
+
24
+ @field_validator("flows_json_path")
25
+ @classmethod
26
+ def validate_flows_json_path(cls, v: str) -> str:
27
+ if not v or not v.strip():
28
+ raise ValueError("flows_json_path cannot be empty")
29
+ if ".." in v:
30
+ raise ValueError("flows_json_path cannot contain '..'")
31
+ return v.strip()
32
+
33
+ @field_validator("environment_name")
34
+ @classmethod
35
+ def validate_environment_name(cls, v: str) -> str:
36
+ if not v or not v.strip():
37
+ raise ValueError("environment_name cannot be empty")
38
+ return v.strip()
39
+
40
+
41
+ class CreateInstanceResponse(BaseModel):
42
+ id: str = Field(..., description="Instance ID (workspace_base_path).")
43
+ url: str = Field(..., description="URL to access the Node-RED instance (includes port).")
44
+
45
+
46
+ class KillInstanceRequest(BaseModel):
47
+ workspace_context: dict[str, Any] = Field(
48
+ ...,
49
+ description="Workspace scoping context. Must include `workspace_base_path`.",
50
+ json_schema_extra={"examples": [{"workspace_base_path": "alice/my-project-JIRA-42"}]},
51
+ )
52
+
53
+
54
+ class KillInstanceResponse(BaseModel):
55
+ message: str
56
+
57
+
58
+ class InstanceInfo(BaseModel):
59
+ id: str
60
+ port: int
61
+ environment_name: str
62
+ url: str
63
+ pid: int