devpy-cli 1.0.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.
app.py ADDED
@@ -0,0 +1,17 @@
1
+ import os
2
+
3
+ # Check for .env before importing frontend_cli which imports backend
4
+ if not os.path.exists('.env'):
5
+ try:
6
+ from setup_wizard import run_setup
7
+
8
+ run_setup()
9
+ except ImportError:
10
+ print('Error: setup_wizard module not found. Please ensure all files are installed correctly.')
11
+ exit(1)
12
+
13
+ from frontend_cli import run_cli
14
+
15
+
16
+ if __name__ == '__main__':
17
+ run_cli()
backend.py ADDED
@@ -0,0 +1,417 @@
1
+ import os
2
+ import threading
3
+ import psutil
4
+ import docker
5
+ import time
6
+ import tempfile
7
+ import atexit
8
+ import re
9
+ from docker.transport import SSHHTTPAdapter
10
+ from dotenv import load_dotenv
11
+ from rich.console import Console
12
+ from rich.markdown import Markdown
13
+ from langchain_core.tools import tool
14
+ from langgraph.prebuilt import create_react_agent
15
+ from langgraph.checkpoint.memory import MemorySaver
16
+ from langchain_core.messages import HumanMessage
17
+ from permissions_manager import PermissionManager, PermissionDecision
18
+ from config_manager import ConfigManager
19
+ from ssh_key_manager import SSHKeyManager
20
+
21
+ load_dotenv()
22
+
23
+ console = Console()
24
+
25
+ config_manager = ConfigManager()
26
+ ssh_key_manager = SSHKeyManager()
27
+ _docker_client = None
28
+ _ssh_temp_key_path = None
29
+
30
+
31
+ def cleanup_temp_key():
32
+ global _ssh_temp_key_path
33
+ if _ssh_temp_key_path and os.path.exists(_ssh_temp_key_path):
34
+ try:
35
+ os.remove(_ssh_temp_key_path)
36
+ except Exception:
37
+ pass
38
+
39
+
40
+ atexit.register(cleanup_temp_key)
41
+
42
+
43
+ class CustomSSHAdapter(SSHHTTPAdapter):
44
+ def __init__(self, base_url, key_filename, **kwargs):
45
+ self.key_filename = key_filename
46
+ super().__init__(base_url, **kwargs)
47
+
48
+ def _create_paramiko_client(self, base_url):
49
+ super()._create_paramiko_client(base_url)
50
+ if self.key_filename:
51
+ self.ssh_params['key_filename'] = self.key_filename
52
+
53
+
54
+ def reset_docker_client():
55
+ global _docker_client
56
+ if _docker_client:
57
+ try:
58
+ _docker_client.close()
59
+ except Exception:
60
+ pass
61
+ _docker_client = None
62
+ cleanup_temp_key()
63
+
64
+
65
+ def get_docker_client():
66
+ global _docker_client, _ssh_temp_key_path
67
+ if _docker_client:
68
+ return _docker_client
69
+
70
+ mode = config_manager.get_mode()
71
+ if mode == 'local':
72
+ try:
73
+ _docker_client = docker.from_env()
74
+ except Exception as e:
75
+ console.print(f'[bold red]Error initializing local Docker client: {e}[/bold red]')
76
+ # Return a dummy client or let it fail later?
77
+ # Better to raise, but tools might need handling.
78
+ raise e
79
+ else:
80
+ ssh_config = config_manager.get_ssh_config()
81
+ host = ssh_config.get('host')
82
+ user = ssh_config.get('user')
83
+ key_name = ssh_config.get('key_name')
84
+
85
+ if not host or not user or not key_name:
86
+ console.print('[bold red]SSH configuration incomplete. Please configure SSH settings.[/bold red]')
87
+ raise ValueError('SSH configuration incomplete')
88
+
89
+ # Get Passphrase
90
+ passphrase = os.getenv('DOCKER_SSH_PASSPHRASE')
91
+ if not passphrase:
92
+ from rich.prompt import Prompt
93
+
94
+ passphrase = Prompt.ask(f"Enter passphrase for key '{key_name}'", password=True)
95
+
96
+ try:
97
+ private_key_content = ssh_key_manager.get_key(key_name, passphrase)
98
+ except Exception as e:
99
+ console.print(f'[bold red]Failed to load SSH key: {e}[/bold red]')
100
+ raise e
101
+
102
+ # Create temp file for the key
103
+ # We need to close it so paramiko can open it by name
104
+ tf = tempfile.NamedTemporaryFile(delete=False, mode='w', encoding='utf-8')
105
+ tf.write(private_key_content)
106
+ tf.close()
107
+ _ssh_temp_key_path = tf.name
108
+
109
+ ssh_url = f'ssh://{user}@{host}'
110
+
111
+ try:
112
+ # Create SSH Adapter
113
+ ssh_adapter = CustomSSHAdapter(ssh_url, key_filename=_ssh_temp_key_path)
114
+
115
+ # Create API Client
116
+ api_client = docker.APIClient(base_url='http://localhost', version='auto')
117
+ api_client.mount('http+docker://ssh', ssh_adapter)
118
+ api_client.base_url = 'http+docker://ssh'
119
+
120
+ # Create Docker Client
121
+ client = docker.DockerClient(version='auto')
122
+ client.api = api_client
123
+ _docker_client = client
124
+ except Exception as e:
125
+ cleanup_temp_key()
126
+ console.print(f'[bold red]Error connecting to remote Docker: {e}[/bold red]')
127
+ raise e
128
+
129
+ return _docker_client
130
+
131
+
132
+ global_config = {'configurable': {'thread_id': 'prinsipal_devops'}}
133
+
134
+ permission_manager = PermissionManager()
135
+
136
+
137
+ def build_command_preview(parts):
138
+ return ' '.join(str(p) for p in parts)
139
+
140
+
141
+ def permission_prompt(operation, impact, command_preview):
142
+ console.print('\n[bold yellow]Permission Required[/bold yellow]')
143
+ console.print(f'Operation: {operation}')
144
+ if impact:
145
+ console.print(f'Potential Impact: {impact}')
146
+ if command_preview:
147
+ console.print(f'Command: {command_preview}')
148
+ console.print('Options: (y) yes, (n) no, (yc) yes for command, (ys) yes for session')
149
+ from rich.prompt import Prompt
150
+
151
+ answer = Prompt.ask('(y/n/yc/ys)', choices=['y', 'n', 'yc', 'ys'], default='n')
152
+ if answer == 'y':
153
+ return PermissionDecision.ALLOW_ONCE
154
+ if answer == 'yc':
155
+ return PermissionDecision.ALLOW_COMMAND
156
+ if answer == 'ys':
157
+ return PermissionDecision.ALLOW_SESSION
158
+ return PermissionDecision.DENY
159
+
160
+
161
+ @tool
162
+ def check_resource() -> str:
163
+ """Shows system CPU, memory, and disk usage"""
164
+ # For remote docker, psutil runs on LOCAL machine.
165
+ # If we want remote stats, we should use a container or docker stats?
166
+ # The user asked for "Remote Docker execution".
167
+ # check_resource using psutil checks LOCAL resource.
168
+ # This might be intended or not. If we want remote host stats, we can't easily get them via docker API except via a container.
169
+ # I'll keep it local for now as psutil is local.
170
+ cpu = psutil.cpu_percent(interval=1)
171
+ memory = psutil.virtual_memory()
172
+ disk = psutil.disk_usage('/')
173
+ return f'CPU: {cpu}%, Memory: {memory.percent}%, Disk: {disk.percent}%'
174
+
175
+
176
+ @tool
177
+ def get_docker_logs(container_name: str, tail: int = 50) -> str:
178
+ """Gets the last logs of a Docker container"""
179
+ try:
180
+ client = get_docker_client()
181
+ container = client.containers.get(container_name)
182
+ logs = container.logs(tail=tail).decode('utf-8')
183
+ return f'Logs for container {container_name}:\n{logs[-2000:]}'
184
+ except docker.errors.NotFound:
185
+ return f'Error: Container {container_name} not found'
186
+ except Exception as e:
187
+ return f'Error: {str(e)}'
188
+
189
+
190
+ @tool
191
+ def list_containers() -> str:
192
+ """Lists active Docker containers with their status"""
193
+ try:
194
+ client = get_docker_client()
195
+ containers = client.containers.list()
196
+ return '\n'.join([f'{c.name} ({c.status})' for c in containers])
197
+ except Exception as e:
198
+ return f'Error listing containers: {e}'
199
+
200
+
201
+ @tool
202
+ def inspect_container(container_name: str) -> str:
203
+ """Inspects a Docker container and returns its attributes"""
204
+ try:
205
+ client = get_docker_client()
206
+ container = client.containers.get(container_name)
207
+ return str(container.attrs)
208
+ except docker.errors.NotFound:
209
+ return f'Error: Container {container_name} not found'
210
+ except Exception as e:
211
+ return f'Error: {str(e)}'
212
+
213
+
214
+ @tool
215
+ def restart_docker_container(container_name: str) -> str:
216
+ """Restarts a specified Docker container"""
217
+ command_preview = build_command_preview(['docker', 'restart', container_name])
218
+
219
+ def action():
220
+ client = get_docker_client()
221
+ container = client.containers.get(container_name)
222
+ container.restart()
223
+ return f'Container {container_name} restarted'
224
+
225
+ return permission_manager.execute(
226
+ operation='restart_container',
227
+ fn=action,
228
+ fn_kwargs={},
229
+ command_preview=command_preview,
230
+ impact='Restarts the indicated container',
231
+ command_key=f'restart:{container_name}',
232
+ prompt_func=permission_prompt,
233
+ )
234
+
235
+
236
+ @tool
237
+ def create_container(container_image: str, container_name: str) -> str:
238
+ """Creates and starts a new Docker container with given image and name"""
239
+ command_preview = build_command_preview(['docker', 'run', '-d', '--name', container_name, container_image])
240
+
241
+ def action():
242
+ client = get_docker_client()
243
+ container = client.containers.create(container_image, name=container_name)
244
+ container.start()
245
+ return f'Container {container_name} created and started'
246
+
247
+ return permission_manager.execute(
248
+ operation='create_container',
249
+ fn=action,
250
+ fn_kwargs={},
251
+ command_preview=command_preview,
252
+ impact='Creates and starts a new container',
253
+ command_key=f'create:{container_image}:{container_name}',
254
+ prompt_func=permission_prompt,
255
+ )
256
+
257
+
258
+ @tool
259
+ def delete_container(container_name: str) -> str:
260
+ """Stops and removes the specified Docker container"""
261
+ command_preview = build_command_preview(['docker', 'rm', '-f', container_name])
262
+
263
+ def action():
264
+ client = get_docker_client()
265
+ container = client.containers.get(container_name)
266
+ container.stop()
267
+ container.remove()
268
+ return f'Container {container_name} deleted'
269
+
270
+ return permission_manager.execute(
271
+ operation='delete_container',
272
+ fn=action,
273
+ fn_kwargs={},
274
+ command_preview=command_preview,
275
+ impact='Stops and removes the indicated container',
276
+ command_key=f'delete:{container_name}',
277
+ prompt_func=permission_prompt,
278
+ )
279
+
280
+
281
+ @tool
282
+ def stop_container(container_name: str) -> str:
283
+ """Stops the specified Docker container"""
284
+ command_preview = build_command_preview(['docker', 'stop', container_name])
285
+
286
+ def action():
287
+ client = get_docker_client()
288
+ container = client.containers.get(container_name)
289
+ container.stop()
290
+ return f'Container {container_name} stopped'
291
+
292
+ return permission_manager.execute(
293
+ operation='stop_container',
294
+ fn=action,
295
+ fn_kwargs={},
296
+ command_preview=command_preview,
297
+ impact='Stops the indicated container',
298
+ command_key=f'stop:{container_name}',
299
+ prompt_func=permission_prompt,
300
+ )
301
+
302
+
303
+ def background_monitor_task(container_name: str, threshold: float):
304
+ while True:
305
+ try:
306
+ client = get_docker_client()
307
+ container = client.containers.get(container_name)
308
+ stats = container.stats(stream=False)
309
+ mem_usage = stats['memory_stats']['usage']
310
+ mem_limit = stats['memory_stats']['limit']
311
+ mem_percent = (mem_usage / mem_limit) * 100
312
+ if mem_percent > threshold:
313
+ console.print(
314
+ f'[blink bold red]Warning: Memory usage {mem_percent:.2f}% exceeds threshold {threshold}%[/blink bold red]'
315
+ )
316
+ console.print('[yellow]Autodiagnostic[/yellow]')
317
+ alert_msg = f'Warning: Memory usage {mem_percent:.2f}% exceeds threshold {threshold}%'
318
+ run_agent_flow(alert_msg)
319
+ break
320
+ except Exception:
321
+ pass
322
+ time.sleep(10)
323
+
324
+
325
+ @tool
326
+ def start_monitoring(container_name: str, threshold_percent: float) -> str:
327
+ """Starts memory monitoring for the container and alerts if threshold is exceeded"""
328
+ command_preview = build_command_preview(['monitor', 'memory', container_name, f'threshold={threshold_percent}'])
329
+
330
+ def action():
331
+ t = threading.Thread(target=background_monitor_task, args=(container_name, threshold_percent), daemon=True)
332
+ t.start()
333
+ return f'Monitoring started for container {container_name} with threshold {threshold_percent}%'
334
+
335
+ return permission_manager.execute(
336
+ operation='start_monitoring',
337
+ fn=action,
338
+ fn_kwargs={},
339
+ command_preview=command_preview,
340
+ impact='Starts monitoring that can trigger container restarts',
341
+ command_key=f'monitor:{container_name}:{threshold_percent}',
342
+ prompt_func=permission_prompt,
343
+ )
344
+
345
+
346
+ def sanitize_command(command: str) -> str:
347
+ """Sanitizes the command to prevent common injection attacks."""
348
+ # Deny chaining characters
349
+ if re.search(r'[;&|]', command):
350
+ raise ValueError('Command chaining characters (;, &, |) are not allowed.')
351
+
352
+ # Deny command substitution
353
+ if re.search(r'\$\(.*\)|`.*`', command):
354
+ raise ValueError('Command substitution is not allowed.')
355
+
356
+ return command
357
+
358
+
359
+ @tool
360
+ def exec_command(container_name: str, command: str) -> str:
361
+ """Executes a command in the specified Docker container"""
362
+ try:
363
+ safe_command = sanitize_command(command)
364
+ except ValueError as e:
365
+ return f'Security Error: {e}'
366
+
367
+ command_preview = build_command_preview(['docker', 'exec', container_name, safe_command])
368
+
369
+ def action():
370
+ client = get_docker_client()
371
+ container = client.containers.get(container_name)
372
+ exec_id = container.exec_run(safe_command)
373
+ return exec_id.output.decode('utf-8').strip()
374
+
375
+ return permission_manager.execute(
376
+ operation='exec_command',
377
+ fn=action,
378
+ fn_kwargs={},
379
+ command_preview=command_preview,
380
+ impact='Executes a command in the indicated container',
381
+ command_key=f'exec:{container_name}:{safe_command}',
382
+ prompt_func=permission_prompt,
383
+ )
384
+
385
+
386
+ tools = [
387
+ check_resource,
388
+ get_docker_logs,
389
+ list_containers,
390
+ inspect_container,
391
+ restart_docker_container,
392
+ create_container,
393
+ delete_container,
394
+ stop_container,
395
+ start_monitoring,
396
+ exec_command,
397
+ ]
398
+
399
+
400
+ if os.getenv('LLM') == 'deepseek':
401
+ from llm.deepseek import llm
402
+ else:
403
+ from llm.chatgpt import llm
404
+
405
+
406
+ memory = MemorySaver()
407
+ agent_executor = create_react_agent(llm, tools, checkpointer=memory)
408
+
409
+
410
+ def run_agent_flow(user_input: str):
411
+ initial_state = {'messages': [HumanMessage(content=user_input)]}
412
+ for event in agent_executor.stream(initial_state, global_config):
413
+ if 'agent' in event:
414
+ msg = event['agent']['messages'][0]
415
+ if msg.content:
416
+ console.print('\n[bold magenta]Agent[/bold magenta]')
417
+ console.print(Markdown(msg.content))
config_manager.py ADDED
@@ -0,0 +1,47 @@
1
+ import json
2
+ import os
3
+
4
+ class ConfigManager:
5
+ def __init__(self, config_file='config.json'):
6
+ self.config_file = config_file
7
+ self.config = self._load_config()
8
+
9
+ def _load_config(self):
10
+ if not os.path.exists(self.config_file):
11
+ return {
12
+ 'mode': 'local',
13
+ 'ssh': {
14
+ 'host': '',
15
+ 'user': '',
16
+ 'key_name': ''
17
+ }
18
+ }
19
+ try:
20
+ with open(self.config_file, 'r', encoding='utf-8') as f:
21
+ return json.load(f)
22
+ except json.JSONDecodeError:
23
+ return {'mode': 'local'}
24
+
25
+ def save_config(self):
26
+ with open(self.config_file, 'w', encoding='utf-8') as f:
27
+ json.dump(self.config, f, indent=2)
28
+
29
+ def set_mode(self, mode):
30
+ if mode not in ['local', 'ssh']:
31
+ raise ValueError("Mode must be 'local' or 'ssh'")
32
+ self.config['mode'] = mode
33
+ self.save_config()
34
+
35
+ def get_mode(self):
36
+ return self.config.get('mode', 'local')
37
+
38
+ def set_ssh_config(self, host, user, key_name):
39
+ self.config['ssh'] = {
40
+ 'host': host,
41
+ 'user': user,
42
+ 'key_name': key_name
43
+ }
44
+ self.save_config()
45
+
46
+ def get_ssh_config(self):
47
+ return self.config.get('ssh', {})