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
3 changes: 2 additions & 1 deletion backend/domain/entities/docker/dockerfile_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ def __init__(self, python_version: str):
# Set environment variables
ENV IMAGE_NAME={image_name}
ENV OTEL_METRICS_EXPORTER_LABELS="project_name={project_name},model_name={model_name},model_version={model_version}"
ENV ROOT_PATH=""

# Setup uv
RUN wget -qO- https://astral.sh/uv/install.sh | sh && which uv || echo "UV installation failed"
Expand Down Expand Up @@ -47,7 +48,7 @@ def __init__(self, python_version: str):
EXPOSE 8000

# Activate conda environment and start the application
CMD ["bash", "-c", "uv run opentelemetry-instrument --service_name $IMAGE_NAME uvicorn fast_api_template:app --host 0.0.0.0 --port 8000 --log-level debug"]
CMD ["bash", "-c", "uv run opentelemetry-instrument --service_name $IMAGE_NAME uvicorn fast_api_template:app --host 0.0.0.0 --port 8000 --root-path $ROOT_PATH --log-level debug"]
"""

def generate_dockerfile(
Expand Down
100 changes: 92 additions & 8 deletions backend/domain/entities/docker/fast_api_template.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,113 @@
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.resources import SERVICE_NAME, Resource
from prometheus_client import generate_latest
from pydantic import BaseModel
from pydantic import BaseModel, create_model

try:
logger.info("Starting up and loading model...")
model = mlflow.pyfunc.load_model("/opt/mlflow/")
logger.info("Model loaded successfully")
except Exception as e:
logger.error(f"Error loading model: {e}")
model = None

image_name = os.environ["IMAGE_NAME"]

tracer = trace.get_tracer(f"model_platform_{image_name}")

app = FastAPI(reload=True)
# ---------------------------------------------------------------------------
# Dynamic schema discovery from MLflow model signature
# ---------------------------------------------------------------------------
MLFLOW_TYPE_MAP = {
"double": float,
"float": float,
"long": int,
"integer": int,
"string": str,
"boolean": bool,
}

DynamicInputs = None
DynamicOutputs = None
signature_description = ""

try:
if model is not None and hasattr(model, "metadata") and model.metadata and model.metadata.signature:
sig = model.metadata.signature

# Build input schema
if sig.inputs and hasattr(sig.inputs, "inputs") and sig.inputs.inputs:
fields = {}
field_descriptions = []
for col in sig.inputs.inputs:
col_type_str = str(col.type)
py_type = MLFLOW_TYPE_MAP.get(col_type_str, Any)
fields[col.name] = (py_type, ...)
field_descriptions.append(f"- **{col.name}**: `{col_type_str}`")
if fields:
DynamicInputs = create_model("ModelInputs", **fields)
signature_description = "### Input schema (from MLflow signature)\n" + "\n".join(field_descriptions)
logger.info(f"Built dynamic input schema with {len(fields)} fields: {list(fields.keys())}")

# Build output schema (best-effort, less critical)
if sig.outputs and hasattr(sig.outputs, "inputs") and sig.outputs.inputs:
out_fields = {}
for col in sig.outputs.inputs:
col_type_str = str(col.type)
py_type = MLFLOW_TYPE_MAP.get(col_type_str, Any)
out_fields[col.name] = (py_type, ...)
if out_fields:
DynamicOutputs = create_model("ModelOutputs", **out_fields)
logger.info(f"Built dynamic output schema with {len(out_fields)} fields: {list(out_fields.keys())}")

except Exception as e:
logger.warning(f"Could not build dynamic schema from model signature, falling back to generic schema: {e}")
DynamicInputs = None
DynamicOutputs = None
signature_description = ""

# ---------------------------------------------------------------------------
# Request / Response models (dynamic if signature available, generic fallback)
# ---------------------------------------------------------------------------
if DynamicInputs is not None:

class PredictionRequest(BaseModel):
inputs: DynamicInputs

else:

class PredictionRequest(BaseModel):
inputs: Dict[str, Any]
class PredictionRequest(BaseModel):
inputs: Dict[str, Any]


class PredictionResponse(BaseModel):
outputs: Any


@app.post("/predict", response_model=PredictionResponse)
# ---------------------------------------------------------------------------
# FastAPI application
# ---------------------------------------------------------------------------
api_description = f"""Inference API for deployed ML model **{image_name}**.

Accepts JSON payloads or file uploads for prediction.

{signature_description}
"""

app = FastAPI(
title=f"Model API - {image_name}",
description=api_description,
version="1.0.0",
root_path=os.getenv("ROOT_PATH", ""),
)


@app.post(
"/predict",
response_model=PredictionResponse,
summary="Run inference",
description="Send input features as JSON or upload a file for prediction.",
)
async def predict(request: Request, file: Optional[UploadFile] = File(None)):
tracer = trace.get_tracer(__name__)
try:
Expand All @@ -58,6 +140,8 @@ async def predict(request: Request, file: Optional[UploadFile] = File(None)):

if isinstance(input_data, dict):
input_df = pd.DataFrame([input_data])
elif isinstance(input_data, BaseModel):
input_df = pd.DataFrame([input_data.model_dump()])
else:
input_df = pd.DataFrame(input_data)

Expand All @@ -77,18 +161,18 @@ async def predict(request: Request, file: Optional[UploadFile] = File(None)):
raise HTTPException(status_code=500, detail=str(e))


@app.get("/health")
@app.get("/health", summary="Health check")
async def health_check():
return {"status": "healthy"}


@app.get("/metrics")
@app.get("/metrics", summary="Prometheus metrics")
def metrics_endpoint():
return Response(content=generate_latest(), media_type="text/plain")


FastAPIInstrumentor.instrument_app(app)
# Check si on devrait mettre le service name lié à k8s
# Check si on devrait mettre le service name lie a k8s
resource = Resource.create(attributes={SERVICE_NAME: f"model-platform-{image_name}"})

# Prometheus client
Expand Down
6 changes: 6 additions & 0 deletions backend/infrastructure/k8s_model_deployment_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,12 @@ def _create_model_service_deployment(self):
image=f"{self.docker_image_name}:latest",
image_pull_policy="IfNotPresent", # Ajouté pour éviter les erreurs de pull
ports=[client.V1ContainerPort(container_port=self.port)],
env=[
client.V1EnvVar(
name="ROOT_PATH",
value=f"/deploy/{self.namespace}/{self.service_name}",
),
],
)
],
restart_policy="Always", # Bonne pratique pour un Deployment
Expand Down
1 change: 1 addition & 0 deletions frontend/js/pages/monitoring.js
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ const MonitoringPage = (() => {
<div class="model-row-version">v${m.version}</div>
</div>
<div class="model-row-actions">
<a href="${m.deployment_name && m.project ? `http://${window.MP_HOST_NAME || ''}/deploy/${m.project}/${m.deployment_name}/docs` : '#'}" target="_blank" rel="noopener" class="model-row-link${!m.deployment_name ? ' model-row-link-disabled' : ''}" title="Open API Docs (Swagger)"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><polyline points="14 2 14 8 20 8"></polyline><line x1="16" y1="13" x2="8" y2="13"></line><line x1="16" y1="17" x2="8" y2="17"></line></svg></a>
<a href="${m.dashboard_url ? m.dashboard_url + GRAFANA_QUERY_PARAMS : '#'}" target="_blank" rel="noopener" class="model-row-link${!m.dashboard_url ? ' model-row-link-disabled' : ''}" title="Open Grafana dashboard"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg></a>
</div>
</div>
Expand Down
2 changes: 2 additions & 0 deletions frontend/js/pages/project-detail.js
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ const ProjectDetailPage = (() => {
const dashUrl = m.dashboard_url || buildDashboardUrl(projectName, deployName);
const deployDate = m.deployment_date ? new Date(m.deployment_date).toLocaleDateString() : '—';
const endpointUrl = buildDashboardUrl(projectName, deployName);
const docsUrl = endpointUrl ? `${endpointUrl}/docs` : '';

return `
<tr>
Expand All @@ -647,6 +648,7 @@ const ProjectDetailPage = (() => {
<td>${statusBadge(status)}</td>
<td class="actions">
<div class="flex gap-2 justify-end">
${docsUrl ? `<a href="${escHtml(docsUrl)}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">API Docs</a>` : ''}
${dashUrl ? `<a href="${escHtml(dashUrl)}" target="_blank" rel="noopener" class="btn btn-secondary btn-sm">Dashboard</a>` : ''}
<button class="btn btn-danger btn-sm undeploy-btn" data-model="${escHtml(name)}" data-version="${escHtml(String(version))}">Undeploy</button>
</div>
Expand Down
36 changes: 36 additions & 0 deletions infrastructure/k8s/nginx-configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,42 @@ data:
proxy_read_timeout 86400;
}

location ~ ^/deploy/([^/]+)/([^/]+)/docs$ {
set $project_name $1;
set $deployment_name $2;

resolver kube-dns.kube-system.svc.cluster.local valid=10s;

proxy_pass http://$deployment_name.$project_name.svc.cluster.local:8000/docs;

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}

location ~ ^/deploy/([^/]+)/([^/]+)/openapi\.json$ {
set $project_name $1;
set $deployment_name $2;

resolver kube-dns.kube-system.svc.cluster.local valid=10s;

proxy_pass http://$deployment_name.$project_name.svc.cluster.local:8000/openapi.json;

proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}

location /prometheus/ {
resolver kube-dns.kube-system.svc.cluster.local valid=10s;
proxy_pass http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090/;
Expand Down
Loading