procler 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- procler/__init__.py +3 -0
- procler/__main__.py +6 -0
- procler/api/__init__.py +5 -0
- procler/api/app.py +261 -0
- procler/api/deps.py +21 -0
- procler/api/routes/__init__.py +5 -0
- procler/api/routes/config.py +290 -0
- procler/api/routes/groups.py +62 -0
- procler/api/routes/logs.py +43 -0
- procler/api/routes/processes.py +185 -0
- procler/api/routes/recipes.py +69 -0
- procler/api/routes/snippets.py +134 -0
- procler/api/routes/ws.py +459 -0
- procler/cli.py +1478 -0
- procler/config/__init__.py +65 -0
- procler/config/changelog.py +148 -0
- procler/config/loader.py +256 -0
- procler/config/schema.py +315 -0
- procler/core/__init__.py +54 -0
- procler/core/context_base.py +117 -0
- procler/core/context_docker.py +384 -0
- procler/core/context_local.py +287 -0
- procler/core/daemon_detector.py +325 -0
- procler/core/events.py +74 -0
- procler/core/groups.py +419 -0
- procler/core/health.py +280 -0
- procler/core/log_tailer.py +262 -0
- procler/core/process_manager.py +1277 -0
- procler/core/recipes.py +330 -0
- procler/core/snippets.py +231 -0
- procler/core/variable_substitution.py +65 -0
- procler/db.py +96 -0
- procler/logging.py +41 -0
- procler/models.py +130 -0
- procler/py.typed +0 -0
- procler/settings.py +29 -0
- procler/static/assets/AboutView-BwZnsfpW.js +4 -0
- procler/static/assets/AboutView-UHbxWXcS.css +1 -0
- procler/static/assets/Code-HTS-H1S6.js +74 -0
- procler/static/assets/ConfigView-CGJcmp9G.css +1 -0
- procler/static/assets/ConfigView-aVtbRDf8.js +1 -0
- procler/static/assets/DashboardView-C5jw9Nsd.css +1 -0
- procler/static/assets/DashboardView-Dab7Cu9v.js +1 -0
- procler/static/assets/DataTable-z39TOAa4.js +746 -0
- procler/static/assets/DescriptionsItem-B2E8YbqJ.js +74 -0
- procler/static/assets/Divider-Dk-6aD2Y.js +42 -0
- procler/static/assets/Empty-MuygEHZM.js +24 -0
- procler/static/assets/Grid-CZ9QVKAT.js +1 -0
- procler/static/assets/GroupsView-BALG7i1X.js +1 -0
- procler/static/assets/GroupsView-gXAI1CVC.css +1 -0
- procler/static/assets/Input-e0xaxoWE.js +259 -0
- procler/static/assets/PhArrowsClockwise.vue-DqDg31az.js +1 -0
- procler/static/assets/PhCheckCircle.vue-Fwj9sh9m.js +1 -0
- procler/static/assets/PhEye.vue-JcPHciC2.js +1 -0
- procler/static/assets/PhPlay.vue-CZm7Gy3u.js +1 -0
- procler/static/assets/PhPlus.vue-yTWqKlSh.js +1 -0
- procler/static/assets/PhStop.vue-DxsqwIki.js +1 -0
- procler/static/assets/PhTrash.vue-DcqQbN1_.js +125 -0
- procler/static/assets/PhXCircle.vue-BXWmrabV.js +1 -0
- procler/static/assets/ProcessDetailView-DDbtIWq9.css +1 -0
- procler/static/assets/ProcessDetailView-DPtdNV-q.js +1 -0
- procler/static/assets/ProcessesView-B3a6Umur.js +1 -0
- procler/static/assets/ProcessesView-goLmghbJ.css +1 -0
- procler/static/assets/RecipesView-D2VxdneD.js +166 -0
- procler/static/assets/RecipesView-DXnFDCK4.css +1 -0
- procler/static/assets/Select-BBR17AHq.js +317 -0
- procler/static/assets/SnippetsView-B3a9q3AI.css +1 -0
- procler/static/assets/SnippetsView-DBCB2yGq.js +1 -0
- procler/static/assets/Spin-BXTjvFUk.js +90 -0
- procler/static/assets/Tag-Bh_qV63A.js +71 -0
- procler/static/assets/changelog-KkTT4H9-.js +1 -0
- procler/static/assets/groups-Zu-_v8ey.js +1 -0
- procler/static/assets/index-BsN-YMXq.css +1 -0
- procler/static/assets/index-BzW1XhyH.js +1282 -0
- procler/static/assets/procler-DOrSB1Vj.js +1 -0
- procler/static/assets/recipes-1w5SseGb.js +1 -0
- procler/static/index.html +17 -0
- procler/static/procler.png +0 -0
- procler-0.2.0.dist-info/METADATA +545 -0
- procler-0.2.0.dist-info/RECORD +83 -0
- procler-0.2.0.dist-info/WHEEL +4 -0
- procler-0.2.0.dist-info/entry_points.txt +2 -0
- procler-0.2.0.dist-info/licenses/LICENSE +21 -0
procler/core/recipes.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Recipe execution engine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from ..config import (
|
|
10
|
+
ChangelogAction,
|
|
11
|
+
OnErrorAction,
|
|
12
|
+
RecipeDef,
|
|
13
|
+
RecipeStepExec,
|
|
14
|
+
RecipeStepGroupStart,
|
|
15
|
+
RecipeStepGroupStop,
|
|
16
|
+
RecipeStepRestart,
|
|
17
|
+
RecipeStepStart,
|
|
18
|
+
RecipeStepStop,
|
|
19
|
+
RecipeStepWait,
|
|
20
|
+
append_changelog,
|
|
21
|
+
get_config,
|
|
22
|
+
)
|
|
23
|
+
from . import get_process_manager
|
|
24
|
+
from .events import EVENT_RECIPE_STEP, get_event_bus
|
|
25
|
+
from .groups import get_group_manager
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RecipeExecutor:
|
|
29
|
+
"""Executes multi-step recipes."""
|
|
30
|
+
|
|
31
|
+
def __init__(self):
|
|
32
|
+
self._process_manager = get_process_manager()
|
|
33
|
+
self._group_manager = get_group_manager()
|
|
34
|
+
|
|
35
|
+
def list_recipes(self) -> dict[str, Any]:
|
|
36
|
+
"""List all defined recipes."""
|
|
37
|
+
config = get_config()
|
|
38
|
+
|
|
39
|
+
recipes_data = []
|
|
40
|
+
for name, recipe in config.recipes.items():
|
|
41
|
+
recipes_data.append(
|
|
42
|
+
{
|
|
43
|
+
"name": name,
|
|
44
|
+
"description": recipe.description,
|
|
45
|
+
"steps_count": len(recipe.steps),
|
|
46
|
+
"on_error": recipe.on_error.value,
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
"success": True,
|
|
52
|
+
"data": {
|
|
53
|
+
"recipes": recipes_data,
|
|
54
|
+
"count": len(recipes_data),
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
def get_recipe(self, name: str) -> dict[str, Any]:
|
|
59
|
+
"""Get a specific recipe with full details."""
|
|
60
|
+
config = get_config()
|
|
61
|
+
|
|
62
|
+
if name not in config.recipes:
|
|
63
|
+
return {
|
|
64
|
+
"success": False,
|
|
65
|
+
"error": f"Recipe '{name}' not found",
|
|
66
|
+
"error_code": "recipe_not_found",
|
|
67
|
+
"suggestion": "Run 'procler recipe list' to see available recipes",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
recipe = config.recipes[name]
|
|
71
|
+
return {
|
|
72
|
+
"success": True,
|
|
73
|
+
"data": {
|
|
74
|
+
"recipe": {
|
|
75
|
+
"name": name,
|
|
76
|
+
"description": recipe.description,
|
|
77
|
+
"steps": recipe.steps, # Raw steps for display
|
|
78
|
+
"on_error": recipe.on_error.value,
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async def run_recipe(
|
|
84
|
+
self,
|
|
85
|
+
name: str,
|
|
86
|
+
dry_run: bool = False,
|
|
87
|
+
continue_on_error: bool | None = None,
|
|
88
|
+
) -> dict[str, Any]:
|
|
89
|
+
"""
|
|
90
|
+
Execute a recipe.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
name: Recipe name
|
|
94
|
+
dry_run: If True, just show what would happen
|
|
95
|
+
continue_on_error: Override recipe's on_error setting
|
|
96
|
+
"""
|
|
97
|
+
config = get_config()
|
|
98
|
+
|
|
99
|
+
if name not in config.recipes:
|
|
100
|
+
return {
|
|
101
|
+
"success": False,
|
|
102
|
+
"error": f"Recipe '{name}' not found",
|
|
103
|
+
"error_code": "recipe_not_found",
|
|
104
|
+
"suggestion": "Run 'procler recipe list' to see available recipes",
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
recipe = config.recipes[name]
|
|
108
|
+
steps = recipe.get_steps()
|
|
109
|
+
|
|
110
|
+
# Determine error handling behavior
|
|
111
|
+
if continue_on_error is not None:
|
|
112
|
+
should_continue = continue_on_error
|
|
113
|
+
else:
|
|
114
|
+
should_continue = recipe.on_error == OnErrorAction.CONTINUE
|
|
115
|
+
|
|
116
|
+
if dry_run:
|
|
117
|
+
return self._dry_run(name, recipe, steps)
|
|
118
|
+
|
|
119
|
+
# Execute for real
|
|
120
|
+
start_time = time.time()
|
|
121
|
+
results = []
|
|
122
|
+
all_success = True
|
|
123
|
+
stopped_at_step = None
|
|
124
|
+
total_steps = len(steps)
|
|
125
|
+
|
|
126
|
+
for i, step in enumerate(steps):
|
|
127
|
+
step_num = i + 1
|
|
128
|
+
action_desc = self._describe_step(step)
|
|
129
|
+
|
|
130
|
+
# Emit "running" event before execution
|
|
131
|
+
self._emit_step_event(name, step_num, total_steps, action_desc, "running")
|
|
132
|
+
|
|
133
|
+
step_result = await self._execute_step(step, step_num)
|
|
134
|
+
results.append(step_result)
|
|
135
|
+
|
|
136
|
+
# Determine status for event
|
|
137
|
+
if step_result["success"]:
|
|
138
|
+
status = "success"
|
|
139
|
+
elif step_result.get("ignore_error"):
|
|
140
|
+
status = "warning"
|
|
141
|
+
else:
|
|
142
|
+
status = "error"
|
|
143
|
+
|
|
144
|
+
# Emit completion event
|
|
145
|
+
self._emit_step_event(name, step_num, total_steps, action_desc, status, error=step_result.get("error"))
|
|
146
|
+
|
|
147
|
+
if not step_result["success"]:
|
|
148
|
+
all_success = False
|
|
149
|
+
if not should_continue and not step_result.get("ignore_error"):
|
|
150
|
+
stopped_at_step = step_num
|
|
151
|
+
# Emit skipped events for remaining steps
|
|
152
|
+
for j in range(i + 1, len(steps)):
|
|
153
|
+
skip_action = self._describe_step(steps[j])
|
|
154
|
+
self._emit_step_event(name, j + 1, total_steps, skip_action, "skipped")
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
duration_ms = int((time.time() - start_time) * 1000)
|
|
158
|
+
|
|
159
|
+
# Log execution to changelog
|
|
160
|
+
append_changelog(
|
|
161
|
+
action=ChangelogAction.EXECUTE,
|
|
162
|
+
entity_type="recipe",
|
|
163
|
+
entity_name=name,
|
|
164
|
+
details={
|
|
165
|
+
"duration_ms": duration_ms,
|
|
166
|
+
"success": all_success,
|
|
167
|
+
"steps_completed": len(results),
|
|
168
|
+
"stopped_at": stopped_at_step,
|
|
169
|
+
},
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
"success": all_success,
|
|
174
|
+
"data": {
|
|
175
|
+
"recipe": name,
|
|
176
|
+
"duration_ms": duration_ms,
|
|
177
|
+
"steps_total": len(steps),
|
|
178
|
+
"steps_completed": len(results),
|
|
179
|
+
"stopped_at_step": stopped_at_step,
|
|
180
|
+
"results": results,
|
|
181
|
+
},
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
def _dry_run(self, name: str, recipe: RecipeDef, steps: list) -> dict[str, Any]:
|
|
185
|
+
"""Show what a recipe would do without executing."""
|
|
186
|
+
planned_steps = []
|
|
187
|
+
|
|
188
|
+
for i, step in enumerate(steps):
|
|
189
|
+
planned_steps.append(
|
|
190
|
+
{
|
|
191
|
+
"step": i + 1,
|
|
192
|
+
"action": self._describe_step(step),
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"success": True,
|
|
198
|
+
"data": {
|
|
199
|
+
"recipe": name,
|
|
200
|
+
"description": recipe.description,
|
|
201
|
+
"dry_run": True,
|
|
202
|
+
"planned_steps": planned_steps,
|
|
203
|
+
},
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
def _emit_step_event(
|
|
207
|
+
self,
|
|
208
|
+
recipe: str,
|
|
209
|
+
step: int,
|
|
210
|
+
total: int,
|
|
211
|
+
action: str,
|
|
212
|
+
status: str,
|
|
213
|
+
error: str | None = None,
|
|
214
|
+
) -> None:
|
|
215
|
+
"""Emit a recipe step event for real-time updates."""
|
|
216
|
+
event_bus = get_event_bus()
|
|
217
|
+
event_bus.emit_sync(
|
|
218
|
+
EVENT_RECIPE_STEP,
|
|
219
|
+
{
|
|
220
|
+
"recipe": recipe,
|
|
221
|
+
"step": step,
|
|
222
|
+
"total": total,
|
|
223
|
+
"action": action,
|
|
224
|
+
"status": status, # running, success, error, warning, skipped
|
|
225
|
+
"error": error,
|
|
226
|
+
},
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def _describe_step(self, step) -> str:
|
|
230
|
+
"""Get a human-readable description of a step."""
|
|
231
|
+
if isinstance(step, RecipeStepStart):
|
|
232
|
+
return f"start process '{step.start}'"
|
|
233
|
+
elif isinstance(step, RecipeStepStop):
|
|
234
|
+
suffix = " (ignore_error)" if step.ignore_error else ""
|
|
235
|
+
return f"stop process '{step.stop}'{suffix}"
|
|
236
|
+
elif isinstance(step, RecipeStepRestart):
|
|
237
|
+
return f"restart process '{step.restart}'"
|
|
238
|
+
elif isinstance(step, RecipeStepGroupStart):
|
|
239
|
+
return f"start group '{step.group_start}'"
|
|
240
|
+
elif isinstance(step, RecipeStepGroupStop):
|
|
241
|
+
return f"stop group '{step.group_stop}'"
|
|
242
|
+
elif isinstance(step, RecipeStepWait):
|
|
243
|
+
return f"wait {step.wait}"
|
|
244
|
+
elif isinstance(step, RecipeStepExec):
|
|
245
|
+
ctx = f" (docker:{step.container})" if step.container else ""
|
|
246
|
+
suffix = " (ignore_error)" if step.ignore_error else ""
|
|
247
|
+
return f"exec '{step.exec}'{ctx}{suffix}"
|
|
248
|
+
else:
|
|
249
|
+
return f"unknown step: {step}"
|
|
250
|
+
|
|
251
|
+
async def _execute_step(self, step, step_num: int) -> dict[str, Any]:
|
|
252
|
+
"""Execute a single recipe step."""
|
|
253
|
+
result = {
|
|
254
|
+
"step": step_num,
|
|
255
|
+
"action": self._describe_step(step),
|
|
256
|
+
"success": True,
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
if isinstance(step, RecipeStepStart):
|
|
261
|
+
res = await self._process_manager.start(step.start)
|
|
262
|
+
result["success"] = res["success"]
|
|
263
|
+
result["details"] = res.get("data") or {"error": res.get("error")}
|
|
264
|
+
|
|
265
|
+
elif isinstance(step, RecipeStepStop):
|
|
266
|
+
res = await self._process_manager.stop(step.stop)
|
|
267
|
+
result["success"] = res["success"]
|
|
268
|
+
result["details"] = res.get("data") or {"error": res.get("error")}
|
|
269
|
+
result["ignore_error"] = step.ignore_error
|
|
270
|
+
|
|
271
|
+
elif isinstance(step, RecipeStepRestart):
|
|
272
|
+
res = await self._process_manager.restart(step.restart)
|
|
273
|
+
result["success"] = res["success"]
|
|
274
|
+
result["details"] = res.get("data") or {"error": res.get("error")}
|
|
275
|
+
|
|
276
|
+
elif isinstance(step, RecipeStepGroupStart):
|
|
277
|
+
res = await self._group_manager.start_group(step.group_start)
|
|
278
|
+
result["success"] = res["success"]
|
|
279
|
+
result["details"] = res.get("data") or {"error": res.get("error")}
|
|
280
|
+
|
|
281
|
+
elif isinstance(step, RecipeStepGroupStop):
|
|
282
|
+
res = await self._group_manager.stop_group(step.group_stop)
|
|
283
|
+
result["success"] = res["success"]
|
|
284
|
+
result["details"] = res.get("data") or {"error": res.get("error")}
|
|
285
|
+
|
|
286
|
+
elif isinstance(step, RecipeStepWait):
|
|
287
|
+
seconds = step.get_seconds()
|
|
288
|
+
await asyncio.sleep(seconds)
|
|
289
|
+
result["details"] = {"waited_seconds": seconds}
|
|
290
|
+
|
|
291
|
+
elif isinstance(step, RecipeStepExec):
|
|
292
|
+
timeout = step.get_timeout_seconds()
|
|
293
|
+
res = await self._process_manager.exec_command(
|
|
294
|
+
command=step.exec,
|
|
295
|
+
context_type=step.context.value,
|
|
296
|
+
container_name=step.container,
|
|
297
|
+
cwd=step.cwd,
|
|
298
|
+
timeout=timeout,
|
|
299
|
+
)
|
|
300
|
+
result["success"] = res["success"]
|
|
301
|
+
result["details"] = res.get("data") or {"error": res.get("error")}
|
|
302
|
+
result["ignore_error"] = step.ignore_error
|
|
303
|
+
|
|
304
|
+
else:
|
|
305
|
+
result["success"] = False
|
|
306
|
+
result["error"] = f"Unknown step type: {type(step)}"
|
|
307
|
+
|
|
308
|
+
except Exception as e:
|
|
309
|
+
result["success"] = False
|
|
310
|
+
result["error"] = str(e)
|
|
311
|
+
|
|
312
|
+
return result
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
# Singleton
|
|
316
|
+
_recipe_executor: RecipeExecutor | None = None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def get_recipe_executor() -> RecipeExecutor:
|
|
320
|
+
"""Get the singleton RecipeExecutor instance."""
|
|
321
|
+
global _recipe_executor
|
|
322
|
+
if _recipe_executor is None:
|
|
323
|
+
_recipe_executor = RecipeExecutor()
|
|
324
|
+
return _recipe_executor
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def reset_recipe_executor() -> None:
|
|
328
|
+
"""Reset the singleton (for testing)."""
|
|
329
|
+
global _recipe_executor
|
|
330
|
+
_recipe_executor = None
|
procler/core/snippets.py
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Snippet management for reusable commands."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from sqler.query import SQLerField as F
|
|
7
|
+
|
|
8
|
+
from ..db import init_database
|
|
9
|
+
from ..models import Snippet
|
|
10
|
+
from .context_docker import is_docker_available
|
|
11
|
+
from .context_local import get_local_context
|
|
12
|
+
from .variable_substitution import substitute_vars_from_config
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SnippetManager:
|
|
16
|
+
"""Manager for command snippets."""
|
|
17
|
+
|
|
18
|
+
def __init__(self):
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
def _get_snippet_by_name(self, name: str) -> Snippet | None:
|
|
22
|
+
"""Get a snippet by name."""
|
|
23
|
+
init_database()
|
|
24
|
+
results = Snippet.query().filter(F("name") == name).all()
|
|
25
|
+
return results[0] if results else None
|
|
26
|
+
|
|
27
|
+
def _snippet_to_dict(self, snippet: Snippet) -> dict[str, Any]:
|
|
28
|
+
"""Convert a Snippet to a dict for JSON output."""
|
|
29
|
+
return {
|
|
30
|
+
"id": snippet._id,
|
|
31
|
+
"name": snippet.name,
|
|
32
|
+
"command": snippet.command,
|
|
33
|
+
"description": snippet.description,
|
|
34
|
+
"context_type": snippet.context_type,
|
|
35
|
+
"container_name": snippet.container_name,
|
|
36
|
+
"tags": snippet.tags or [],
|
|
37
|
+
"created_at": snippet.created_at,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def list_snippets(self, tag: str | None = None) -> dict[str, Any]:
|
|
41
|
+
"""
|
|
42
|
+
List all snippets, optionally filtered by tag.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
tag: Optional tag to filter by
|
|
46
|
+
|
|
47
|
+
Returns a dict with snippet list (for JSON output).
|
|
48
|
+
"""
|
|
49
|
+
init_database()
|
|
50
|
+
|
|
51
|
+
snippets = Snippet.query().all()
|
|
52
|
+
|
|
53
|
+
if tag:
|
|
54
|
+
# Filter by tag
|
|
55
|
+
snippets = [s for s in snippets if s.tags and tag in s.tags]
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
"success": True,
|
|
59
|
+
"data": {
|
|
60
|
+
"snippets": [self._snippet_to_dict(s) for s in snippets],
|
|
61
|
+
"count": len(snippets),
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
def save_snippet(
|
|
66
|
+
self,
|
|
67
|
+
name: str,
|
|
68
|
+
command: str,
|
|
69
|
+
description: str | None = None,
|
|
70
|
+
context_type: str = "local",
|
|
71
|
+
container_name: str | None = None,
|
|
72
|
+
tags: list[str] | None = None,
|
|
73
|
+
) -> dict[str, Any]:
|
|
74
|
+
"""
|
|
75
|
+
Save a new snippet.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
name: Unique snippet name
|
|
79
|
+
command: Command to execute
|
|
80
|
+
description: Optional description
|
|
81
|
+
context_type: Execution context ('local' or 'docker')
|
|
82
|
+
container_name: Docker container name (required if context=docker)
|
|
83
|
+
tags: Optional list of tags
|
|
84
|
+
|
|
85
|
+
Returns a dict with save result (for JSON output).
|
|
86
|
+
"""
|
|
87
|
+
init_database()
|
|
88
|
+
|
|
89
|
+
# Validate docker context
|
|
90
|
+
if context_type == "docker" and not container_name:
|
|
91
|
+
return {
|
|
92
|
+
"success": False,
|
|
93
|
+
"error": "Container name required for docker context",
|
|
94
|
+
"error_code": "missing_container",
|
|
95
|
+
"suggestion": "Use --container <name> to specify the Docker container",
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
# Check if snippet already exists
|
|
99
|
+
existing = self._get_snippet_by_name(name)
|
|
100
|
+
if existing:
|
|
101
|
+
return {
|
|
102
|
+
"success": False,
|
|
103
|
+
"error": f"Snippet '{name}' already exists",
|
|
104
|
+
"error_code": "snippet_exists",
|
|
105
|
+
"suggestion": f"Use 'procler snippet remove {name}' first, or choose a different name",
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
snippet = Snippet(
|
|
109
|
+
name=name,
|
|
110
|
+
command=command,
|
|
111
|
+
description=description,
|
|
112
|
+
context_type=context_type,
|
|
113
|
+
container_name=container_name,
|
|
114
|
+
tags=tags,
|
|
115
|
+
created_at=datetime.now().isoformat(),
|
|
116
|
+
)
|
|
117
|
+
snippet.save()
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
"success": True,
|
|
121
|
+
"data": {
|
|
122
|
+
"action": "created",
|
|
123
|
+
"snippet": self._snippet_to_dict(snippet),
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
def remove_snippet(self, name: str) -> dict[str, Any]:
|
|
128
|
+
"""
|
|
129
|
+
Remove a snippet by name.
|
|
130
|
+
|
|
131
|
+
Returns a dict with removal result (for JSON output).
|
|
132
|
+
"""
|
|
133
|
+
init_database()
|
|
134
|
+
|
|
135
|
+
snippet = self._get_snippet_by_name(name)
|
|
136
|
+
if not snippet:
|
|
137
|
+
return {
|
|
138
|
+
"success": False,
|
|
139
|
+
"error": f"Snippet '{name}' not found",
|
|
140
|
+
"error_code": "snippet_not_found",
|
|
141
|
+
"suggestion": "Run 'procler snippet list' to see available snippets",
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
snippet.delete()
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
"success": True,
|
|
148
|
+
"data": {
|
|
149
|
+
"action": "removed",
|
|
150
|
+
"name": name,
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async def run_snippet(self, name: str) -> dict[str, Any]:
|
|
155
|
+
"""
|
|
156
|
+
Run a snippet by name.
|
|
157
|
+
|
|
158
|
+
Returns a dict with execution result (for JSON output).
|
|
159
|
+
"""
|
|
160
|
+
init_database()
|
|
161
|
+
|
|
162
|
+
snippet = self._get_snippet_by_name(name)
|
|
163
|
+
if not snippet:
|
|
164
|
+
return {
|
|
165
|
+
"success": False,
|
|
166
|
+
"error": f"Snippet '{name}' not found",
|
|
167
|
+
"error_code": "snippet_not_found",
|
|
168
|
+
"suggestion": "Run 'procler snippet list' to see available snippets",
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
# Substitute config vars in command (e.g., ${SIM_CONTAINER})
|
|
172
|
+
resolved_command = substitute_vars_from_config(snippet.command)
|
|
173
|
+
|
|
174
|
+
# Validate docker context
|
|
175
|
+
if snippet.context_type == "docker":
|
|
176
|
+
if not snippet.container_name:
|
|
177
|
+
return {
|
|
178
|
+
"success": False,
|
|
179
|
+
"error": "Snippet has docker context but no container name",
|
|
180
|
+
"error_code": "missing_container",
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if not is_docker_available():
|
|
184
|
+
return {
|
|
185
|
+
"success": False,
|
|
186
|
+
"error": "Docker is not available",
|
|
187
|
+
"error_code": "docker_unavailable",
|
|
188
|
+
"suggestion": "Ensure Docker is installed and running",
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
# Import here to avoid circular imports
|
|
192
|
+
from .context_docker import get_docker_context
|
|
193
|
+
|
|
194
|
+
context = get_docker_context()
|
|
195
|
+
try:
|
|
196
|
+
result = await context.exec_command(
|
|
197
|
+
command=resolved_command,
|
|
198
|
+
container_name=snippet.container_name,
|
|
199
|
+
)
|
|
200
|
+
except ValueError as e:
|
|
201
|
+
return {
|
|
202
|
+
"success": False,
|
|
203
|
+
"error": str(e),
|
|
204
|
+
"error_code": "container_not_found",
|
|
205
|
+
}
|
|
206
|
+
else:
|
|
207
|
+
# Local context
|
|
208
|
+
context = get_local_context()
|
|
209
|
+
result = await context.exec_command(command=resolved_command)
|
|
210
|
+
|
|
211
|
+
return {
|
|
212
|
+
"success": True,
|
|
213
|
+
"data": {
|
|
214
|
+
"snippet": name,
|
|
215
|
+
"stdout": result.stdout,
|
|
216
|
+
"stderr": result.stderr,
|
|
217
|
+
"exit_code": result.exit_code,
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# Global singleton
|
|
223
|
+
_snippet_manager: SnippetManager | None = None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def get_snippet_manager() -> SnippetManager:
|
|
227
|
+
"""Get the global SnippetManager instance."""
|
|
228
|
+
global _snippet_manager
|
|
229
|
+
if _snippet_manager is None:
|
|
230
|
+
_snippet_manager = SnippetManager()
|
|
231
|
+
return _snippet_manager
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Variable substitution for procler commands.
|
|
2
|
+
|
|
3
|
+
Replaces ${VAR} patterns in commands with values from config.vars.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import re
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
# Patterns that might indicate shell injection
|
|
14
|
+
SUSPICIOUS_PATTERNS = [";", "&&", "||", "$(", "`", "\n"]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def warn_suspicious_vars(vars: dict[str, str]) -> None:
|
|
18
|
+
"""Log warnings for vars containing suspicious patterns."""
|
|
19
|
+
for name, value in vars.items():
|
|
20
|
+
for pattern in SUSPICIOUS_PATTERNS:
|
|
21
|
+
if pattern in value:
|
|
22
|
+
logger.warning(
|
|
23
|
+
f"Variable '{name}' contains suspicious pattern '{pattern}'. " f"Ensure this is intentional."
|
|
24
|
+
)
|
|
25
|
+
break
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def substitute_vars(text: str, vars: dict[str, str]) -> str:
|
|
29
|
+
"""
|
|
30
|
+
Replace ${VAR} patterns with values from vars dict.
|
|
31
|
+
|
|
32
|
+
If a variable is not found, the original ${VAR} pattern is kept.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
text: String containing ${VAR} patterns
|
|
36
|
+
vars: Dict mapping variable names to values
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
String with variables substituted
|
|
40
|
+
"""
|
|
41
|
+
if not vars:
|
|
42
|
+
return text
|
|
43
|
+
|
|
44
|
+
def replacer(match: re.Match) -> str:
|
|
45
|
+
var_name = match.group(1)
|
|
46
|
+
if var_name in vars:
|
|
47
|
+
return vars[var_name]
|
|
48
|
+
# Keep original if not found
|
|
49
|
+
return match.group(0)
|
|
50
|
+
|
|
51
|
+
# Match ${VAR_NAME} pattern (alphanumeric and underscore)
|
|
52
|
+
return re.sub(r"\$\{(\w+)\}", replacer, text)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def substitute_vars_from_config(text: str) -> str:
|
|
56
|
+
"""
|
|
57
|
+
Replace ${VAR} patterns with values from config.vars.
|
|
58
|
+
|
|
59
|
+
Convenience function that loads vars from the current config.
|
|
60
|
+
"""
|
|
61
|
+
# Import here to avoid circular dependency
|
|
62
|
+
from procler.config.loader import get_config
|
|
63
|
+
|
|
64
|
+
config = get_config()
|
|
65
|
+
return substitute_vars(text, config.vars)
|