devops-copilot 0.1.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.
@@ -0,0 +1,443 @@
1
+ import re
2
+ import uuid
3
+ import asyncio
4
+ import contextvars
5
+ import asyncssh
6
+ from typing import List, Tuple
7
+ from fastapi import HTTPException, status
8
+ from langchain_openai import ChatOpenAI
9
+ from langchain.agents import create_agent
10
+ from langchain_core.messages import HumanMessage, AIMessage
11
+ from langchain_core.tools import tool
12
+ from langchain_core.callbacks import AsyncCallbackHandler
13
+ from sqlmodel import select
14
+
15
+ from app.core.config import settings
16
+ from app.core.database import async_session_maker
17
+ from app.core.security import decrypt_data
18
+ from app.modules.servers.models import Server
19
+ from app.modules.servers.service import servers_service
20
+ from app.modules.chat.models import AgentAction
21
+
22
+ # Context variables to track active execution details across async threads
23
+ active_websocket = contextvars.ContextVar("active_websocket")
24
+ active_session_id = contextvars.ContextVar("active_session_id")
25
+ active_owner_id = contextvars.ContextVar("active_owner_id")
26
+
27
+ class StreamingCallbackHandler(AsyncCallbackHandler):
28
+ """Callback handler to stream LLM generation tokens directly to the active WebSocket."""
29
+ def __init__(self, websocket):
30
+ self.websocket = websocket
31
+
32
+ async def on_llm_new_token(self, token: str, **kwargs) -> None:
33
+ try:
34
+ await self.websocket.send_json({
35
+ "type": "token",
36
+ "data": token
37
+ })
38
+ except Exception:
39
+ pass
40
+
41
+ def get_llm(callbacks=None) -> ChatOpenAI:
42
+ """Initialize OpenRouter LLM client with streaming support."""
43
+ if not settings.OPENROUTER_API_KEY:
44
+ raise ValueError("OPENROUTER_API_KEY is not configured in environment")
45
+
46
+ return ChatOpenAI(
47
+ openai_api_key=settings.OPENROUTER_API_KEY,
48
+ openai_api_base="https://openrouter.ai/api/v1",
49
+ model=settings.OPENROUTER_MODEL,
50
+ temperature=0.0, # Zero temperature for deterministic system operations
51
+ streaming=True,
52
+ callbacks=callbacks or [],
53
+ default_headers={
54
+ "HTTP-Referer": "https://github.com/irzix/devops-copilot",
55
+ "X-Title": "DevOps Copilot Agent"
56
+ }
57
+ )
58
+
59
+ def is_write_command(command: str) -> bool:
60
+ """
61
+ Heuristic check to determine if a bash command is state-modifying.
62
+ Read-only commands are allowed to run instantly; others require human verification.
63
+ """
64
+ cmd = command.strip().lower()
65
+
66
+ # List of safe read-only commands
67
+ read_only_prefixes = (
68
+ "df", "free", "uptime", "uname", "whoami", "hostname",
69
+ "ps", "top", "htop", "cat", "ls", "grep", "tail", "head",
70
+ "stat", "systemctl status", "service status"
71
+ )
72
+
73
+ # Split by common shell execution delimiters to check pipelines
74
+ sub_commands = re.split(r';|&&|\|\||\|', cmd)
75
+
76
+ for sub in sub_commands:
77
+ sub = sub.strip()
78
+ if not sub:
79
+ continue
80
+
81
+ is_read = False
82
+ for prefix in read_only_prefixes:
83
+ if sub.startswith(prefix):
84
+ # If command writes outputs via redirection, treat it as a write command
85
+ if ">" not in sub:
86
+ is_read = True
87
+ break
88
+
89
+ if not is_read:
90
+ return True
91
+
92
+ return False
93
+
94
+ @tool
95
+ async def list_available_servers() -> str:
96
+ """
97
+ Retrieve a list of all target servers currently registered in the database.
98
+ Returns their IDs, names, and IP addresses. Use this tool to discover the server_id before running commands.
99
+ """
100
+ try:
101
+ owner_id = active_owner_id.get()
102
+ async with async_session_maker() as session:
103
+ statement = select(Server).where(Server.owner_id == owner_id)
104
+ result = await session.exec(statement)
105
+ servers = result.all()
106
+
107
+ if not servers:
108
+ return "No target servers registered yet. Tell the user to register a server first."
109
+
110
+ output = "Available target servers:\n"
111
+ for s in servers:
112
+ output += f"- ID: {s.id} | Name: {s.name} | Host: {s.ip_address}:{s.ssh_port} | User: {s.ssh_username} | Auth: {s.ssh_auth_type}\n"
113
+ return output
114
+ except Exception as e:
115
+ return f"Error retrieving server list: {str(e)}"
116
+
117
+ @tool
118
+ async def execute_ssh_command(server_id: int | str, command: str) -> str:
119
+ """
120
+ Execute a terminal command asynchronously on a target server via SSH.
121
+ First runs semantic security checks and determines if human-in-the-loop approval is required.
122
+ Streams execution logs line-by-line in real-time to the active terminal session.
123
+ """
124
+ try:
125
+ # 1. Run semantic safety guardrail
126
+ from app.modules.guardrails.service import is_command_safe
127
+ is_safe, warning, similarity = is_command_safe(command)
128
+ if not is_safe:
129
+ return f"Execution Blocked by Guardrail: {warning} (similarity index: {similarity:.2f})"
130
+
131
+ # 2. Enforce human verification for state-modifying actions
132
+ if is_write_command(command):
133
+ action_id = f"act_{uuid.uuid4().hex[:12]}"
134
+ sess_id = active_session_id.get()
135
+ owner_id = active_owner_id.get()
136
+
137
+ async with async_session_maker() as session:
138
+ # Check server ownership
139
+ try:
140
+ # Check if server_id is an integer or digit string
141
+ is_id = False
142
+ if isinstance(server_id, int):
143
+ is_id = True
144
+ elif isinstance(server_id, str) and server_id.isdigit():
145
+ is_id = True
146
+ server_id = int(server_id)
147
+
148
+ if is_id:
149
+ server = await servers_service.get_server_by_id(session, server_id, owner_id)
150
+ else:
151
+ server = await servers_service.get_server_by_name(session, str(server_id), owner_id)
152
+ except Exception:
153
+ return f"Error: Server '{server_id}' not found or permission denied."
154
+
155
+ # Record the pending action in database
156
+ action = AgentAction(
157
+ id=action_id,
158
+ session_id=sess_id,
159
+ server_id=server.id,
160
+ command=command,
161
+ status="pending"
162
+ )
163
+ session.add(action)
164
+ await session.commit()
165
+
166
+ # Emit approval notification to WebSocket client
167
+ ws = active_websocket.get()
168
+ await ws.send_json({
169
+ "type": "approval_required",
170
+ "action_id": action_id,
171
+ "server_name": server.name,
172
+ "command": command
173
+ })
174
+
175
+ # Return special status string to halt agent execution loop
176
+ return f"PAUSED: REQUIRES_APPROVAL: {action_id}"
177
+
178
+ # 3. Safe read-only execution: connect and stream output line-by-line
179
+ owner_id = active_owner_id.get()
180
+ async with async_session_maker() as session:
181
+ try:
182
+ # Check if server_id is an integer or digit string
183
+ is_id = False
184
+ if isinstance(server_id, int):
185
+ is_id = True
186
+ elif isinstance(server_id, str) and server_id.isdigit():
187
+ is_id = True
188
+ server_id = int(server_id)
189
+
190
+ if is_id:
191
+ server = await servers_service.get_server_by_id(session, server_id, owner_id)
192
+ else:
193
+ server = await servers_service.get_server_by_name(session, str(server_id), owner_id)
194
+ except Exception:
195
+ return f"Error: Server '{server_id}' not found."
196
+
197
+ credential = decrypt_data(server.encrypted_credential)
198
+ conn_args = {
199
+ "host": server.ip_address,
200
+ "port": server.ssh_port,
201
+ "username": server.ssh_username,
202
+ "known_hosts": None,
203
+ }
204
+
205
+ if server.ssh_auth_type == "password":
206
+ conn_args["password"] = credential
207
+ else:
208
+ try:
209
+ conn_args["client_keys"] = [asyncssh.import_private_key(credential)]
210
+ except Exception as e:
211
+ return f"Error: Invalid SSH private key format: {str(e)}"
212
+
213
+ ws = active_websocket.get()
214
+ await ws.send_json({
215
+ "type": "stdout",
216
+ "data": f"\n[Executing on {server.name}]: {command}\n"
217
+ })
218
+
219
+ stdout_accumulated = []
220
+ stderr_accumulated = []
221
+
222
+ async def run_with_timeout():
223
+ async with asyncssh.connect(**conn_args) as conn:
224
+ async with conn.create_process(command) as process:
225
+ # Stream stdout line-by-line
226
+ async for line in process.stdout:
227
+ if isinstance(line, bytes):
228
+ line = line.decode("utf-8", errors="replace")
229
+ stdout_accumulated.append(line)
230
+ await ws.send_json({"type": "stdout", "data": line})
231
+
232
+ # Stream stderr line-by-line
233
+ async for line in process.stderr:
234
+ if isinstance(line, bytes):
235
+ line = line.decode("utf-8", errors="replace")
236
+ stderr_accumulated.append(line)
237
+ await ws.send_json({"type": "stderr", "data": line})
238
+
239
+ exit_status = await process.wait()
240
+ exit_code = exit_status.exit_status if exit_status.exit_status is not None else 0
241
+
242
+ await ws.send_json({
243
+ "type": "stdout",
244
+ "data": f"[Process finished with code {exit_code}]\n"
245
+ })
246
+
247
+ stdout_str = "".join(stdout_accumulated)
248
+ stderr_str = "".join(stderr_accumulated)
249
+
250
+ # Auto-index execution output into RAG knowledge base
251
+ try:
252
+ from app.modules.knowledge.service import index_command_output
253
+ index_command_output(server.name, command, stdout_str, stderr_str, exit_code)
254
+ except Exception:
255
+ pass # Non-critical: don't break execution if indexing fails
256
+
257
+ return f"Exit Code: {exit_code}\nStdout:\n{stdout_str}\nStderr:\n{stderr_str}"
258
+
259
+ try:
260
+ return await asyncio.wait_for(run_with_timeout(), timeout=30.0)
261
+ except asyncio.TimeoutError:
262
+ return f"Error: Command execution timed out after 30 seconds on {server.name}."
263
+ except Exception as e:
264
+ return f"Tool Execution Error: {str(e)}"
265
+
266
+
267
+ @tool
268
+ async def search_knowledge(query: str, sources: list[str] | None = None) -> str:
269
+ """
270
+ Search the DevOps knowledge base for previously collected information.
271
+ Sources can include: 'command_history', 'server_logs', 'server_configs'.
272
+ If sources is None, all collections are searched.
273
+ Use this tool to recall past command outputs, server logs, or config file contents.
274
+ """
275
+ try:
276
+ from app.modules.knowledge.service import search
277
+ return search(query, collection_names=sources, n_results=3)
278
+ except Exception as e:
279
+ return f"Knowledge search error: {str(e)}"
280
+
281
+
282
+ @tool
283
+ async def fetch_server_logs(server_id: int | str, log_source: str = "journalctl -n 100 --no-pager") -> str:
284
+ """
285
+ Fetch recent logs from a target server via SSH, stream them to the user, and auto-index them.
286
+ Default log source is journalctl. Can also use: 'tail -n 100 /var/log/syslog', etc.
287
+ """
288
+ try:
289
+ owner_id = active_owner_id.get()
290
+ async with async_session_maker() as session:
291
+ try:
292
+ is_id = isinstance(server_id, int) or (isinstance(server_id, str) and server_id.isdigit())
293
+ if is_id:
294
+ server = await servers_service.get_server_by_id(session, int(server_id) if isinstance(server_id, str) else server_id, owner_id)
295
+ else:
296
+ server = await servers_service.get_server_by_name(session, str(server_id), owner_id)
297
+ except Exception:
298
+ return f"Error: Server '{server_id}' not found."
299
+
300
+ credential = decrypt_data(server.encrypted_credential)
301
+ conn_args = {
302
+ "host": server.ip_address,
303
+ "port": server.ssh_port,
304
+ "username": server.ssh_username,
305
+ "known_hosts": None,
306
+ }
307
+ if server.ssh_auth_type == "password":
308
+ conn_args["password"] = credential
309
+ else:
310
+ conn_args["client_keys"] = [asyncssh.import_private_key(credential)]
311
+
312
+ ws = active_websocket.get()
313
+ await ws.send_json({"type": "stdout", "data": f"\n[Fetching logs from {server.name}]: {log_source}\n"})
314
+
315
+ async def run_logs_with_timeout():
316
+ async with asyncssh.connect(**conn_args) as conn:
317
+ result = await conn.run(log_source, check=False)
318
+ stdout = result.stdout or ""
319
+ if isinstance(stdout, bytes):
320
+ stdout = stdout.decode("utf-8", errors="replace")
321
+ await ws.send_json({"type": "stdout", "data": stdout})
322
+ return stdout
323
+
324
+ try:
325
+ stdout = await asyncio.wait_for(run_logs_with_timeout(), timeout=30.0)
326
+ except asyncio.TimeoutError:
327
+ return f"Error: Fetching logs timed out after 30 seconds on {server.name}."
328
+
329
+ # Auto-index fetched logs
330
+ try:
331
+ from app.modules.knowledge.service import index_log
332
+ index_log(server.name, log_source, stdout)
333
+ except Exception:
334
+ pass
335
+
336
+ return f"Fetched {len(stdout)} chars of logs from {server.name}.\n{stdout[:2000]}"
337
+ except Exception as e:
338
+ return f"Error fetching logs: {str(e)}"
339
+
340
+
341
+ @tool
342
+ async def fetch_server_config(server_id: int | str, file_path: str) -> str:
343
+ """
344
+ Read a config file from a target server via SSH (e.g. /etc/nginx/nginx.conf),
345
+ stream it to the user, and auto-index it for future reference.
346
+ """
347
+ try:
348
+ owner_id = active_owner_id.get()
349
+ async with async_session_maker() as session:
350
+ try:
351
+ is_id = isinstance(server_id, int) or (isinstance(server_id, str) and server_id.isdigit())
352
+ if is_id:
353
+ server = await servers_service.get_server_by_id(session, int(server_id) if isinstance(server_id, str) else server_id, owner_id)
354
+ else:
355
+ server = await servers_service.get_server_by_name(session, str(server_id), owner_id)
356
+ except Exception:
357
+ return f"Error: Server '{server_id}' not found."
358
+
359
+ credential = decrypt_data(server.encrypted_credential)
360
+ conn_args = {
361
+ "host": server.ip_address,
362
+ "port": server.ssh_port,
363
+ "username": server.ssh_username,
364
+ "known_hosts": None,
365
+ }
366
+ if server.ssh_auth_type == "password":
367
+ conn_args["password"] = credential
368
+ else:
369
+ conn_args["client_keys"] = [asyncssh.import_private_key(credential)]
370
+
371
+ ws = active_websocket.get()
372
+ await ws.send_json({"type": "stdout", "data": f"\n[Reading {file_path} from {server.name}]\n"})
373
+
374
+ async def run_config_with_timeout():
375
+ async with asyncssh.connect(**conn_args) as conn:
376
+ result = await conn.run(f"cat {file_path}", check=False)
377
+ stdout = result.stdout or ""
378
+ if isinstance(stdout, bytes):
379
+ stdout = stdout.decode("utf-8", errors="replace")
380
+ stderr = result.stderr or ""
381
+ if isinstance(stderr, bytes):
382
+ stderr = stderr.decode("utf-8", errors="replace")
383
+
384
+ if result.exit_status != 0:
385
+ return f"Error reading {file_path}: {stderr}"
386
+
387
+ await ws.send_json({"type": "stdout", "data": stdout})
388
+ return stdout
389
+
390
+ try:
391
+ res = await asyncio.wait_for(run_config_with_timeout(), timeout=30.0)
392
+ if res.startswith("Error reading"):
393
+ return res
394
+ stdout = res
395
+ except asyncio.TimeoutError:
396
+ return f"Error: Reading config timed out after 30 seconds on {server.name}."
397
+
398
+ # Auto-index the config file
399
+ try:
400
+ from app.modules.knowledge.service import index_config
401
+ index_config(server.name, file_path, stdout)
402
+ except Exception:
403
+ pass
404
+
405
+ return f"Config file {file_path} from {server.name}:\n{stdout[:3000]}"
406
+ except Exception as e:
407
+ return f"Error fetching config: {str(e)}"
408
+
409
+
410
+ # Define list of tools available to the Agent
411
+ tools = [
412
+ list_available_servers,
413
+ execute_ssh_command,
414
+ search_knowledge,
415
+ fetch_server_logs,
416
+ fetch_server_config,
417
+ ]
418
+
419
+ # Setup agent prompt template
420
+ system_prompt = """You are a highly capable DevOps AI Assistant managing servers via SSH.
421
+ You have tools to list servers, execute commands, fetch logs/configs, and search past knowledge.
422
+
423
+ IMPORTANT INSTRUCTIONS:
424
+ 1. Always call `list_available_servers` first if you do not know the server_id.
425
+ 2. Use `search_knowledge` to recall past command outputs, logs, or configs before re-running commands.
426
+ 3. Use `fetch_server_logs` to pull and index server logs (journalctl, syslog, etc.).
427
+ 4. Use `fetch_server_config` to read and index config files (nginx, systemd, etc.).
428
+ 5. Any command you execute will be checked by a semantic guardrail.
429
+ 6. If a tool returns 'PAUSED: REQUIRES_APPROVAL: <action_id>', you MUST:
430
+ - STOP all execution immediately. Do not attempt to call any other tools.
431
+ - Reply to the user explaining that the command requires their approval.
432
+ - Explicitly mention the Action ID: '<action_id>'.
433
+ 7. Be concise and report the final outputs clearly.
434
+ """
435
+
436
+ def create_agent_executor(callbacks=None):
437
+ """Initialize agent compiled StateGraph using modern LangChain create_agent."""
438
+ llm = get_llm(callbacks=callbacks)
439
+ return create_agent(
440
+ model=llm,
441
+ tools=tools,
442
+ system_prompt=system_prompt
443
+ )
@@ -0,0 +1,48 @@
1
+ from datetime import datetime, timezone
2
+ from sqlmodel import SQLModel, Field
3
+ from typing import Optional
4
+
5
+ class ChatSession(SQLModel, table=True):
6
+ """
7
+ Database table storing chat sessions/threads created by administrators.
8
+ """
9
+ id: Optional[int] = Field(default=None, primary_key=True)
10
+ title: str = Field(index=True, nullable=False)
11
+ user_id: int = Field(foreign_key="user.id", nullable=False)
12
+ created_at: datetime = Field(
13
+ default_factory=lambda: datetime.now(timezone.utc),
14
+ nullable=False
15
+ )
16
+
17
+ class ChatMessage(SQLModel, table=True):
18
+ """
19
+ Database table storing the historical messages for chat sessions.
20
+ """
21
+ id: Optional[int] = Field(default=None, primary_key=True)
22
+ session_id: int = Field(foreign_key="chatsession.id", nullable=False)
23
+
24
+ # Either "user" or "ai"
25
+ sender: str = Field(index=True, nullable=False)
26
+ content: str = Field(nullable=False)
27
+ created_at: datetime = Field(
28
+ default_factory=lambda: datetime.now(timezone.utc),
29
+ nullable=False
30
+ )
31
+
32
+ class AgentAction(SQLModel, table=True):
33
+ """
34
+ Database table storing target server execution requests generated by the AI
35
+ which require human-in-the-loop validation/approval before being executed.
36
+ """
37
+ # UUID string to make the action approval identifier secure and unguessable
38
+ id: str = Field(primary_key=True)
39
+ session_id: int = Field(foreign_key="chatsession.id", nullable=False)
40
+ server_id: int = Field(foreign_key="server.id", nullable=False)
41
+ command: str = Field(nullable=False)
42
+
43
+ # Status can be: "pending", "approved", or "rejected"
44
+ status: str = Field(default="pending", index=True, nullable=False)
45
+ created_at: datetime = Field(
46
+ default_factory=lambda: datetime.now(timezone.utc),
47
+ nullable=False
48
+ )