DEV Community

Aisalkyn Aidarova
Aisalkyn Aidarova

Posted on

FDE Lab 1: Build a DevOps AI Assistant

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

2. Create the project

Open Terminal.

mkdir fde-devops-ai-assistant
cd fde-devops-ai-assistant
Enter fullscreen mode Exit fullscreen mode

Create the folders:

mkdir -p app/static
mkdir -p tests
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Check the structure:

find . -maxdepth 3 -type f
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

6. Create .dockerignore

.venv
.git
.gitignore
.env
__pycache__
.pytest_cache
tests
*.pyc
.DS_Store
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

What this file does

This file manages the application configuration.

The application expects these environment variables:

OPENAI_API_KEY
OPENAI_MODEL
MAX_HISTORY_MESSAGES
Enter fullscreen mode Exit fullscreen mode

Pydantic automatically converts environment variable names such as:

OPENAI_API_KEY
Enter fullscreen mode Exit fullscreen mode

into the Python field:

openai_api_key
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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": ""
}
Enter fullscreen mode Exit fullscreen mode

This request will also be rejected:

{
  "session_id": "",
  "message": "Explain Docker."
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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,
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

11. How conversation history works

Suppose the first user message is:

What is Kubernetes?
Enter fullscreen mode Exit fullscreen mode

The history becomes:

[
    {
        "role": "user",
        "content": "What is Kubernetes?"
    },
    {
        "role": "assistant",
        "content": "Kubernetes is a container orchestration platform..."
    }
]
Enter fullscreen mode Exit fullscreen mode

The user then asks:

How does it perform scaling?
Enter fullscreen mode Exit fullscreen mode

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?"
    }
]
Enter fullscreen mode Exit fullscreen mode

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,
    )
Enter fullscreen mode Exit fullscreen mode

13. API endpoints

The application has four endpoints.

Home page

GET /
Enter fullscreen mode Exit fullscreen mode

Returns the browser interface.

Health check

GET /api/health
Enter fullscreen mode Exit fullscreen mode

Example response:

{
  "status": "UP",
  "service": "devops-ai-assistant",
  "model": "gpt-5"
}
Enter fullscreen mode Exit fullscreen mode

Chat endpoint

POST /api/chat
Enter fullscreen mode Exit fullscreen mode

Request:

{
  "session_id": "student-1",
  "message": "Explain Docker."
}
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "session_id": "student-1",
  "answer": "Docker is a platform used to package applications...",
  "model": "gpt-5",
  "history_messages": 2
}
Enter fullscreen mode Exit fullscreen mode

Delete conversation history

DELETE /api/history/student-1
Enter fullscreen mode Exit fullscreen mode

Response:

{
  "session_id": "student-1",
  "deleted": true
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
  })
});
Enter fullscreen mode Exit fullscreen mode

The browser does not contain the OpenAI API key.

This is important.

The secure flow is:

Browser
   ↓
Our backend
   ↓
OpenAI API
Enter fullscreen mode Exit fullscreen mode

The insecure flow would be:

Browser containing secret key
   ↓
OpenAI API
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

18. Create app/__init__.py

Leave this file empty:

Enter fullscreen mode Exit fullscreen mode

Its presence tells Python that app is a Python package.


19. Create tests/__init__.py

Leave this file empty:

Enter fullscreen mode Exit fullscreen mode

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,
    }
Enter fullscreen mode Exit fullscreen mode

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:
Enter fullscreen mode Exit fullscreen mode

When the application calls:

service.answer(...)
Enter fullscreen mode Exit fullscreen mode

the test returns:

Mock answer: What is Docker?
Enter fullscreen mode Exit fullscreen mode

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"
]
Enter fullscreen mode Exit fullscreen mode

23. Dockerfile explanation

Base image

FROM python:3.12-slim
Enter fullscreen mode Exit fullscreen mode

Uses a smaller Python image.

Environment variables

ENV PYTHONDONTWRITEBYTECODE=1
Enter fullscreen mode Exit fullscreen mode

Prevents Python from creating unnecessary .pyc files.

PYTHONUNBUFFERED=1
Enter fullscreen mode Exit fullscreen mode

Makes logs appear immediately.

PIP_NO_CACHE_DIR=1
Enter fullscreen mode Exit fullscreen mode

Prevents pip from keeping its download cache.

Working directory

WORKDIR /app
Enter fullscreen mode Exit fullscreen mode

All following commands run inside /app.

Non-root user

RUN addgroup --system appgroup \
    && adduser --system --ingroup appgroup appuser
Enter fullscreen mode Exit fullscreen mode

Creates a non-root user.

Later:

USER appuser
Enter fullscreen mode Exit fullscreen mode

The application does not run as root.

Health check

Docker calls:

http://localhost:8000/api/health
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Students can now use:

make install
Enter fullscreen mode Exit fullscreen mode
make run
Enter fullscreen mode Exit fullscreen mode
make test
Enter fullscreen mode Exit fullscreen mode
make docker-build
Enter fullscreen mode Exit fullscreen mode
make docker-run
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Copy the environment file:

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

Add your API key:

OPENAI_API_KEY=your_real_api_key
OPENAI_MODEL=gpt-5
MAX_HISTORY_MESSAGES=10
Enter fullscreen mode Exit fullscreen mode

Run:

uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
Enter fullscreen mode Exit fullscreen mode

Open:

http://localhost:8000
Enter fullscreen mode Exit fullscreen mode

API documentation

Open:

http://localhost:8000/docs
Enter fullscreen mode Exit fullscreen mode

Health endpoint

curl http://localhost:8000/api/health
Enter fullscreen mode Exit fullscreen mode

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."
  }'
Enter fullscreen mode Exit fullscreen mode

Run tests

pytest -v
Enter fullscreen mode Exit fullscreen mode

Build Docker image

docker build \
  -t fde-devops-ai-assistant:v1 \
  .
Enter fullscreen mode Exit fullscreen mode

Run Docker container

docker run \
  --rm \
  --name fde-ai \
  -p 8000:8000 \
  --env-file .env \
  fde-devops-ai-assistant:v1
Enter fullscreen mode Exit fullscreen mode

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
  • .env is not committed

---

# 26. Create a virtual environment

From the project root:

```bash
python3 -m venv .venv
Enter fullscreen mode Exit fullscreen mode

Activate it on macOS or Linux:

source .venv/bin/activate
Enter fullscreen mode Exit fullscreen mode

Activate it on Windows PowerShell:

.venv\Scripts\Activate.ps1
Enter fullscreen mode Exit fullscreen mode

After activation, Terminal should show something similar to:

(.venv) student@computer fde-devops-ai-assistant %
Enter fullscreen mode Exit fullscreen mode

27. Install dependencies

python -m pip install --upgrade pip
Enter fullscreen mode Exit fullscreen mode
pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Verify FastAPI:

pip show fastapi
Enter fullscreen mode Exit fullscreen mode

Verify OpenAI:

pip show openai
Enter fullscreen mode Exit fullscreen mode

28. Create the real .env file

macOS or Linux:

cp .env.example .env
Enter fullscreen mode Exit fullscreen mode

Windows PowerShell:

Copy-Item .env.example .env
Enter fullscreen mode Exit fullscreen mode

Open .env:

OPENAI_API_KEY=your_real_api_key
OPENAI_MODEL=gpt-5
MAX_HISTORY_MESSAGES=10
Enter fullscreen mode Exit fullscreen mode

Do not add quotation marks unless they are part of the secret.

Correct:

OPENAI_API_KEY=sk-example
Enter fullscreen mode Exit fullscreen mode

Avoid:

OPENAI_API_KEY="sk-example"
Enter fullscreen mode Exit fullscreen mode

29. Confirm .env is ignored

Run:

git init
git status
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Open in the browser:

http://localhost:8000
Enter fullscreen mode Exit fullscreen mode

31. Open automatic API documentation

FastAPI automatically creates Swagger documentation.

Open:

http://localhost:8000/docs
Enter fullscreen mode Exit fullscreen mode

You should see:

GET    /
GET    /api/health
POST   /api/chat
DELETE /api/history/{session_id}
Enter fullscreen mode Exit fullscreen mode

32. Test the health endpoint

Browser:

http://localhost:8000/api/health
Enter fullscreen mode Exit fullscreen mode

Terminal:

curl http://localhost:8000/api/health
Enter fullscreen mode Exit fullscreen mode

Expected response:

{
  "status": "UP",
  "service": "devops-ai-assistant",
  "model": "gpt-5"
}
Enter fullscreen mode Exit fullscreen mode

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."
  }'
Enter fullscreen mode Exit fullscreen mode

Expected structure:

{
  "session_id": "student-1",
  "answer": "A Docker image is a reusable template...",
  "model": "gpt-5",
  "history_messages": 2
}
Enter fullscreen mode Exit fullscreen mode

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?"
  }'
Enter fullscreen mode Exit fullscreen mode

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?"
  }'
Enter fullscreen mode Exit fullscreen mode

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?"
  }'
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expected response:

{
  "session_id": "student-history",
  "deleted": true
}
Enter fullscreen mode Exit fullscreen mode

Calling it again may return:

{
  "session_id": "student-history",
  "deleted": false
}
Enter fullscreen mode Exit fullscreen mode

That means there was no remaining history.


36. Run automated tests

pytest -v
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

37. Build the Docker image

Stop the local application with:

Control + C
Enter fullscreen mode Exit fullscreen mode

Build:

docker build \
  -t fde-devops-ai-assistant:v1 \
  .
Enter fullscreen mode Exit fullscreen mode

Verify:

docker images
Enter fullscreen mode Exit fullscreen mode

You should see:

fde-devops-ai-assistant   v1
Enter fullscreen mode Exit fullscreen mode

38. Run the Docker container

docker run \
  --rm \
  --name fde-ai \
  -p 8000:8000 \
  --env-file .env \
  fde-devops-ai-assistant:v1
Enter fullscreen mode Exit fullscreen mode

Open:

http://localhost:8000
Enter fullscreen mode Exit fullscreen mode

39. Check Docker container status

In another Terminal:

docker ps
Enter fullscreen mode Exit fullscreen mode

You should see the container:

fde-ai
Enter fullscreen mode Exit fullscreen mode

After the health check runs, Docker should eventually show:

healthy
Enter fullscreen mode Exit fullscreen mode

Inspect health status:

docker inspect \
  --format='{{json .State.Health}}' \
  fde-ai
Enter fullscreen mode Exit fullscreen mode

View logs:

docker logs fde-ai
Enter fullscreen mode Exit fullscreen mode

Follow logs:

docker logs -f fde-ai
Enter fullscreen mode Exit fullscreen mode

40. Test the browser application

Ask:

What is Terraform?
Enter fullscreen mode Exit fullscreen mode

Then ask:

Why do DevOps teams use it?
Enter fullscreen mode Exit fullscreen mode

The second question should use the first answer as context.

Ask:

My Kubernetes Pod is in CrashLoopBackOff. What should I check?
Enter fullscreen mode Exit fullscreen mode

A good response should include commands such as:

kubectl get pods
Enter fullscreen mode Exit fullscreen mode
kubectl describe pod <pod-name>
Enter fullscreen mode Exit fullscreen mode
kubectl logs <pod-name>
Enter fullscreen mode Exit fullscreen mode
kubectl logs <pod-name> --previous
Enter fullscreen mode Exit fullscreen mode

The assistant should not claim it executed those commands.


41. Lab experiment: system prompt

Open:

app/services.py
Enter fullscreen mode Exit fullscreen mode

Find:

SYSTEM_INSTRUCTIONS
Enter fullscreen mode Exit fullscreen mode

Change it temporarily to:

SYSTEM_INSTRUCTIONS = """
You are a DevOps instructor.

Explain every topic using a restaurant analogy.

Keep answers under 200 words.
""".strip()
Enter fullscreen mode Exit fullscreen mode

Restart the application.

Ask:

What is Kubernetes?
Enter fullscreen mode Exit fullscreen mode

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?
Enter fullscreen mode Exit fullscreen mode

Follow with:

Can it route traffic based on URL paths?
Enter fullscreen mode Exit fullscreen mode

Then ask:

Show me an example.
Enter fullscreen mode Exit fullscreen mode

The assistant should understand all three questions are connected.

Press:

Clear history
Enter fullscreen mode Exit fullscreen mode

Then ask:

Show me an example.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Prompt 2

Explain Docker.
Enter fullscreen mode Exit fullscreen mode

Prompt 3

Explain Docker to a beginner DevOps student.
Include images, containers, Dockerfiles, registries,
volumes, and networking. Use one restaurant analogy.
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

This demonstrates an important rule:

An LLM should not invent real infrastructure state.
Enter fullscreen mode Exit fullscreen mode

45. Student exercise 1: Add a question counter

Add this inside index.html near the model information:

<span id="questionCount">
  Questions: 0
</span>
Enter fullscreen mode Exit fullscreen mode

In app.js, add:

const questionCount =
  document.getElementById(
    "questionCount"
  );

let totalQuestions = 0;
Enter fullscreen mode Exit fullscreen mode

After a successful user submission, add:

totalQuestions += 1;

questionCount.textContent =
  `Questions: ${totalQuestions}`;
Enter fullscreen mode Exit fullscreen mode

When history is cleared:

totalQuestions = 0;

questionCount.textContent =
  "Questions: 0";
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Update imports in app/main.py:

from app.models import (
    ChatRequest,
    ChatResponse,
    DeleteHistoryResponse,
    ExplainErrorRequest,
    ExplainErrorResponse,
    HealthResponse,
)
Enter fullscreen mode Exit fullscreen mode

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,
    )
Enter fullscreen mode Exit fullscreen mode

Test:

curl -X POST \
  http://localhost:8000/api/explain-error \
  -H "Content-Type: application/json" \
  -d '{
    "error": "CrashLoopBackOff"
  }'
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Test the difference.


48. Student exercise 4: Add request logging

In app/main.py, add:

import time
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Now logs may look like:

POST /api/chat status=200 duration_ms=2450.31
Enter fullscreen mode Exit fullscreen mode

49. Student exercise 5: Add Kubernetes endpoints

Create these endpoints:

POST /api/kubernetes/troubleshoot
POST /api/docker/explain
POST /api/terraform/review
Enter fullscreen mode Exit fullscreen mode

Example request:

{
  "message": "Pod is Pending"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 ──┘
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Each conversation should belong to an authenticated user.


53. Troubleshooting

Error: OPENAI_API_KEY is missing

Check:

ls -la
Enter fullscreen mode Exit fullscreen mode

Confirm .env exists.

Check the file:

cat .env
Enter fullscreen mode Exit fullscreen mode

It should contain:

OPENAI_API_KEY=your_real_key
Enter fullscreen mode Exit fullscreen mode

Restart Uvicorn after changing .env.


Error: ModuleNotFoundError

Activate the environment:

source .venv/bin/activate
Enter fullscreen mode Exit fullscreen mode

Install dependencies:

pip install -r requirements.txt
Enter fullscreen mode Exit fullscreen mode

Error: port 8000 already in use

macOS or Linux:

lsof -i :8000
Enter fullscreen mode Exit fullscreen mode

Use another port:

uvicorn app.main:app \
  --reload \
  --port 8001
Enter fullscreen mode Exit fullscreen mode

Open:

http://localhost:8001
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Incorrect:

OPEN_AI_KEY=your_key
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Do not copy .env into the Docker image.


Error: Docker container exits

Check logs:

docker logs fde-ai
Enter fullscreen mode Exit fullscreen mode

Run without automatically removing the container:

docker run \
  --name fde-ai \
  -p 8000:8000 \
  --env-file .env \
  fde-devops-ai-assistant:v1
Enter fullscreen mode Exit fullscreen mode

Then inspect:

docker ps -a
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

56. Final result

Students should be able to open:

http://localhost:8000
Enter fullscreen mode Exit fullscreen mode

They will see:

FDE LAB 1

DevOps AI Assistant

Ask questions about AWS, Docker, Kubernetes,
Terraform, CI/CD, monitoring, Linux, and Git.
Enter fullscreen mode Exit fullscreen mode

Example question:

Why is my Kubernetes Pod in CrashLoopBackOff?
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

57. Definition of done

The lab is complete when:

  • The project structure is correct
  • Python virtual environment works
  • Dependencies are installed
  • .env contains the API key
  • .env is ignored by Git
  • The home page loads
  • /api/health returns 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:

  1. Add a new page or section called:
Kubernetes Troubleshooter
Enter fullscreen mode Exit fullscreen mode
  1. Accept:
Pod status
kubectl describe output
kubectl logs output
Enter fullscreen mode Exit fullscreen mode
  1. Generate:
Problem summary
Likely root cause
Evidence
Commands to run
Recommended resolution
Risk warning
Enter fullscreen mode Exit fullscreen mode
  1. Add an endpoint:
POST /api/kubernetes/troubleshoot
Enter fullscreen mode Exit fullscreen mode
  1. Add at least three automated tests.

  2. Build a new Docker image:

docker build \
  -t kubernetes-ai-assistant:v1 \
  .
Enter fullscreen mode Exit fullscreen mode
  1. Add screenshots and instructions to the README.

  2. Push the project to GitHub without committing .env.

Top comments (0)