Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
name: CI

# Runs on every PR (and on pushes to the default branches, so a merge that
# bypasses PR checks — e.g. an admin merge — still gets caught). See
# ruff.toml for why the lint job's rule set is deliberately narrow, and
# tests/conftest.py for why the test job needs a real Postgres service
# rather than a mock.

on:
pull_request:
push:
branches: [master, main]

permissions:
contents: read

jobs:
lint:
name: Ruff
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install dev dependencies
run: pip install -r requirements-dev.txt

- name: Ruff check
run: ruff check .

test:
name: Pytest
runs-on: ubuntu-latest

# tests/conftest.py requires a real PostgreSQL instance — it creates the
# aistudio_test database on first run and truncates tables between
# tests. A service container is the GitHub Actions equivalent of the
# `docker compose up -d postgres` step in local development.
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: aistudio
POSTGRES_PASSWORD: aistudio
POSTGRES_DB: aistudio
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U aistudio"
--health-interval 5s
--health-timeout 3s
--health-retries 5

env:
# Matches the "pytest directly on the host machine" case documented at
# the top of tests/conftest.py — the service container is reachable on
# localhost:5432 from the job's steps.
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
POSTGRES_USERNAME: aistudio
POSTGRES_PASSWORD: aistudio
POSTGRES_DATABASE: aistudio_test
RABBITMQ_URL: localhost
RABBITMQ_USERNAME: aistudio
RABBITMQ_PASSWORD: aistudio

steps:
- uses: actions/checkout@v4

- uses: actions/setup-python@v5
with:
python-version: "3.11"

- name: Install dev dependencies
run: pip install -r requirements-dev.txt

- name: Run tests
run: pytest tests/ -v
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends openssh-client

WORKDIR /AIStudio

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY requirements.txt requirements-dev.txt ./
RUN pip install --no-cache-dir -r requirements-dev.txt

COPY . .

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# aistudio-server

[![CI](https://github.com/corespan/aistudio-server/actions/workflows/ci.yml/badge.svg)](https://github.com/corespan/aistudio-server/actions/workflows/ci.yml)

Open-source LLM benchmarking and workload orchestration backend. SSHes into GPU nodes, runs vLLM benchmarks or launches Jupyter Lab, streams live logs via SSE, and stores results in PostgreSQL with a full leaderboard API.

**Licence:** Apache-2.0 for this repository's source. The workload container
Expand Down
4 changes: 2 additions & 2 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
from fastapi.responses import JSONResponse
from fastapi.exceptions import RequestValidationError

from app.routers import system, ingest, benchmarks, results, jupyter, gpu_specs

# The app instance
app = FastAPI(
title="AIStudio API",
Expand Down Expand Up @@ -60,8 +62,6 @@ async def generic_exception_handler(request: Request, exc: Exception):
},
)

from app.routers import system, ingest, benchmarks, results, jupyter, gpu_specs

# ── Routers ───────────────────────────────────────────────────────────────────
app.include_router(system.router)
app.include_router(ingest.router)
Expand Down
2 changes: 1 addition & 1 deletion app/models/workload.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from datetime import datetime
from typing import Optional

from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text, func
from sqlalchemy import DateTime, Enum, Integer, String, Text, func
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship

Expand Down
4 changes: 4 additions & 0 deletions app/routers/benchmarks.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ async def get_benchmark_status(

return {
"task_id": workload.workload_id,
# Kept alongside task_id (same value) for callers that key off the
# DB column name directly — e.g. Composer lifecycle-event tracking,
# which correlates status polls against workload_id elsewhere.
"workload_id": workload.workload_id,
"state": workload.state,
"error_message": workload.error_message,
"updated_at": workload.updated_at,
Expand Down
1 change: 0 additions & 1 deletion app/services/node_inspector.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from app.config import settings
import json
import logging
from app.services.ssh_executor import SSHExecutor

Expand Down
2 changes: 1 addition & 1 deletion app/services/ssh_executor.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import re
import uuid
import paramiko
from typing import Generator, Optional
from typing import Optional

from app.database import SyncSessionLocal
from app.models.task_log import TaskLog
Expand Down
16 changes: 8 additions & 8 deletions app/utils/sse.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,6 @@
from datetime import datetime
from typing import AsyncGenerator, Optional

# Masks the last two octets of any IPv4 address in log lines so node IPs are
# never exposed to the client. e.g. 10.6.12.26 → 10.6.x.x
_IP_RE = re.compile(r'(?<![.\d])(\d{1,3}\.\d{1,3})\.\d{1,3}\.\d{1,3}(?![.\d])')


def _mask_ip(text: str) -> str:
return _IP_RE.sub(r'\1.x.x', text)

from fastapi import Request
from sqlalchemy import select

Expand All @@ -24,6 +16,14 @@ def _mask_ip(text: str) -> str:
from app.models.task_log import TaskLog
from app.models.workload import Workload

# Masks the last two octets of any IPv4 address in log lines so node IPs are
# never exposed to the client. e.g. 10.6.12.26 → 10.6.x.x
_IP_RE = re.compile(r'(?<![.\d])(\d{1,3}\.\d{1,3})\.\d{1,3}\.\d{1,3}(?![.\d])')


def _mask_ip(text: str) -> str:
return _IP_RE.sub(r'\1.x.x', text)


async def task_log_stream(
workload_db_id: uuid.UUID,
Expand Down
Loading
Loading