Lab objective
Students will build a browser-based AI assistant that can answer questions about:
- AWS
- Linux
- Git
- Docker
- Kubernetes
- Terraform
- CI/CD
- Helm
- Argo CD
- Monitoring
- Networking
- Security
The application will include:
Browser frontend
↓
FastAPI backend
↓
OpenAI API
↓
Large Language Model
↓
DevOps answer
Students will learn:
- How an application communicates with an LLM
- How system instructions control model behavior
- How prompts are sent from a frontend
- How to keep API keys secure
- How to create REST API endpoints
- How to maintain short conversation history
- How to validate requests
- How to test an AI application
- How to package the application with Docker
1. Final project structure
fde-devops-ai-assistant/
├── app/
│ ├── static/
│ │ ├── index.html
│ │ ├── app.js
│ │ └── styles.css
│ ├── __init__.py
│ ├── config.py
│ ├── main.py
│ ├── models.py
│ └── services.py
├── tests/
│ ├── __init__.py
│ └── test_api.py
├── .dockerignore
├── .env.example
├── .gitignore
├── Dockerfile
├── Makefile
├── README.md
└── requirements.txt
2. Create the project
Open Terminal.
mkdir fde-devops-ai-assistant
cd fde-devops-ai-assistant
Create the folders:
mkdir -p app/static
mkdir -p tests
Create the files:
touch app/__init__.py
touch app/config.py
touch app/main.py
touch app/models.py
touch app/services.py
touch app/static/index.html
touch app/static/app.js
touch app/static/styles.css
touch tests/__init__.py
touch tests/test_api.py
touch requirements.txt
touch .env.example
touch .gitignore
touch .dockerignore
touch Dockerfile
touch Makefile
touch README.md
Check the structure:
find . -maxdepth 3 -type f
3. Create requirements.txt
fastapi>=0.115,<1.0
uvicorn[standard]>=0.34,<1.0
openai>=1.65,<3.0
python-dotenv>=1.0,<2.0
pydantic-settings>=2.7,<3.0
pytest>=8.3,<9.0
httpx>=0.28,<1.0
Explanation
fastapi creates the REST API.
uvicorn runs the FastAPI application.
openai connects the application to the OpenAI API.
python-dotenv allows local environment variables to be loaded from .env.
pydantic-settings validates configuration.
pytest runs automated tests.
httpx is used by FastAPI testing tools.
4. Create .env.example
OPENAI_API_KEY=replace_with_your_api_key
OPENAI_MODEL=gpt-5
MAX_HISTORY_MESSAGES=10
Students will copy this file to .env.
The .env.example file can be committed to Git because it does not contain a real secret.
The .env file must never be committed.
5. Create .gitignore
.venv/
__pycache__/
*.py[cod]
.pytest_cache/
.env
.DS_Store
coverage.xml
htmlcov/
6. Create .dockerignore
.venv
.git
.gitignore
.env
__pycache__
.pytest_cache
tests
*.pyc
.DS_Store
This prevents unnecessary or sensitive files from being copied into the Docker image.
7. Create app/config.py
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""
Application configuration.
Values are loaded from environment variables or from a local .env file.
"""
openai_api_key: str = ""
openai_model: str = "gpt-5"
max_history_messages: int = 10
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
@lru_cache
def get_settings() -> Settings:
"""
Return a cached Settings object.
Caching prevents the application from repeatedly reading the environment
file for every request.
"""
return Settings()
What this file does
This file manages the application configuration.
The application expects these environment variables:
OPENAI_API_KEY
OPENAI_MODEL
MAX_HISTORY_MESSAGES
Pydantic automatically converts environment variable names such as:
OPENAI_API_KEY
into the Python field:
openai_api_key
8. Create app/models.py
from pydantic import BaseModel, Field, field_validator
class ChatRequest(BaseModel):
"""
Request body sent by the browser to POST /api/chat.
"""
session_id: str = Field(
min_length=1,
max_length=100,
)
message: str = Field(
min_length=1,
max_length=4000,
)
@field_validator("session_id", "message")
@classmethod
def strip_whitespace(cls, value: str) -> str:
"""
Remove whitespace from the beginning and end.
Reject values that contain only spaces.
"""
cleaned = value.strip()
if not cleaned:
raise ValueError("Value must not be empty.")
return cleaned
class ChatResponse(BaseModel):
"""
Response returned by POST /api/chat.
"""
session_id: str
answer: str
model: str
history_messages: int
class HealthResponse(BaseModel):
"""
Response returned by GET /api/health.
"""
status: str
service: str
model: str
class DeleteHistoryResponse(BaseModel):
"""
Response returned by DELETE /api/history/{session_id}.
"""
session_id: str
deleted: bool
Why models are important
Models validate incoming and outgoing data.
For example, this request will be rejected because the message is empty:
{
"session_id": "student-1",
"message": ""
}
This request will also be rejected:
{
"session_id": "",
"message": "Explain Docker."
}
FastAPI automatically returns HTTP status code 422 when validation fails.
9. Create app/services.py
import logging
from collections import defaultdict
from threading import Lock
from typing import Protocol
from openai import OpenAI
from app.config import Settings
logger = logging.getLogger(__name__)
SYSTEM_INSTRUCTIONS = """
You are a Senior DevOps Engineer and patient technical instructor.
Your responsibilities:
1. Answer questions about Linux, Git, Docker, CI/CD, AWS, Terraform,
Kubernetes, Helm, Argo CD, monitoring, networking, reliability,
cloud infrastructure, and security.
2. Begin with a direct and simple explanation.
3. Use beginner-friendly language first, then add technical depth.
4. Include commands, YAML, Terraform, Docker, or configuration examples
when they are useful.
5. Clearly label commands that can change, restart, or delete infrastructure.
6. Never claim that you executed a command.
7. Never invent logs, metrics, deployment results, AWS resources,
Kubernetes resources, or monitoring data.
8. When information is missing, explain exactly what the student should inspect.
9. When uncertain, clearly say that verification is required.
10. Keep the response focused on the student's question.
11. For troubleshooting questions, organize the answer using:
- What the problem means
- Most likely causes
- Commands to run
- How to interpret the output
- Safe next steps
12. Warn the student before providing destructive commands such as:
- kubectl delete
- terraform destroy
- aws resource deletion commands
- database deletion commands
""".strip()
class AIService(Protocol):
"""
Interface used by the FastAPI application.
Using a protocol makes it easy to replace the real OpenAI service
with a fake service during tests.
"""
def answer(
self,
session_id: str,
message: str,
) -> tuple[str, int]:
...
def clear_history(
self,
session_id: str,
) -> bool:
...
class OpenAIDevOpsService:
"""
OpenAI-backed DevOps assistant.
Conversation history is stored in memory.
This approach is acceptable for a classroom lab but is not appropriate
for a production application with multiple replicas.
"""
def __init__(self, settings: Settings):
if not settings.openai_api_key:
raise ValueError(
"OPENAI_API_KEY is missing. "
"Copy .env.example to .env and add a valid API key."
)
self.settings = settings
self.client = OpenAI(
api_key=settings.openai_api_key
)
self._history: dict[
str,
list[dict[str, str]]
] = defaultdict(list)
self._lock = Lock()
def answer(
self,
session_id: str,
message: str,
) -> tuple[str, int]:
"""
Send the user's message and recent conversation history to the model.
Returns:
tuple:
- generated answer
- number of messages stored in history
"""
with self._lock:
previous_messages = list(
self._history[session_id]
)
model_input = previous_messages + [
{
"role": "user",
"content": message,
}
]
try:
response = self.client.responses.create(
model=self.settings.openai_model,
instructions=SYSTEM_INSTRUCTIONS,
input=model_input,
)
except Exception:
logger.exception(
"OpenAI request failed for session %s",
session_id,
)
raise
answer = response.output_text.strip()
if not answer:
answer = (
"The model returned an empty response. "
"Please try again."
)
with self._lock:
history = self._history[session_id]
history.extend(
[
{
"role": "user",
"content": message,
},
{
"role": "assistant",
"content": answer,
},
]
)
max_messages = max(
2,
self.settings.max_history_messages,
)
if len(history) > max_messages:
self._history[session_id] = history[
-max_messages:
]
history_count = len(
self._history[session_id]
)
return answer, history_count
def clear_history(
self,
session_id: str,
) -> bool:
"""
Delete history for one browser session.
Returns True when the session existed.
"""
with self._lock:
existed = session_id in self._history
self._history.pop(session_id, None)
return existed
10. Understanding the service
The most important code is:
response = self.client.responses.create(
model=self.settings.openai_model,
instructions=SYSTEM_INSTRUCTIONS,
input=model_input,
)
The model specifies which model to use.
The instructions define how the assistant should behave.
The input contains the current user request and conversation history.
The model returns text through:
response.output_text
11. How conversation history works
Suppose the first user message is:
What is Kubernetes?
The history becomes:
[
{
"role": "user",
"content": "What is Kubernetes?"
},
{
"role": "assistant",
"content": "Kubernetes is a container orchestration platform..."
}
]
The user then asks:
How does it perform scaling?
The application sends the previous conversation plus the new question:
[
{
"role": "user",
"content": "What is Kubernetes?"
},
{
"role": "assistant",
"content": "Kubernetes is a container orchestration platform..."
},
{
"role": "user",
"content": "How does it perform scaling?"
}
]
The model understands that the word it refers to Kubernetes.
12. Create app/main.py
import logging
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import (
Depends,
FastAPI,
HTTPException,
Request,
status,
)
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.config import Settings, get_settings
from app.models import (
ChatRequest,
ChatResponse,
DeleteHistoryResponse,
HealthResponse,
)
from app.services import (
AIService,
OpenAIDevOpsService,
)
logging.basicConfig(
level=logging.INFO,
format=(
"%(asctime)s "
"%(levelname)s "
"%(name)s "
"%(message)s"
),
)
logger = logging.getLogger(__name__)
STATIC_DIR = Path(__file__).parent / "static"
def create_ai_service(
settings: Settings,
) -> AIService:
"""
Create the real OpenAI service.
Keeping service creation in a separate function makes the application
easier to test and extend.
"""
return OpenAIDevOpsService(settings)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""
Run application startup and shutdown logic.
The AI service is created once when the application starts.
"""
settings = get_settings()
try:
app.state.ai_service = create_ai_service(
settings
)
logger.info(
"AI service initialized with model %s",
settings.openai_model,
)
except ValueError as exc:
app.state.ai_service = None
logger.warning(
"AI service is not configured: %s",
exc,
)
yield
app = FastAPI(
title="FDE DevOps AI Assistant",
description=(
"A student lab demonstrating a "
"production-shaped LLM application."
),
version="1.0.0",
lifespan=lifespan,
)
app.mount(
"/static",
StaticFiles(directory=STATIC_DIR),
name="static",
)
def get_ai_service(
request: Request,
) -> AIService:
"""
Dependency that retrieves the initialized AI service.
"""
service = getattr(
request.app.state,
"ai_service",
None,
)
if service is None:
raise HTTPException(
status_code=(
status.HTTP_503_SERVICE_UNAVAILABLE
),
detail=(
"AI service is not configured. "
"Set OPENAI_API_KEY and restart "
"the application."
),
)
return service
@app.get(
"/",
include_in_schema=False,
)
def home() -> FileResponse:
"""
Return the browser frontend.
"""
return FileResponse(
STATIC_DIR / "index.html"
)
@app.get(
"/api/health",
response_model=HealthResponse,
)
def health(
settings: Settings = Depends(get_settings),
) -> HealthResponse:
"""
Application health endpoint.
This endpoint does not call the OpenAI API.
"""
return HealthResponse(
status="UP",
service="devops-ai-assistant",
model=settings.openai_model,
)
@app.post(
"/api/chat",
response_model=ChatResponse,
)
def chat(
payload: ChatRequest,
service: AIService = Depends(
get_ai_service
),
settings: Settings = Depends(
get_settings
),
) -> ChatResponse:
"""
Send a user question to the DevOps AI assistant.
"""
try:
answer, history_count = service.answer(
session_id=payload.session_id,
message=payload.message,
)
except HTTPException:
raise
except Exception as exc:
logger.exception(
"Chat request failed"
)
raise HTTPException(
status_code=(
status.HTTP_502_BAD_GATEWAY
),
detail=(
"The AI provider request failed. "
"Check the server logs."
),
) from exc
return ChatResponse(
session_id=payload.session_id,
answer=answer,
model=settings.openai_model,
history_messages=history_count,
)
@app.delete(
"/api/history/{session_id}",
response_model=DeleteHistoryResponse,
)
def delete_history(
session_id: str,
service: AIService = Depends(
get_ai_service
),
) -> DeleteHistoryResponse:
"""
Delete one session's conversation history.
"""
deleted = service.clear_history(
session_id
)
return DeleteHistoryResponse(
session_id=session_id,
deleted=deleted,
)
13. API endpoints
The application has four endpoints.
Home page
GET /
Returns the browser interface.
Health check
GET /api/health
Example response:
{
"status": "UP",
"service": "devops-ai-assistant",
"model": "gpt-5"
}
Chat endpoint
POST /api/chat
Request:
{
"session_id": "student-1",
"message": "Explain Docker."
}
Response:
{
"session_id": "student-1",
"answer": "Docker is a platform used to package applications...",
"model": "gpt-5",
"history_messages": 2
}
Delete conversation history
DELETE /api/history/student-1
Response:
{
"session_id": "student-1",
"deleted": true
}
14. Create app/static/index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
>
<title>DevOps AI Assistant</title>
<link
rel="stylesheet"
href="/static/styles.css"
>
</head>
<body>
<main class="app-shell">
<header class="hero">
<div>
<p class="eyebrow">
FDE LAB 1
</p>
<h1>
DevOps AI Assistant
</h1>
<p class="subtitle">
Ask questions about AWS, Docker,
Kubernetes, Terraform, CI/CD,
monitoring, Linux, and Git.
</p>
</div>
<button
id="clearButton"
class="secondary-button"
type="button"
>
Clear history
</button>
</header>
<section
class="status-bar"
aria-live="polite"
>
<span
id="statusDot"
class="status-dot"
></span>
<span id="statusText">
Checking API health...
</span>
<span id="modelText"></span>
</section>
<section
id="messages"
class="messages"
aria-live="polite"
>
<article class="message assistant">
<div class="message-label">
Assistant
</div>
<div class="message-content">
Welcome. Ask me a DevOps question.
For example: “Why is my Kubernetes
Pod in CrashLoopBackOff?”
</div>
</article>
</section>
<form
id="chatForm"
class="composer"
>
<label for="messageInput">
Your question
</label>
<textarea
id="messageInput"
rows="4"
maxlength="4000"
placeholder="Explain the difference between readiness and liveness probes..."
required
></textarea>
<div class="composer-footer">
<span id="characterCount">
0 / 4000
</span>
<button
id="sendButton"
type="submit"
>
Ask assistant
</button>
</div>
</form>
</main>
<script src="/static/app.js"></script>
</body>
</html>
15. Create app/static/app.js
const chatForm =
document.getElementById("chatForm");
const messageInput =
document.getElementById("messageInput");
const messages =
document.getElementById("messages");
const sendButton =
document.getElementById("sendButton");
const clearButton =
document.getElementById("clearButton");
const characterCount =
document.getElementById("characterCount");
const statusText =
document.getElementById("statusText");
const statusDot =
document.getElementById("statusDot");
const modelText =
document.getElementById("modelText");
const sessionId =
localStorage.getItem("fde-session-id") ||
(
crypto.randomUUID
? crypto.randomUUID()
: `session-${Date.now()}`
);
localStorage.setItem(
"fde-session-id",
sessionId
);
function addMessage(role, text) {
const article =
document.createElement("article");
article.className =
`message ${role}`;
const label =
document.createElement("div");
label.className =
"message-label";
label.textContent =
role === "user"
? "You"
: "Assistant";
const content =
document.createElement("div");
content.className =
"message-content";
content.textContent = text;
article.append(
label,
content
);
messages.appendChild(article);
messages.scrollTop =
messages.scrollHeight;
return article;
}
function setLoading(isLoading) {
sendButton.disabled =
isLoading;
messageInput.disabled =
isLoading;
sendButton.textContent =
isLoading
? "Thinking..."
: "Ask assistant";
}
async function checkHealth() {
try {
const response =
await fetch("/api/health");
if (!response.ok) {
throw new Error(
`Health check returned ${response.status}`
);
}
const data =
await response.json();
statusDot.classList.add(
"healthy"
);
statusText.textContent =
`${data.service} is ${data.status}`;
modelText.textContent =
`Model: ${data.model}`;
} catch (error) {
statusDot.classList.add(
"unhealthy"
);
statusText.textContent =
"API health check failed";
modelText.textContent = "";
}
}
messageInput.addEventListener(
"input",
() => {
characterCount.textContent =
`${messageInput.value.length} / 4000`;
}
);
chatForm.addEventListener(
"submit",
async (event) => {
event.preventDefault();
const message =
messageInput.value.trim();
if (!message) {
return;
}
addMessage(
"user",
message
);
messageInput.value = "";
characterCount.textContent =
"0 / 4000";
setLoading(true);
const pending = addMessage(
"assistant",
"Thinking..."
);
try {
const response = await fetch(
"/api/chat",
{
method: "POST",
headers: {
"Content-Type":
"application/json",
},
body: JSON.stringify({
session_id: sessionId,
message: message,
}),
}
);
const data =
await response.json();
if (!response.ok) {
const detail =
typeof data.detail === "string"
? data.detail
: "The request failed.";
throw new Error(detail);
}
pending
.querySelector(".message-content")
.textContent = data.answer;
modelText.textContent =
`Model: ${data.model} | ` +
`History messages: ${data.history_messages}`;
} catch (error) {
pending
.querySelector(".message-content")
.textContent =
`Error: ${error.message}`;
pending.classList.add(
"error"
);
} finally {
setLoading(false);
messageInput.focus();
}
}
);
clearButton.addEventListener(
"click",
async () => {
try {
const response = await fetch(
`/api/history/${
encodeURIComponent(sessionId)
}`,
{
method: "DELETE",
}
);
if (!response.ok) {
throw new Error(
"Could not clear history."
);
}
messages.innerHTML = "";
addMessage(
"assistant",
"Conversation history was cleared. " +
"Start a new question."
);
modelText.textContent = "";
} catch (error) {
addMessage(
"assistant",
`Error: ${error.message}`
);
}
}
);
checkHealth();
messageInput.focus();
16. How the frontend works
When the user enters a question, JavaScript sends this request:
fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
session_id: sessionId,
message: message
})
});
The browser does not contain the OpenAI API key.
This is important.
The secure flow is:
Browser
↓
Our backend
↓
OpenAI API
The insecure flow would be:
Browser containing secret key
↓
OpenAI API
Anyone can inspect browser JavaScript. Therefore, secret keys must remain on the backend.
17. Create app/static/styles.css
:root {
color-scheme: light;
font-family:
Inter,
ui-sans-serif,
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: #f4f7fb;
color: #172033;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(
circle at top left,
#dbeafe 0,
transparent 35%
),
#f4f7fb;
}
button,
textarea {
font: inherit;
}
.app-shell {
width: min(
960px,
calc(100% - 32px)
);
margin: 32px auto;
}
.hero {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: flex-start;
padding: 28px;
background: #ffffff;
border:
1px solid #dbe3ef;
border-radius:
20px 20px 0 0;
}
.eyebrow {
margin:
0 0 8px;
font-size: 0.76rem;
font-weight: 800;
letter-spacing: 0.16em;
color: #3159a6;
}
h1 {
margin: 0;
font-size:
clamp(
2rem,
5vw,
3.25rem
);
line-height: 1;
}
.subtitle {
max-width: 680px;
margin:
14px 0 0;
color: #526078;
line-height: 1.6;
}
.status-bar {
display: flex;
gap: 10px;
align-items: center;
min-height: 48px;
padding:
0 28px;
background: #f8fafc;
border-right:
1px solid #dbe3ef;
border-left:
1px solid #dbe3ef;
color: #526078;
font-size: 0.9rem;
}
#modelText {
margin-left: auto;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #9ca3af;
}
.status-dot.healthy {
background: #16a34a;
}
.status-dot.unhealthy {
background: #dc2626;
}
.messages {
height: 460px;
overflow-y: auto;
padding: 28px;
background: #ffffff;
border:
1px solid #dbe3ef;
}
.message {
max-width: 82%;
margin-bottom: 22px;
}
.message.user {
margin-left: auto;
}
.message-label {
margin-bottom: 6px;
font-size: 0.78rem;
font-weight: 800;
color: #526078;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.message-content {
padding:
15px 17px;
border-radius: 16px;
line-height: 1.6;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.message.assistant
.message-content {
background: #eef3fb;
}
.message.user
.message-content {
background: #172033;
color: #ffffff;
}
.message.error
.message-content {
background: #fee2e2;
color: #991b1b;
}
.composer {
padding:
24px 28px 28px;
background: #ffffff;
border:
1px solid #dbe3ef;
border-top: 0;
border-radius:
0 0 20px 20px;
}
.composer label {
display: block;
margin-bottom: 8px;
font-weight: 700;
}
textarea {
width: 100%;
resize: vertical;
min-height: 100px;
padding: 14px;
border:
1px solid #b9c5d8;
border-radius: 12px;
outline: none;
}
textarea:focus {
border-color: #3159a6;
box-shadow:
0 0 0 3px
rgba(49, 89, 166, 0.14);
}
.composer-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-top: 14px;
}
#characterCount {
color: #667085;
font-size: 0.85rem;
}
button {
border: 0;
border-radius: 10px;
cursor: pointer;
font-weight: 800;
}
#sendButton {
padding:
12px 20px;
background: #3159a6;
color: #ffffff;
}
#sendButton:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.secondary-button {
padding:
10px 14px;
background: #e8eef8;
color: #243b68;
white-space: nowrap;
}
@media (
max-width: 700px
) {
.app-shell {
width: 100%;
margin: 0;
}
.hero {
flex-direction: column;
border-radius: 0;
}
.messages {
height: 52vh;
}
.composer {
border-radius: 0;
}
#modelText {
display: none;
}
}
18. Create app/__init__.py
Leave this file empty:
Its presence tells Python that app is a Python package.
19. Create tests/__init__.py
Leave this file empty:
20. Create tests/test_api.py
from fastapi.testclient import TestClient
from app.main import (
app,
get_ai_service,
)
class FakeAIService:
"""
Fake AI service used during testing.
It does not call OpenAI and does not consume API credits.
"""
def __init__(self):
self.history: dict[
str,
list[str]
] = {}
def answer(
self,
session_id: str,
message: str,
) -> tuple[str, int]:
self.history.setdefault(
session_id,
[],
).extend(
[
message,
f"Mock answer: {message}",
]
)
return (
f"Mock answer: {message}",
len(self.history[session_id]),
)
def clear_history(
self,
session_id: str,
) -> bool:
existed = (
session_id in self.history
)
self.history.pop(
session_id,
None,
)
return existed
fake_service = FakeAIService()
def override_ai_service():
return fake_service
app.dependency_overrides[
get_ai_service
] = override_ai_service
client = TestClient(app)
def test_health_endpoint():
response = client.get(
"/api/health"
)
assert response.status_code == 200
body = response.json()
assert body["status"] == "UP"
assert (
body["service"]
== "devops-ai-assistant"
)
def test_home_page():
response = client.get("/")
assert response.status_code == 200
assert (
"DevOps AI Assistant"
in response.text
)
def test_chat_endpoint():
response = client.post(
"/api/chat",
json={
"session_id":
"test-session",
"message":
"What is Docker?",
},
)
assert response.status_code == 200
body = response.json()
assert (
body["answer"]
== "Mock answer: What is Docker?"
)
assert (
body["history_messages"]
== 2
)
def test_empty_message_is_rejected():
response = client.post(
"/api/chat",
json={
"session_id":
"test-session",
"message":
" ",
},
)
assert response.status_code == 422
def test_delete_history():
client.post(
"/api/chat",
json={
"session_id":
"delete-session",
"message":
"What is Terraform?",
},
)
response = client.delete(
"/api/history/delete-session"
)
assert response.status_code == 200
assert response.json() == {
"session_id":
"delete-session",
"deleted":
True,
}
21. Why we use a fake service in tests
We do not want automated tests to call the real OpenAI API.
Real API calls would:
- Consume money
- Depend on the internet
- Be slower
- Produce variable answers
- Possibly fail because of rate limits
Instead, this test replaces the real service with:
class FakeAIService:
When the application calls:
service.answer(...)
the test returns:
Mock answer: What is Docker?
This is called dependency injection.
22. Create the Dockerfile
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1
WORKDIR /app
RUN addgroup \
--system \
appgroup \
&& adduser \
--system \
--ingroup appgroup \
appuser
COPY requirements.txt .
RUN pip install \
--upgrade pip \
&& pip install \
-r requirements.txt
COPY app ./app
USER appuser
EXPOSE 8000
HEALTHCHECK \
--interval=30s \
--timeout=5s \
--start-period=10s \
--retries=3 \
CMD python -c \
"import urllib.request; urllib.request.urlopen('http://localhost:8000/api/health')" \
|| exit 1
CMD [
"uvicorn",
"app.main:app",
"--host",
"0.0.0.0",
"--port",
"8000"
]
23. Dockerfile explanation
Base image
FROM python:3.12-slim
Uses a smaller Python image.
Environment variables
ENV PYTHONDONTWRITEBYTECODE=1
Prevents Python from creating unnecessary .pyc files.
PYTHONUNBUFFERED=1
Makes logs appear immediately.
PIP_NO_CACHE_DIR=1
Prevents pip from keeping its download cache.
Working directory
WORKDIR /app
All following commands run inside /app.
Non-root user
RUN addgroup --system appgroup \
&& adduser --system --ingroup appgroup appuser
Creates a non-root user.
Later:
USER appuser
The application does not run as root.
Health check
Docker calls:
http://localhost:8000/api/health
to verify the application is responding.
24. Create Makefile
Make sure commands below each target begin with a real tab.
.PHONY: install run test docker-build docker-run
install:
python -m pip install --upgrade pip
pip install -r requirements.txt
run:
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
test:
pytest -v
docker-build:
docker build -t fde-devops-ai-assistant:v1 .
docker-run:
docker run --rm --name fde-ai -p 8000:8000 --env-file .env fde-devops-ai-assistant:v1
Students can now use:
make install
make run
make test
make docker-build
make docker-run
25. Create README.md
# FDE Lab 1: DevOps AI Assistant
## Overview
This project is a browser-based DevOps AI assistant built with:
- Python
- FastAPI
- OpenAI API
- HTML
- CSS
- JavaScript
- Docker
- Pytest
## Architecture
```text
Browser
|
| HTTP
v
FastAPI backend
|
| OpenAI Responses API
v
Large Language Model
Features
- DevOps-focused AI assistant
- Browser chat interface
- Conversation history
- Clear-history function
- Request validation
- Health endpoint
- Error handling
- Automated testing
- Docker container
- Non-root container user
- Container health check
Local setup
Create a virtual environment:
python3 -m venv .venv
source .venv/bin/activate
Install dependencies:
pip install -r requirements.txt
Copy the environment file:
cp .env.example .env
Add your API key:
OPENAI_API_KEY=your_real_api_key
OPENAI_MODEL=gpt-5
MAX_HISTORY_MESSAGES=10
Run:
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
Open:
http://localhost:8000
API documentation
Open:
http://localhost:8000/docs
Health endpoint
curl http://localhost:8000/api/health
Test the chat API
curl -X POST \
http://localhost:8000/api/chat \
-H "Content-Type: application/json" \
-d '{
"session_id": "student-1",
"message": "Explain Docker images and containers."
}'
Run tests
pytest -v
Build Docker image
docker build \
-t fde-devops-ai-assistant:v1 \
.
Run Docker container
docker run \
--rm \
--name fde-ai \
-p 8000:8000 \
--env-file .env \
fde-devops-ai-assistant:v1
Definition of done
The project is complete when:
- The browser page loads
- Health endpoint returns 200
- User can ask a DevOps question
- Assistant returns a response
- Follow-up questions use history
- Clear History works
- Tests pass
- Docker image builds
- Docker container runs
-
.envis not committed
---
# 26. Create a virtual environment
From the project root:
```bash
python3 -m venv .venv
Activate it on macOS or Linux:
source .venv/bin/activate
Activate it on Windows PowerShell:
.venv\Scripts\Activate.ps1
After activation, Terminal should show something similar to:
(.venv) student@computer fde-devops-ai-assistant %
27. Install dependencies
python -m pip install --upgrade pip
pip install -r requirements.txt
Verify FastAPI:
pip show fastapi
Verify OpenAI:
pip show openai
28. Create the real .env file
macOS or Linux:
cp .env.example .env
Windows PowerShell:
Copy-Item .env.example .env
Open .env:
OPENAI_API_KEY=your_real_api_key
OPENAI_MODEL=gpt-5
MAX_HISTORY_MESSAGES=10
Do not add quotation marks unless they are part of the secret.
Correct:
OPENAI_API_KEY=sk-example
Avoid:
OPENAI_API_KEY="sk-example"
29. Confirm .env is ignored
Run:
git init
git status
The .env file should not appear as an untracked file.
You should see .env.example, but not .env.
30. Run the application
uvicorn app.main:app \
--reload \
--host 0.0.0.0 \
--port 8000
Expected output:
INFO: Will watch for changes in these directories
INFO: Uvicorn running on http://0.0.0.0:8000
INFO: Started reloader process
INFO: Started server process
INFO: Application startup complete
Open in the browser:
http://localhost:8000
31. Open automatic API documentation
FastAPI automatically creates Swagger documentation.
Open:
http://localhost:8000/docs
You should see:
GET /
GET /api/health
POST /api/chat
DELETE /api/history/{session_id}
32. Test the health endpoint
Browser:
http://localhost:8000/api/health
Terminal:
curl http://localhost:8000/api/health
Expected response:
{
"status": "UP",
"service": "devops-ai-assistant",
"model": "gpt-5"
}
33. Test the chat endpoint
curl -X POST \
http://localhost:8000/api/chat \
-H "Content-Type: application/json" \
-d '{
"session_id": "student-1",
"message": "Explain the difference between Docker images and containers."
}'
Expected structure:
{
"session_id": "student-1",
"answer": "A Docker image is a reusable template...",
"model": "gpt-5",
"history_messages": 2
}
The exact answer can vary.
LLM output is not always identical.
34. Test conversation history
First request:
curl -X POST \
http://localhost:8000/api/chat \
-H "Content-Type: application/json" \
-d '{
"session_id": "student-history",
"message": "What is Kubernetes?"
}'
Second request using the same session ID:
curl -X POST \
http://localhost:8000/api/chat \
-H "Content-Type: application/json" \
-d '{
"session_id": "student-history",
"message": "How does it perform autoscaling?"
}'
Because the session ID is the same, the model receives the previous conversation.
Now use a different session ID:
curl -X POST \
http://localhost:8000/api/chat \
-H "Content-Type: application/json" \
-d '{
"session_id": "different-student",
"message": "How does it perform autoscaling?"
}'
The model may not know what it means because this session has no previous context.
35. Clear conversation history
curl -X DELETE \
http://localhost:8000/api/history/student-history
Expected response:
{
"session_id": "student-history",
"deleted": true
}
Calling it again may return:
{
"session_id": "student-history",
"deleted": false
}
That means there was no remaining history.
36. Run automated tests
pytest -v
Expected output:
tests/test_api.py::test_health_endpoint PASSED
tests/test_api.py::test_home_page PASSED
tests/test_api.py::test_chat_endpoint PASSED
tests/test_api.py::test_empty_message_is_rejected PASSED
tests/test_api.py::test_delete_history PASSED
37. Build the Docker image
Stop the local application with:
Control + C
Build:
docker build \
-t fde-devops-ai-assistant:v1 \
.
Verify:
docker images
You should see:
fde-devops-ai-assistant v1
38. Run the Docker container
docker run \
--rm \
--name fde-ai \
-p 8000:8000 \
--env-file .env \
fde-devops-ai-assistant:v1
Open:
http://localhost:8000
39. Check Docker container status
In another Terminal:
docker ps
You should see the container:
fde-ai
After the health check runs, Docker should eventually show:
healthy
Inspect health status:
docker inspect \
--format='{{json .State.Health}}' \
fde-ai
View logs:
docker logs fde-ai
Follow logs:
docker logs -f fde-ai
40. Test the browser application
Ask:
What is Terraform?
Then ask:
Why do DevOps teams use it?
The second question should use the first answer as context.
Ask:
My Kubernetes Pod is in CrashLoopBackOff. What should I check?
A good response should include commands such as:
kubectl get pods
kubectl describe pod <pod-name>
kubectl logs <pod-name>
kubectl logs <pod-name> --previous
The assistant should not claim it executed those commands.
41. Lab experiment: system prompt
Open:
app/services.py
Find:
SYSTEM_INSTRUCTIONS
Change it temporarily to:
SYSTEM_INSTRUCTIONS = """
You are a DevOps instructor.
Explain every topic using a restaurant analogy.
Keep answers under 200 words.
""".strip()
Restart the application.
Ask:
What is Kubernetes?
Observe how the system instruction changes the answer.
Then restore the original system instruction.
42. Lab experiment: context
Ask:
What is an AWS Application Load Balancer?
Follow with:
Can it route traffic based on URL paths?
Then ask:
Show me an example.
The assistant should understand all three questions are connected.
Press:
Clear history
Then ask:
Show me an example.
Now the assistant does not have enough context.
This demonstrates why conversation history matters.
43. Lab experiment: user prompt quality
Compare these prompts.
Prompt 1
Docker
Prompt 2
Explain Docker.
Prompt 3
Explain Docker to a beginner DevOps student.
Include images, containers, Dockerfiles, registries,
volumes, and networking. Use one restaurant analogy.
Students should record:
- Which answer is clearest
- Which answer is most detailed
- Which answer is easiest to understand
- Why the third prompt performs better
44. Lab experiment: hallucination and uncertainty
Ask:
Show me the exact CPU usage of my Kubernetes Pod.
The assistant should explain that it cannot know the real CPU usage without access to the cluster or monitoring system.
It should recommend commands such as:
kubectl top pod <pod-name>
This demonstrates an important rule:
An LLM should not invent real infrastructure state.
45. Student exercise 1: Add a question counter
Add this inside index.html near the model information:
<span id="questionCount">
Questions: 0
</span>
In app.js, add:
const questionCount =
document.getElementById(
"questionCount"
);
let totalQuestions = 0;
After a successful user submission, add:
totalQuestions += 1;
questionCount.textContent =
`Questions: ${totalQuestions}`;
When history is cleared:
totalQuestions = 0;
questionCount.textContent =
"Questions: 0";
46. Student exercise 2: Add an explain-error endpoint
Add these models to app/models.py:
class ExplainErrorRequest(BaseModel):
error: str = Field(
min_length=1,
max_length=4000,
)
class ExplainErrorResponse(BaseModel):
explanation: str
model: str
Update imports in app/main.py:
from app.models import (
ChatRequest,
ChatResponse,
DeleteHistoryResponse,
ExplainErrorRequest,
ExplainErrorResponse,
HealthResponse,
)
Add the endpoint:
@app.post(
"/api/explain-error",
response_model=ExplainErrorResponse,
)
def explain_error(
payload: ExplainErrorRequest,
service: AIService = Depends(
get_ai_service
),
settings: Settings = Depends(
get_settings
),
) -> ExplainErrorResponse:
prompt = f"""
Analyze this DevOps error:
{payload.error}
Return:
1. What the error means
2. Most likely causes
3. Commands to run
4. How to interpret the output
5. Safe next steps
""".strip()
try:
answer, _ = service.answer(
session_id="error-analysis",
message=prompt,
)
except Exception as exc:
raise HTTPException(
status_code=502,
detail=(
"The AI provider request failed."
),
) from exc
return ExplainErrorResponse(
explanation=answer,
model=settings.openai_model,
)
Test:
curl -X POST \
http://localhost:8000/api/explain-error \
-H "Content-Type: application/json" \
-d '{
"error": "CrashLoopBackOff"
}'
47. Student exercise 3: Add output length instructions
In SYSTEM_INSTRUCTIONS, add:
Keep normal responses under 500 words unless the user asks for a detailed explanation.
Test the difference.
48. Student exercise 4: Add request logging
In app/main.py, add:
import time
Add middleware before the endpoints:
@app.middleware("http")
async def log_requests(
request: Request,
call_next,
):
start_time = time.perf_counter()
response = await call_next(
request
)
duration_ms = (
time.perf_counter()
- start_time
) * 1000
logger.info(
"%s %s status=%s duration_ms=%.2f",
request.method,
request.url.path,
response.status_code,
duration_ms,
)
return response
Now logs may look like:
POST /api/chat status=200 duration_ms=2450.31
49. Student exercise 5: Add Kubernetes endpoints
Create these endpoints:
POST /api/kubernetes/troubleshoot
POST /api/docker/explain
POST /api/terraform/review
Example request:
{
"message": "Pod is Pending"
}
The Kubernetes endpoint should ask the model to return:
- Meaning
- Possible causes
- Investigation commands
- Expected outputs
- Resolution options
50. Student exercise 6: Add rate limiting concept
Ask students:
Why should one user not be allowed to make unlimited requests?
Reasons:
- API cost
- Abuse
- Denial-of-service risk
- Provider rate limits
- Resource consumption
A future version can add Redis-based rate limiting.
51. Student exercise 7: Replace memory with Redis
Current memory:
self._history
is stored inside one Python process.
Problems:
- Lost after restart
- Not shared between containers
- Not persistent
- Cannot support multiple replicas correctly
Production design:
Browser
↓
FastAPI Replica 1 ──┐
FastAPI Replica 2 ──┼── Redis
FastAPI Replica 3 ──┘
Redis would store conversation history by session ID.
52. Student exercise 8: Add authentication
Current application allows anyone to ask questions.
Production architecture should include:
User
↓
Login
↓
JWT or secure session
↓
FastAPI
↓
Authorized AI request
Each conversation should belong to an authenticated user.
53. Troubleshooting
Error: OPENAI_API_KEY is missing
Check:
ls -la
Confirm .env exists.
Check the file:
cat .env
It should contain:
OPENAI_API_KEY=your_real_key
Restart Uvicorn after changing .env.
Error: ModuleNotFoundError
Activate the environment:
source .venv/bin/activate
Install dependencies:
pip install -r requirements.txt
Error: port 8000 already in use
macOS or Linux:
lsof -i :8000
Use another port:
uvicorn app.main:app \
--reload \
--port 8001
Open:
http://localhost:8001
Error: API returns 401
Possible reasons:
- Invalid API key
- Expired or revoked key
- Spaces inside
.env - Wrong environment variable name
Correct:
OPENAI_API_KEY=your_key
Incorrect:
OPEN_AI_KEY=your_key
Error: quota or billing problem
The API key may be valid, but the account may not have available API billing or credits.
API usage and ChatGPT subscriptions are generally separate services.
Error: Docker cannot access API key
Make sure you used:
docker run \
--env-file .env \
-p 8000:8000 \
fde-devops-ai-assistant:v1
Do not copy .env into the Docker image.
Error: Docker container exits
Check logs:
docker logs fde-ai
Run without automatically removing the container:
docker run \
--name fde-ai \
-p 8000:8000 \
--env-file .env \
fde-devops-ai-assistant:v1
Then inspect:
docker ps -a
Error: empty response
Possible reasons:
- Temporary API issue
- Unsupported model
- Provider error
- Network issue
Check application logs.
54. Production limitations
This lab uses in-memory conversation history.
That is acceptable for learning, but not production.
Production improvements should include:
- PostgreSQL or Redis
- Authentication
- Authorization
- Rate limiting
- Request timeouts
- Retry logic
- Centralized logging
- Prometheus metrics
- Distributed tracing
- Cost tracking
- Token tracking
- Input safety checks
- Output validation
- Prompt-injection protection
- Secret management
- HTTPS
- Audit logging
- Persistent storage
- Automated LLM evaluations
- Kubernetes deployment
- Horizontal autoscaling
55. Complete production architecture
User
│
▼
React or Web UI
│
▼
Application Load Balancer
│
▼
FastAPI API
│
┌──────────────┼──────────────┐
│ │ │
▼ ▼ ▼
Redis PostgreSQL OpenAI API
│
▼
Conversation history
Additional components:
- AWS Secrets Manager
- CloudWatch
- Prometheus
- Grafana
- WAF
- Cognito or another identity provider
- GitHub Actions
- ECR
- ECS or EKS
56. Final result
Students should be able to open:
http://localhost:8000
They will see:
FDE LAB 1
DevOps AI Assistant
Ask questions about AWS, Docker, Kubernetes,
Terraform, CI/CD, monitoring, Linux, and Git.
Example question:
Why is my Kubernetes Pod in CrashLoopBackOff?
Expected answer structure:
What the problem means
CrashLoopBackOff means Kubernetes starts the container,
the container crashes, and Kubernetes waits before restarting it.
Most likely causes
- Application startup error
- Missing environment variable
- Invalid command
- Failed health probe
- Missing secret
- Database connection failure
- Insufficient permissions
Commands to run
kubectl get pods
kubectl describe pod <pod-name>
kubectl logs <pod-name>
kubectl logs <pod-name> --previous
How to interpret the output
Check exit codes, events, application errors, missing secrets,
probe failures, and resource problems.
Safe next steps
Correct the configuration, update the deployment, and verify
the rollout.
57. Definition of done
The lab is complete when:
- The project structure is correct
- Python virtual environment works
- Dependencies are installed
-
.envcontains the API key -
.envis ignored by Git - The home page loads
-
/api/healthreturns HTTP 200 - The user can ask a question
- The assistant returns an answer
- Follow-up questions use conversation history
- Clear History works
- Swagger documentation works
- All tests pass
- Docker image builds
- Docker container starts
- Docker health check becomes healthy
- The application does not expose the API key
- The container runs as a non-root user
58. Homework
Students must extend the project into an AI Kubernetes Troubleshooting Assistant.
Requirements:
- Add a new page or section called:
Kubernetes Troubleshooter
- Accept:
Pod status
kubectl describe output
kubectl logs output
- Generate:
Problem summary
Likely root cause
Evidence
Commands to run
Recommended resolution
Risk warning
- Add an endpoint:
POST /api/kubernetes/troubleshoot
Add at least three automated tests.
Build a new Docker image:
docker build \
-t kubernetes-ai-assistant:v1 \
.
Add screenshots and instructions to the README.
Push the project to GitHub without committing
.env.
Top comments (0)