devops-copilot 0.1.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 (38) hide show
  1. devops_copilot-0.1.0/PKG-INFO +81 -0
  2. devops_copilot-0.1.0/README.md +55 -0
  3. devops_copilot-0.1.0/app/__init__.py +1 -0
  4. devops_copilot-0.1.0/app/cli/__init__.py +1 -0
  5. devops_copilot-0.1.0/app/cli/config.py +41 -0
  6. devops_copilot-0.1.0/app/cli/main.py +251 -0
  7. devops_copilot-0.1.0/app/core/config.py +19 -0
  8. devops_copilot-0.1.0/app/core/database/__init__.py +24 -0
  9. devops_copilot-0.1.0/app/core/security.py +18 -0
  10. devops_copilot-0.1.0/app/main.py +31 -0
  11. devops_copilot-0.1.0/app/modules/__init__.py +1 -0
  12. devops_copilot-0.1.0/app/modules/auth/__init__.py +9 -0
  13. devops_copilot-0.1.0/app/modules/auth/router.py +101 -0
  14. devops_copilot-0.1.0/app/modules/auth/schema.py +34 -0
  15. devops_copilot-0.1.0/app/modules/auth/service.py +91 -0
  16. devops_copilot-0.1.0/app/modules/chat/__init__.py +1 -0
  17. devops_copilot-0.1.0/app/modules/chat/agent.py +443 -0
  18. devops_copilot-0.1.0/app/modules/chat/models.py +48 -0
  19. devops_copilot-0.1.0/app/modules/chat/router.py +362 -0
  20. devops_copilot-0.1.0/app/modules/chat/schema.py +39 -0
  21. devops_copilot-0.1.0/app/modules/guardrails/__init__.py +1 -0
  22. devops_copilot-0.1.0/app/modules/guardrails/schema.py +22 -0
  23. devops_copilot-0.1.0/app/modules/guardrails/service.py +121 -0
  24. devops_copilot-0.1.0/app/modules/knowledge/__init__.py +1 -0
  25. devops_copilot-0.1.0/app/modules/knowledge/service.py +123 -0
  26. devops_copilot-0.1.0/app/modules/servers/__init__.py +1 -0
  27. devops_copilot-0.1.0/app/modules/servers/models.py +30 -0
  28. devops_copilot-0.1.0/app/modules/servers/router.py +67 -0
  29. devops_copilot-0.1.0/app/modules/servers/schema.py +45 -0
  30. devops_copilot-0.1.0/app/modules/servers/service.py +164 -0
  31. devops_copilot-0.1.0/devops_copilot.egg-info/PKG-INFO +81 -0
  32. devops_copilot-0.1.0/devops_copilot.egg-info/SOURCES.txt +36 -0
  33. devops_copilot-0.1.0/devops_copilot.egg-info/dependency_links.txt +1 -0
  34. devops_copilot-0.1.0/devops_copilot.egg-info/entry_points.txt +2 -0
  35. devops_copilot-0.1.0/devops_copilot.egg-info/requires.txt +19 -0
  36. devops_copilot-0.1.0/devops_copilot.egg-info/top_level.txt +1 -0
  37. devops_copilot-0.1.0/pyproject.toml +43 -0
  38. devops_copilot-0.1.0/setup.cfg +4 -0
@@ -0,0 +1,81 @@
1
+ Metadata-Version: 2.4
2
+ Name: devops-copilot
3
+ Version: 0.1.0
4
+ Summary: An AI-driven DevOps Copilot and CLI Client for managing bare-metal servers securely
5
+ Author: irzix
6
+ Requires-Python: >=3.12
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: typer>=0.12.0
9
+ Requires-Dist: httpx>=0.27.0
10
+ Requires-Dist: websockets>=12.0
11
+ Provides-Extra: server
12
+ Requires-Dist: fastapi>=0.111.0; extra == "server"
13
+ Requires-Dist: uvicorn>=0.30.0; extra == "server"
14
+ Requires-Dist: sqlmodel>=0.0.22; extra == "server"
15
+ Requires-Dist: pyjwt>=2.8.0; extra == "server"
16
+ Requires-Dist: aiosqlite>=0.20.0; extra == "server"
17
+ Requires-Dist: cryptography>=42.0.0; extra == "server"
18
+ Requires-Dist: langchain>=0.2.0; extra == "server"
19
+ Requires-Dist: chromadb>=0.5.0; extra == "server"
20
+ Requires-Dist: pydantic-settings>=2.2.0; extra == "server"
21
+ Requires-Dist: bcrypt>=4.1.0; extra == "server"
22
+ Requires-Dist: python-multipart>=0.0.9; extra == "server"
23
+ Requires-Dist: greenlet>=3.0.0; extra == "server"
24
+ Requires-Dist: langchain-openai>=0.1.0; extra == "server"
25
+ Requires-Dist: asyncssh>=2.14.0; extra == "server"
26
+
27
+ # DevOps-Copilot 🚀
28
+
29
+ An open-source, AI-driven DevOps Copilot and CLI Client designed to manage raw root/bare-metal servers securely. It features persistent credential encryption, real-time terminal streaming, and semantic security guardrails using a local vector database.
30
+
31
+ ---
32
+
33
+ ## Key Features
34
+ - **Lean RAG Knowledge Base:** Automatically chunks and indexes executed SSH command outputs, logs, and server configs into separate **ChromaDB** collections, enabling the agent to search past server history before executing new commands.
35
+ - **Semantic Guardrails:** Uses local vector search to intercept and block dangerous terminal commands.
36
+ - **Human-in-the-Loop (HITL):** Enforces admin approval (`[y/N]`) inside the terminal for any state-modifying actions with clean prompt synchronization.
37
+ - **CLI Connection Resilience:** Automatically reconnects to the WebSocket server using exponential backoff if the network drops or the server restarts.
38
+ - **Real-Time Streaming:** Streams LLM thoughts and active SSH `stdout`/`stderr` line-by-line using WebSockets with 30s execution timeouts.
39
+ - **Encrypted Credentials:** Securely encrypts passwords and SSH private keys using Fernet (AES-256).
40
+ - **Server & Session CRUD:** Full REST API support for updating/deleting server connections and deleting chat sessions (with cascade cleanup).
41
+ - **Flexible AI Models:** Powered by **OpenRouter** (supports Llama 3, Gemini, GPT, etc.).
42
+
43
+ ---
44
+
45
+ ## 📦 Quick Start (Backend Server)
46
+
47
+ ### 1. Configure Settings
48
+ Copy the env file and populate keys:
49
+ ```bash
50
+ cp .env.example .env
51
+ ```
52
+ Make sure to add your `OPENROUTER_API_KEY` and a custom base64 `ENCRYPTION_KEY` in `.env`.
53
+
54
+ ### 2. Run with Docker Compose
55
+ ```bash
56
+ docker compose up -d --build
57
+ ```
58
+ The server will boot on port `8000`. Database tables and security blacklist vectors are automatically seeded on startup.
59
+
60
+ ---
61
+
62
+ ## 💻 Quick Start (CLI Client)
63
+
64
+ ### 1. Install Globally
65
+ Install the package in editable mode from your local repository root:
66
+ ```bash
67
+ uv pip install -e .
68
+ ```
69
+
70
+ ### 2. Authenticate
71
+ Configure the server URL and log in to get your JWT access token:
72
+ ```bash
73
+ devops-copilot login
74
+ ```
75
+
76
+ ### 3. Interactive Chat
77
+ Start the real-time DevOps chat session:
78
+ ```bash
79
+ devops-copilot chat
80
+ ```
81
+ *Ask the agent to check stats or run actions. Approve state-modifying commands directly in the prompt.*
@@ -0,0 +1,55 @@
1
+ # DevOps-Copilot 🚀
2
+
3
+ An open-source, AI-driven DevOps Copilot and CLI Client designed to manage raw root/bare-metal servers securely. It features persistent credential encryption, real-time terminal streaming, and semantic security guardrails using a local vector database.
4
+
5
+ ---
6
+
7
+ ## Key Features
8
+ - **Lean RAG Knowledge Base:** Automatically chunks and indexes executed SSH command outputs, logs, and server configs into separate **ChromaDB** collections, enabling the agent to search past server history before executing new commands.
9
+ - **Semantic Guardrails:** Uses local vector search to intercept and block dangerous terminal commands.
10
+ - **Human-in-the-Loop (HITL):** Enforces admin approval (`[y/N]`) inside the terminal for any state-modifying actions with clean prompt synchronization.
11
+ - **CLI Connection Resilience:** Automatically reconnects to the WebSocket server using exponential backoff if the network drops or the server restarts.
12
+ - **Real-Time Streaming:** Streams LLM thoughts and active SSH `stdout`/`stderr` line-by-line using WebSockets with 30s execution timeouts.
13
+ - **Encrypted Credentials:** Securely encrypts passwords and SSH private keys using Fernet (AES-256).
14
+ - **Server & Session CRUD:** Full REST API support for updating/deleting server connections and deleting chat sessions (with cascade cleanup).
15
+ - **Flexible AI Models:** Powered by **OpenRouter** (supports Llama 3, Gemini, GPT, etc.).
16
+
17
+ ---
18
+
19
+ ## 📦 Quick Start (Backend Server)
20
+
21
+ ### 1. Configure Settings
22
+ Copy the env file and populate keys:
23
+ ```bash
24
+ cp .env.example .env
25
+ ```
26
+ Make sure to add your `OPENROUTER_API_KEY` and a custom base64 `ENCRYPTION_KEY` in `.env`.
27
+
28
+ ### 2. Run with Docker Compose
29
+ ```bash
30
+ docker compose up -d --build
31
+ ```
32
+ The server will boot on port `8000`. Database tables and security blacklist vectors are automatically seeded on startup.
33
+
34
+ ---
35
+
36
+ ## 💻 Quick Start (CLI Client)
37
+
38
+ ### 1. Install Globally
39
+ Install the package in editable mode from your local repository root:
40
+ ```bash
41
+ uv pip install -e .
42
+ ```
43
+
44
+ ### 2. Authenticate
45
+ Configure the server URL and log in to get your JWT access token:
46
+ ```bash
47
+ devops-copilot login
48
+ ```
49
+
50
+ ### 3. Interactive Chat
51
+ Start the real-time DevOps chat session:
52
+ ```bash
53
+ devops-copilot chat
54
+ ```
55
+ *Ask the agent to check stats or run actions. Approve state-modifying commands directly in the prompt.*
@@ -0,0 +1 @@
1
+ # This makes the app folder a Python package
@@ -0,0 +1 @@
1
+ # This makes the app/cli folder a Python package
@@ -0,0 +1,41 @@
1
+ import json
2
+ import os
3
+ from pathlib import Path
4
+ from typing import Dict, Optional
5
+
6
+ CONFIG_DIR = Path.home() / ".config" / "devops-copilot"
7
+ CONFIG_FILE = CONFIG_DIR / "config.json"
8
+ OLD_CONFIG_DIR = Path.home() / ".config" / "devops-rag"
9
+ OLD_CONFIG_FILE = OLD_CONFIG_DIR / "config.json"
10
+
11
+ def load_config() -> Optional[Dict[str, str]]:
12
+ """Loads CLI settings from local configuration file with fallback/migration from old path."""
13
+ if not CONFIG_FILE.exists():
14
+ if OLD_CONFIG_FILE.exists():
15
+ try:
16
+ # Automatically migrate old config to the new directory
17
+ with open(OLD_CONFIG_FILE, "r") as f:
18
+ old_data = json.load(f)
19
+ save_config(old_data["server_url"], old_data["token"])
20
+ except Exception:
21
+ pass
22
+ else:
23
+ return None
24
+ try:
25
+ with open(CONFIG_FILE, "r") as f:
26
+ return json.load(f)
27
+ except Exception:
28
+ return None
29
+
30
+ def save_config(server_url: str, token: str) -> None:
31
+ """Saves CLI settings to the local config.json file in the user's home config directory."""
32
+ try:
33
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
34
+ config_data = {
35
+ "server_url": server_url.rstrip("/"),
36
+ "token": token
37
+ }
38
+ with open(CONFIG_FILE, "w") as f:
39
+ json.dump(config_data, f, indent=4)
40
+ except Exception as e:
41
+ raise RuntimeError(f"Failed to save local config: {str(e)}")
@@ -0,0 +1,251 @@
1
+ import asyncio
2
+ import sys
3
+ import json
4
+ import httpx
5
+ import websockets
6
+ import typer
7
+ from typing import Optional
8
+
9
+ from app.cli.config import load_config, save_config
10
+
11
+ app = typer.Typer(name="devops-copilot", help="AI-driven DevOps Copilot CLI Client")
12
+
13
+ @app.command("login")
14
+ def login(
15
+ server_url: Optional[str] = typer.Option(None, help="The base URL of the FastAPI backend server"),
16
+ username: Optional[str] = typer.Option(None, help="Your administrator username")
17
+ ):
18
+ """
19
+ Authenticate with the central DevOps-Copilot backend and retrieve an access token.
20
+ Saves configurations locally in ~/.config/devops-copilot/config.json.
21
+ """
22
+ if not server_url:
23
+ server_url = typer.prompt("Enter DevOps Agent server URL (e.g. http://localhost:8000)")
24
+ if not username:
25
+ username = typer.prompt("Enter admin username")
26
+ password = typer.prompt("Enter password", hide_input=True)
27
+
28
+ server_url_clean = server_url.rstrip("/")
29
+ token_url = f"{server_url_clean}/api/v1/auth/token"
30
+
31
+ typer.echo(f"Logging in to {server_url_clean}...")
32
+ try:
33
+ response = httpx.post(
34
+ token_url,
35
+ data={"username": username, "password": password},
36
+ timeout=10.0
37
+ )
38
+ try:
39
+ response_json = response.json()
40
+ except (ValueError, TypeError):
41
+ response_json = None
42
+
43
+ if response.status_code == 200:
44
+ if response_json:
45
+ token = response_json.get("access_token")
46
+ save_config(server_url_clean, token)
47
+ typer.secho("Success! Authenticated and configured locally.", fg=typer.colors.GREEN, bold=True)
48
+ else:
49
+ typer.secho("Authentication Failed: Server returned non-JSON response.", fg=typer.colors.RED, err=True)
50
+ raise typer.Exit(code=1)
51
+ else:
52
+ detail = response_json.get("detail") if response_json else f"HTTP Status {response.status_code}: {response.text[:200]}"
53
+ typer.secho(f"Authentication Failed: {detail}", fg=typer.colors.RED, err=True)
54
+ raise typer.Exit(code=1)
55
+ except typer.Exit:
56
+ raise
57
+ except Exception as e:
58
+ typer.secho(f"Connection Error: {str(e)}", fg=typer.colors.RED, err=True)
59
+ raise typer.Exit(code=1)
60
+
61
+ async def run_chat_loop(server_url: str, token: str, session_id: int):
62
+ """Asynchronous WebSocket loop that handles message passing and streams with auto-reconnect."""
63
+ ws_proto = "wss" if server_url.startswith("https") else "ws"
64
+ host_port = server_url.split("://")[-1]
65
+ ws_url = f"{ws_proto}://{host_port}/api/v1/chat/ws?token={token}"
66
+
67
+ reconnect_delay = 1.0
68
+ max_reconnect_delay = 16.0
69
+
70
+ while True:
71
+ try:
72
+ async with websockets.connect(ws_url) as ws:
73
+ reconnect_delay = 1.0 # Reset delay on success
74
+ typer.secho(f"\nConnected to DevOps AI Agent (Session #{session_id})!", fg=typer.colors.GREEN, bold=True)
75
+ typer.secho("Type your prompt and press Enter. Type 'exit' to quit.\n", fg=typer.colors.CYAN)
76
+
77
+ approval_sent = False
78
+ while True:
79
+ if not approval_sent:
80
+ # Read user input from terminal
81
+ user_input = typer.prompt("You")
82
+ if user_input.lower() in ("exit", "quit"):
83
+ typer.secho("Exiting chat session. Goodbye!", fg=typer.colors.YELLOW)
84
+ return
85
+
86
+ if not user_input.strip():
87
+ continue
88
+
89
+ # Send chat message to server using websockets API (send stringified JSON)
90
+ await ws.send(json.dumps({
91
+ "type": "message",
92
+ "session_id": session_id,
93
+ "content": user_input
94
+ }))
95
+
96
+ # Listen to streamed server events
97
+ typer.secho("Agent: ", fg=typer.colors.MAGENTA, nl=False)
98
+
99
+ approval_sent = False # Reset state for this turn
100
+
101
+ while True:
102
+ event = await ws.recv()
103
+ event_data = json.loads(event)
104
+ event_type = event_data.get("type")
105
+
106
+ if event_type == "token":
107
+ # Print thinking tokens as they arrive
108
+ sys.stdout.write(event_data.get("data", ""))
109
+ sys.stdout.flush()
110
+
111
+ elif event_type == "stdout":
112
+ # Stream command output in gray
113
+ sys.stdout.write("\n") # Newline to separate from previous tokens
114
+ typer.secho(event_data.get("data", ""), fg=typer.colors.BRIGHT_BLACK, nl=False)
115
+ sys.stdout.flush()
116
+
117
+ elif event_type == "stderr":
118
+ # Stream stderr in red
119
+ sys.stdout.write("\n")
120
+ typer.secho(event_data.get("data", ""), fg=typer.colors.RED, nl=False)
121
+ sys.stdout.flush()
122
+
123
+ elif event_type == "approval_required":
124
+ # Handle human-in-the-loop validation request
125
+ action_id = event_data.get("action_id")
126
+ server_name = event_data.get("server_name")
127
+ command = event_data.get("command")
128
+
129
+ typer.secho(f"\n\n[Action Approval Required]", fg=typer.colors.YELLOW, bold=True)
130
+ typer.secho(f"Target Server: {server_name}", fg=typer.colors.YELLOW)
131
+ typer.secho(f"Command to Run: {command}", fg=typer.colors.YELLOW, bold=True)
132
+
133
+ approve = typer.confirm("Approve execution of this command?")
134
+
135
+ if approve:
136
+ typer.secho("Approving command execution...", fg=typer.colors.GREEN)
137
+ await ws.send(json.dumps({
138
+ "type": "approve",
139
+ "action_id": action_id
140
+ }))
141
+ else:
142
+ typer.secho("Rejecting command execution.", fg=typer.colors.RED)
143
+ await ws.send(json.dumps({
144
+ "type": "reject",
145
+ "action_id": action_id
146
+ }))
147
+ approval_sent = True
148
+
149
+ elif event_type == "finished":
150
+ # Output generation is completed
151
+ # Print newline and final summary response if not fully printed
152
+ sys.stdout.write("\n\n")
153
+ break
154
+
155
+ elif event_type == "error":
156
+ typer.secho(f"\nError: {event_data.get('data')}", fg=typer.colors.RED, err=True)
157
+ break
158
+
159
+ except (websockets.exceptions.ConnectionClosed, ConnectionRefusedError, OSError) as e:
160
+ typer.secho(f"\nConnection lost ({str(e)}). Reconnecting in {reconnect_delay}s...", fg=typer.colors.YELLOW)
161
+ await asyncio.sleep(reconnect_delay)
162
+ reconnect_delay = min(reconnect_delay * 2, max_reconnect_delay)
163
+ except Exception as e:
164
+ typer.secho(f"\nWebSocket Error: {str(e)}", fg=typer.colors.RED, err=True)
165
+ break
166
+
167
+ @app.command("chat")
168
+ def chat():
169
+ """
170
+ Start an interactive chat session with the AI DevOps agent in the terminal.
171
+ Allows selecting historical threads or spawning new sessions.
172
+ """
173
+ config = load_config()
174
+ if not config:
175
+ typer.secho("Error: No configuration found. Please run 'devops-copilot login' first.", fg=typer.colors.RED, err=True)
176
+ raise typer.Exit(code=1)
177
+
178
+ server_url = config["server_url"]
179
+ token = config["token"]
180
+ headers = {"Authorization": f"Bearer {token}"}
181
+
182
+ # 1. Fetch existing chat sessions
183
+ typer.echo("Fetching chat sessions from server...")
184
+ try:
185
+ response = httpx.get(f"{server_url}/api/v1/chat/sessions", headers=headers, timeout=10.0)
186
+ if response.status_code == 401:
187
+ typer.secho("Session expired. Please run 'devops-copilot login' again.", fg=typer.colors.RED, err=True)
188
+ raise typer.Exit(code=1)
189
+ response.raise_for_status()
190
+ try:
191
+ sessions = response.json()
192
+ except (ValueError, TypeError):
193
+ typer.secho(f"Failed to fetch sessions: Server returned non-JSON response (HTTP {response.status_code}).", fg=typer.colors.RED, err=True)
194
+ raise typer.Exit(code=1)
195
+ except typer.Exit:
196
+ raise
197
+ except Exception as e:
198
+ typer.secho(f"Failed to fetch sessions: {str(e)}", fg=typer.colors.RED, err=True)
199
+ raise typer.Exit(code=1)
200
+
201
+ # 2. Let the user choose or spawn a new session
202
+ session_id = None
203
+ if sessions:
204
+ typer.echo("\nAvailable chat sessions:")
205
+ session_ids = []
206
+ for s in sessions:
207
+ typer.echo(f" Session ID: {s['id']} | Title: {s['title']}")
208
+ session_ids.append(s['id'])
209
+
210
+ choice = typer.prompt("\nSelect Session ID, or type 'new' to start a new chat")
211
+
212
+ if choice.lower() != "new":
213
+ try:
214
+ val = int(choice)
215
+ if val in session_ids:
216
+ session_id = val
217
+ else:
218
+ typer.secho("Invalid Session ID.", fg=typer.colors.RED)
219
+ raise typer.Exit(code=1)
220
+ except ValueError:
221
+ typer.secho("Invalid input format.", fg=typer.colors.RED)
222
+ raise typer.Exit(code=1)
223
+
224
+ if session_id is None:
225
+ # Create a new session
226
+ title = typer.prompt("Enter title for the new chat session")
227
+ try:
228
+ res = httpx.post(
229
+ f"{server_url}/api/v1/chat/sessions",
230
+ headers=headers,
231
+ json={"title": title},
232
+ timeout=10.0
233
+ )
234
+ res.raise_for_status()
235
+ try:
236
+ new_sess = res.json()
237
+ session_id = new_sess["id"]
238
+ except (ValueError, TypeError, KeyError):
239
+ typer.secho(f"Failed to create new session: Server returned invalid format.", fg=typer.colors.RED, err=True)
240
+ raise typer.Exit(code=1)
241
+ except typer.Exit:
242
+ raise
243
+ except Exception as e:
244
+ typer.secho(f"Failed to create new session: {str(e)}", fg=typer.colors.RED, err=True)
245
+ raise typer.Exit(code=1)
246
+
247
+ # 3. Enter the async WebSocket execution loop
248
+ asyncio.run(run_chat_loop(server_url, token, session_id))
249
+
250
+ if __name__ == "__main__":
251
+ app()
@@ -0,0 +1,19 @@
1
+ from pydantic_settings import BaseSettings, SettingsConfigDict
2
+
3
+ class Settings(BaseSettings):
4
+ DATABASE_URL: str = "sqlite+aiosqlite:///data/devops_rag.db"
5
+
6
+ JWT_SECRET_KEY: str = "supersecretkey_change_me_in_production"
7
+ JWT_ALGORITHM: str = "HS256"
8
+ ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
9
+
10
+ # Key for encrypting target server credentials (SSH passwords/private keys)
11
+ ENCRYPTION_KEY: str = "h-0rAgfuXQnNinQ9yZevWRvRNv9_nWdixhHG_DZGmoE="
12
+
13
+ # OpenRouter API configurations for AI Agent
14
+ OPENROUTER_API_KEY: str | None = None
15
+ OPENROUTER_MODEL: str = "google/gemini-2.5-flash"
16
+
17
+ model_config = SettingsConfigDict(env_file=".env", extra="ignore")
18
+
19
+ settings = Settings()
@@ -0,0 +1,24 @@
1
+ from sqlalchemy.ext.asyncio import create_async_engine
2
+ from sqlmodel.ext.asyncio.session import AsyncSession
3
+ from sqlalchemy.orm import sessionmaker
4
+ from sqlmodel import SQLModel
5
+ from typing import AsyncGenerator
6
+ from app.core.config import settings
7
+
8
+ # create async engine
9
+ engine = create_async_engine(settings.DATABASE_URL, echo=True)
10
+
11
+ # create async session maker
12
+ async_session_maker = sessionmaker(
13
+ engine, class_=AsyncSession, expire_on_commit=False
14
+ )
15
+
16
+ # create async db and tables
17
+ async def create_db_and_tables():
18
+ async with engine.begin() as conn:
19
+ await conn.run_sync(SQLModel.metadata.create_all)
20
+
21
+ # create async session dependency
22
+ async def get_session() -> AsyncGenerator[AsyncSession, None]:
23
+ async with async_session_maker() as session:
24
+ yield session
@@ -0,0 +1,18 @@
1
+ from cryptography.fernet import Fernet
2
+ from app.core.config import settings
3
+
4
+ # Initialize symmetric Fernet encryption with key from config
5
+ fernet = Fernet(settings.ENCRYPTION_KEY.encode())
6
+
7
+ def encrypt_data(data: str) -> str:
8
+ """
9
+ Encrypt sensitive data (e.g. passwords, SSH keys) using AES-128/256.
10
+ Returns a URL-safe base64-encoded string.
11
+ """
12
+ return fernet.encrypt(data.encode()).decode()
13
+
14
+ def decrypt_data(encrypted_data: str) -> str:
15
+ """
16
+ Decrypt base64-encoded cipher text back into plain text.
17
+ """
18
+ return fernet.decrypt(encrypted_data.encode()).decode()
@@ -0,0 +1,31 @@
1
+ from fastapi import FastAPI
2
+ from contextlib import asynccontextmanager
3
+
4
+ from app.core.database import create_db_and_tables
5
+ from app.modules.auth.router import router as auth_router
6
+ from app.modules.servers.router import router as servers_router
7
+ from app.modules.guardrails.service import init_and_seed_db
8
+ from app.modules.chat.router import router as chat_router
9
+
10
+ @asynccontextmanager
11
+ async def lifespan(app: FastAPI):
12
+ # Create DB tables on startup
13
+ await create_db_and_tables()
14
+ # Initialize and seed ChromaDB local vector store
15
+ await init_and_seed_db()
16
+ yield
17
+
18
+ app = FastAPI(
19
+ title="DevOps-Copilot API",
20
+ description="An AI-driven DevOps management copilot for bare-metal servers.",
21
+ version="0.1.0",
22
+ lifespan=lifespan
23
+ )
24
+
25
+ app.include_router(auth_router, prefix="/api/v1/auth", tags=["Authentication"])
26
+ app.include_router(servers_router, prefix="/api/v1/servers", tags=["Servers"])
27
+ app.include_router(chat_router, prefix="/api/v1/chat", tags=["Chat & Agent"])
28
+
29
+ @app.get("/health", tags=["Health"])
30
+ def health_check():
31
+ return {"status": "ok"}
@@ -0,0 +1 @@
1
+ # This makes modules folder a Python package
@@ -0,0 +1,9 @@
1
+ from sqlmodel import SQLModel, Field
2
+ from typing import Optional
3
+
4
+ # user model
5
+ class User(SQLModel, table=True):
6
+ id: Optional[int] = Field(default=None, primary_key=True)
7
+ username: str = Field(index=True, unique=True, nullable=False)
8
+ hashed_password: str = Field(nullable=False)
9
+ is_active: bool = Field(default=True)
@@ -0,0 +1,101 @@
1
+ from fastapi import APIRouter, Depends, HTTPException, status
2
+ from fastapi.security import OAuth2PasswordRequestForm
3
+ from sqlalchemy.ext.asyncio import AsyncSession
4
+ from sqlmodel import select
5
+
6
+ from app.core.database import get_session
7
+ from app.modules.auth import User
8
+ from app.modules.auth.schema import UserRegister, UserResponse, Token
9
+ from app.modules.auth.service import (
10
+ hash_password,
11
+ verify_password,
12
+ create_access_token,
13
+ get_current_user
14
+ )
15
+
16
+ router = APIRouter()
17
+
18
+ # Register API
19
+ @router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
20
+ async def register(
21
+ user_in: UserRegister,
22
+ session: AsyncSession = Depends(get_session)
23
+ ):
24
+ """
25
+ Register the Master Administrator account.
26
+
27
+ This is a personal app, meaning registration is strictly restricted and locked down
28
+ after the first successful registration has occurred.
29
+ """
30
+ # check if any user already exists in the database
31
+ any_user_statement = select(User)
32
+ any_user_result = await session.exec(any_user_statement)
33
+ if any_user_result.first() is not None:
34
+ raise HTTPException(
35
+ status_code=status.HTTP_403_FORBIDDEN,
36
+ detail="Registration is disabled. Admin user already exists."
37
+ )
38
+
39
+ # checking if username is already taken
40
+ statement = select(User).where(User.username == user_in.username)
41
+ result = await session.exec(statement)
42
+ existing_user = result.first()
43
+
44
+ if existing_user:
45
+ raise HTTPException(
46
+ status_code=status.HTTP_400_BAD_REQUEST,
47
+ detail="Username already registered"
48
+ )
49
+
50
+ # hashing the password and creating a new user object
51
+ hashed_pw = hash_password(user_in.password)
52
+ new_user = User(username=user_in.username, hashed_password=hashed_pw)
53
+
54
+ # saving in database (asynchronously)
55
+ session.add(new_user)
56
+ await session.commit()
57
+ await session.refresh(new_user)
58
+
59
+ return new_user
60
+
61
+ # Token API
62
+ @router.post("/token", response_model=Token)
63
+ async def login(
64
+ form_data: OAuth2PasswordRequestForm = Depends(),
65
+ session: AsyncSession = Depends(get_session)
66
+ ):
67
+ """
68
+ OAuth2-compatible token exchange endpoint.
69
+
70
+ Accepts form data containing username and password, authenticates credentials,
71
+ and returns a stateless JWT token.
72
+ """
73
+ # finding user in database
74
+ statement = select(User).where(User.username == form_data.username)
75
+ result = await session.exec(statement)
76
+ user = result.first()
77
+
78
+ # checking username and password
79
+ if not user or not verify_password(form_data.password, user.hashed_password):
80
+ raise HTTPException(
81
+ status_code=status.HTTP_401_UNAUTHORIZED,
82
+ detail="Incorrect username or password",
83
+ headers={"WWW-Authenticate": "Bearer"},
84
+ )
85
+
86
+ # creating token for user
87
+ access_token = create_access_token(data={"sub": user.username})
88
+
89
+ return {"access_token": access_token, "token_type": "bearer"}
90
+
91
+ # Get User API
92
+ @router.get("/me", response_model=UserResponse)
93
+ async def read_users_me(
94
+ current_user: User = Depends(get_current_user)
95
+ ):
96
+ """
97
+ Retrieve currently logged-in administrator profile details.
98
+
99
+ Requires a valid JWT Bearer token in the Authorization header.
100
+ """
101
+ return current_user