Initial commit - Datalab Lite source code
This commit is contained in:
commit
e0b852dd6b
6 files changed
+780
No files matched your search
Vendored
+57
@@ -0,0 +1,57 @@
|
|||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
stages {
|
||||||
|
stage('Clone') {
|
||||||
|
steps {
|
||||||
|
echo '✅ Code cloné depuis Gitea'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('SonarQube Analysis') {
|
||||||
|
steps {
|
||||||
|
withSonarQubeEnv('sonarqube') {
|
||||||
|
sh '''
|
||||||
|
sonar-scanner \
|
||||||
|
-Dsonar.projectKey=datalab-lite \
|
||||||
|
-Dsonar.projectName="Datalab Lite" \
|
||||||
|
-Dsonar.sources=. \
|
||||||
|
-Dsonar.inclusions="**/*.py" \
|
||||||
|
-Dsonar.python.version=3.11
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Quality Gate') {
|
||||||
|
steps {
|
||||||
|
waitForQualityGate abortPipeline: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Deploy') {
|
||||||
|
steps {
|
||||||
|
sh '''
|
||||||
|
# Mettre à jour le ConfigMap code
|
||||||
|
kubectl create configmap datalab-lite-code \
|
||||||
|
--from-file=main.py=main.py \
|
||||||
|
--from-file=k8s.py=k8s.py \
|
||||||
|
--from-file=minio_client.py=minio_client.py \
|
||||||
|
-n datalab-lite \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
# Mettre à jour le ConfigMap templates
|
||||||
|
kubectl create configmap datalab-lite-templates \
|
||||||
|
--from-file=index.html=templates/index.html \
|
||||||
|
-n datalab-lite \
|
||||||
|
--dry-run=client -o yaml | kubectl apply -f -
|
||||||
|
|
||||||
|
# Redémarrer le pod
|
||||||
|
kubectl rollout restart deployment datalab-lite -n datalab-lite
|
||||||
|
|
||||||
|
echo "✅ Datalab Lite déployé"
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
success { echo '✅ Pipeline terminé avec succès !' }
|
||||||
|
failure { echo '❌ Pipeline échoué !' }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# Datalab Lite
|
||||||
|
|
||||||
|
Plateforme Data Science en libre-service déployée sur Kubernetes.
|
||||||
|
|
||||||
|
## Services disponibles
|
||||||
|
- JupyterLab
|
||||||
|
- RStudio
|
||||||
|
- VSCode Server
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
- Backend : FastAPI (Python 3.11)
|
||||||
|
- Auth : Keycloak OIDC
|
||||||
|
- Storage : MinIO S3
|
||||||
|
- AI Chat : LiteLLM (llama3.2:3b)
|
||||||
|
- Deploy : Kubernetes
|
||||||
|
|
||||||
|
## URL
|
||||||
|
https://datalablite.lab.leadwire.dev
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
from kubernetes import client, config
|
||||||
|
import random, string
|
||||||
|
from minio_client import generate_password, MINIO_URL, MINIO_CONSOLE_URL
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
def generate_service_password(username: str, service_type: str) -> str:
|
||||||
|
"""Génère un password unique par user+service, persistant"""
|
||||||
|
seed = f"datalab-{username}-{service_type}-2026"
|
||||||
|
return hashlib.sha256(seed.encode()).hexdigest()[:12]
|
||||||
|
|
||||||
|
S3_INIT_SCRIPT = (
|
||||||
|
"pip install s3contents -q --target=/shared/packages && "
|
||||||
|
"mkdir -p /shared/jupyter && "
|
||||||
|
"printf '%s\\n' "
|
||||||
|
"'import sys' "
|
||||||
|
"'sys.path.insert(0, \"/shared/packages\")' "
|
||||||
|
"'import os' "
|
||||||
|
"'c.ServerApp.contents_manager_class = \"s3contents.S3ContentsManager\"' "
|
||||||
|
"'c.S3ContentsManager.endpoint_url = \"https://\" + os.environ.get(\"MINIO_ENDPOINT\", \"\")' "
|
||||||
|
"'c.S3ContentsManager.access_key_id = os.environ.get(\"MINIO_ACCESS_KEY\", \"\")' "
|
||||||
|
"'c.S3ContentsManager.secret_access_key = os.environ.get(\"MINIO_SECRET_KEY\", \"\")' "
|
||||||
|
"'c.S3ContentsManager.bucket = os.environ.get(\"MINIO_BUCKET\", \"\")' "
|
||||||
|
"> /shared/jupyter/jupyter_server_config.py && echo OK"
|
||||||
|
)
|
||||||
|
|
||||||
|
S3_START_CMD = (
|
||||||
|
"mkdir -p /home/jovyan/.jupyter && "
|
||||||
|
"cp /shared/jupyter/jupyter_server_config.py /home/jovyan/.jupyter/ && "
|
||||||
|
"export PYTHONPATH=/shared/packages:$PYTHONPATH && "
|
||||||
|
"start-notebook.sh"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
config.load_incluster_config()
|
||||||
|
except:
|
||||||
|
config.load_kube_config()
|
||||||
|
|
||||||
|
DOMAIN = 'lab.leadwire.dev'
|
||||||
|
|
||||||
|
def get_namespace(username):
|
||||||
|
return f"user-{username}"
|
||||||
|
|
||||||
|
def random_id(length=5):
|
||||||
|
return ''.join(random.choices(string.digits, k=length))
|
||||||
|
|
||||||
|
def ensure_namespace(username):
|
||||||
|
v1 = client.CoreV1Api()
|
||||||
|
ns = get_namespace(username)
|
||||||
|
try:
|
||||||
|
v1.read_namespace(ns)
|
||||||
|
except:
|
||||||
|
v1.create_namespace(client.V1Namespace(
|
||||||
|
metadata=client.V1ObjectMeta(
|
||||||
|
name=ns,
|
||||||
|
labels={"managed-by": "datalab-lite", "user": username}
|
||||||
|
)
|
||||||
|
))
|
||||||
|
return ns
|
||||||
|
|
||||||
|
def create_service(username, service_type, svc_config):
|
||||||
|
# Générer password unique par user+service
|
||||||
|
service_password = generate_service_password(username, service_type)
|
||||||
|
# Remplacer {token} dans les env vars
|
||||||
|
resolved_env = {}
|
||||||
|
for k, v in svc_config["env"].items():
|
||||||
|
resolved_env[k] = v.replace("{token}", service_password)
|
||||||
|
svc_config = {**svc_config, "env": resolved_env}
|
||||||
|
ns = ensure_namespace(username)
|
||||||
|
uid = random_id()
|
||||||
|
name = f"{username}-{service_type}-{uid}"
|
||||||
|
port = svc_config["port"]
|
||||||
|
apps = client.AppsV1Api()
|
||||||
|
v1 = client.CoreV1Api()
|
||||||
|
net = client.NetworkingV1Api()
|
||||||
|
|
||||||
|
minio_password = generate_password(username)
|
||||||
|
minio_envs = {
|
||||||
|
"MINIO_ENDPOINT": MINIO_URL,
|
||||||
|
"MINIO_ACCESS_KEY": username,
|
||||||
|
"MINIO_SECRET_KEY": minio_password,
|
||||||
|
"MINIO_BUCKET": f"user-{username}",
|
||||||
|
"AWS_ACCESS_KEY_ID": username,
|
||||||
|
"AWS_SECRET_ACCESS_KEY": minio_password,
|
||||||
|
"AWS_S3_ENDPOINT": f"https://{MINIO_URL}",
|
||||||
|
"NB_USER": username,
|
||||||
|
"PYTHONPATH": "/home/jovyan/jupyter-packages",
|
||||||
|
"PYTHONHTTPSVERIFY": "0",
|
||||||
|
}
|
||||||
|
env_vars = [client.V1EnvVar(name=k, value=v) for k, v in {**svc_config["env"], **minio_envs}.items()]
|
||||||
|
|
||||||
|
deployment = client.V1Deployment(
|
||||||
|
metadata=client.V1ObjectMeta(
|
||||||
|
name=name, namespace=ns,
|
||||||
|
labels={"app": name, "user": username, "service-type": service_type,
|
||||||
|
"token": svc_config["env"].get("JUPYTER_TOKEN", svc_config["env"].get("PASSWORD", ""))}
|
||||||
|
),
|
||||||
|
spec=client.V1DeploymentSpec(
|
||||||
|
replicas=1,
|
||||||
|
selector=client.V1LabelSelector(match_labels={"app": name}),
|
||||||
|
template=client.V1PodTemplateSpec(
|
||||||
|
metadata=client.V1ObjectMeta(labels={"app": name}),
|
||||||
|
spec=client.V1PodSpec(
|
||||||
|
init_containers=([
|
||||||
|
client.V1Container(
|
||||||
|
name="install-s3",
|
||||||
|
image=svc_config["image"],
|
||||||
|
command=["sh", "-c", S3_INIT_SCRIPT],
|
||||||
|
env=env_vars,
|
||||||
|
volume_mounts=[client.V1VolumeMount(name="shared", mount_path="/shared")]
|
||||||
|
)
|
||||||
|
] if service_type == "jupyter" else []),
|
||||||
|
containers=[
|
||||||
|
client.V1Container(
|
||||||
|
name=name,
|
||||||
|
image=svc_config["image"],
|
||||||
|
ports=[client.V1ContainerPort(container_port=port)],
|
||||||
|
env=env_vars,
|
||||||
|
command=(["sh", "-c", S3_START_CMD] if service_type == "jupyter" else None),
|
||||||
|
volume_mounts=([client.V1VolumeMount(name="shared", mount_path="/shared")] if service_type == "jupyter" else []),
|
||||||
|
resources=client.V1ResourceRequirements(
|
||||||
|
requests={"memory": "512Mi", "cpu": "250m"},
|
||||||
|
limits={"memory": "2Gi", "cpu": "1"}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
volumes=([client.V1Volume(name="shared", empty_dir=client.V1EmptyDirVolumeSource())] if service_type == "jupyter" else [])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
apps.create_namespaced_deployment(ns, deployment)
|
||||||
|
svc = client.V1Service(
|
||||||
|
metadata=client.V1ObjectMeta(name=name, namespace=ns),
|
||||||
|
spec=client.V1ServiceSpec(
|
||||||
|
selector={"app": name},
|
||||||
|
ports=[client.V1ServicePort(port=port, target_port=port)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
v1.create_namespaced_service(ns, svc)
|
||||||
|
host = f"{name}.{DOMAIN}"
|
||||||
|
ingress = client.V1Ingress(
|
||||||
|
metadata=client.V1ObjectMeta(
|
||||||
|
name=name, namespace=ns,
|
||||||
|
annotations={"cert-manager.io/cluster-issuer": "letsencrypt-prod", "nginx.ingress.kubernetes.io/proxy-read-timeout": "3600", "nginx.ingress.kubernetes.io/proxy-send-timeout": "3600"}
|
||||||
|
),
|
||||||
|
spec=client.V1IngressSpec(
|
||||||
|
ingress_class_name="nginx",
|
||||||
|
rules=[client.V1IngressRule(
|
||||||
|
host=host,
|
||||||
|
http=client.V1HTTPIngressRuleValue(paths=[
|
||||||
|
client.V1HTTPIngressPath(
|
||||||
|
path="/", path_type="Prefix",
|
||||||
|
backend=client.V1IngressBackend(
|
||||||
|
service=client.V1IngressServiceBackend(
|
||||||
|
name=name,
|
||||||
|
port=client.V1ServiceBackendPort(number=port)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
])
|
||||||
|
)],
|
||||||
|
tls=[client.V1IngressTLS(hosts=[host], secret_name=f"{name}-tls")]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
net.create_namespaced_ingress(ns, ingress)
|
||||||
|
return f"https://{host}"
|
||||||
|
|
||||||
|
def delete_service(username, service_name):
|
||||||
|
"""service_name = nom complet ex: datauser-jupyter-12345"""
|
||||||
|
ns = get_namespace(username)
|
||||||
|
apps = client.AppsV1Api()
|
||||||
|
v1 = client.CoreV1Api()
|
||||||
|
net = client.NetworkingV1Api()
|
||||||
|
for fn in [
|
||||||
|
lambda: apps.delete_namespaced_deployment(service_name, ns),
|
||||||
|
lambda: v1.delete_namespaced_service(service_name, ns),
|
||||||
|
lambda: net.delete_namespaced_ingress(service_name, ns),
|
||||||
|
]:
|
||||||
|
try: fn()
|
||||||
|
except: pass
|
||||||
|
|
||||||
|
def list_user_services(username):
|
||||||
|
ns = get_namespace(username)
|
||||||
|
apps = client.AppsV1Api()
|
||||||
|
result = []
|
||||||
|
try:
|
||||||
|
deps = apps.list_namespaced_deployment(
|
||||||
|
ns, label_selector=f"user={username}"
|
||||||
|
)
|
||||||
|
for d in deps.items:
|
||||||
|
stype = d.metadata.labels.get("service-type", "")
|
||||||
|
name = d.metadata.name
|
||||||
|
ready = d.status.ready_replicas or 0
|
||||||
|
result.append({
|
||||||
|
"name": name,
|
||||||
|
"type": stype,
|
||||||
|
"ready": ready > 0,
|
||||||
|
"url": f"https://{name}.{DOMAIN}",
|
||||||
|
"token": d.metadata.labels.get("token", "")
|
||||||
|
})
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
return result
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
from fastapi import FastAPI, Request, HTTPException
|
||||||
|
from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
|
||||||
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
import httpx, os, json
|
||||||
|
from k8s import create_service, delete_service, list_user_services
|
||||||
|
from minio_client import ensure_bucket
|
||||||
|
|
||||||
|
app = FastAPI()
|
||||||
|
jinja_env = Environment(loader=FileSystemLoader("/app/code/templates"))
|
||||||
|
|
||||||
|
KEYCLOAK_URL = "https://keycloak.lab.leadwire.dev/auth/realms/datalab"
|
||||||
|
CLIENT_ID = "datalab-lite"
|
||||||
|
CLIENT_SECRET= os.getenv("CLIENT_SECRET", "")
|
||||||
|
REDIRECT_URI = "https://datalablite.lab.leadwire.dev/callback"
|
||||||
|
|
||||||
|
SERVICES = {
|
||||||
|
"jupyter": {
|
||||||
|
"name": "JupyterLab",
|
||||||
|
"image": "jupyter/scipy-notebook:latest",
|
||||||
|
"port": 8888,
|
||||||
|
"icon": "🔬",
|
||||||
|
"env": {"JUPYTER_ENABLE_LAB": "yes", "JUPYTER_TOKEN": "{token}"}
|
||||||
|
},
|
||||||
|
"rstudio": {
|
||||||
|
"name": "RStudio",
|
||||||
|
"image": "rocker/rstudio:latest",
|
||||||
|
"port": 8787,
|
||||||
|
"icon": "📊",
|
||||||
|
"env": {"PASSWORD": "{token}", "USER": "rstudio"}
|
||||||
|
},
|
||||||
|
"vscode": {
|
||||||
|
"name": "VSCode Server",
|
||||||
|
"image": "codercom/code-server:latest",
|
||||||
|
"port": 8080,
|
||||||
|
"icon": "💻",
|
||||||
|
"env": {"PASSWORD": "{token}"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def render(template_name, **ctx):
|
||||||
|
t = jinja_env.get_template(template_name)
|
||||||
|
return HTMLResponse(t.render(**ctx))
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
async def home(request: Request):
|
||||||
|
username = request.cookies.get("username")
|
||||||
|
if not username:
|
||||||
|
return RedirectResponse("/login")
|
||||||
|
services = list_user_services(username)
|
||||||
|
return render("index.html", username=username, services=services, catalog=SERVICES)
|
||||||
|
|
||||||
|
@app.get("/login")
|
||||||
|
async def login():
|
||||||
|
url = (
|
||||||
|
f"{KEYCLOAK_URL}/protocol/openid-connect/auth"
|
||||||
|
f"?client_id={CLIENT_ID}"
|
||||||
|
f"&redirect_uri={REDIRECT_URI}"
|
||||||
|
f"&response_type=code"
|
||||||
|
f"&scope=openid profile email"
|
||||||
|
)
|
||||||
|
return RedirectResponse(url)
|
||||||
|
|
||||||
|
@app.get("/callback")
|
||||||
|
async def callback(code: str, request: Request):
|
||||||
|
async with httpx.AsyncClient(verify=False) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{KEYCLOAK_URL}/protocol/openid-connect/token",
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": CLIENT_ID,
|
||||||
|
"client_secret": CLIENT_SECRET,
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": REDIRECT_URI,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(status_code=401, detail="Auth failed")
|
||||||
|
|
||||||
|
tokens = resp.json()
|
||||||
|
access_token = tokens["access_token"]
|
||||||
|
|
||||||
|
import base64
|
||||||
|
payload = access_token.split(".")[1]
|
||||||
|
payload += "=" * (4 - len(payload) % 4)
|
||||||
|
user_info = json.loads(base64.b64decode(payload))
|
||||||
|
username = user_info.get("preferred_username", "unknown")
|
||||||
|
|
||||||
|
ensure_bucket(username)
|
||||||
|
|
||||||
|
response = RedirectResponse("/", status_code=302)
|
||||||
|
response.set_cookie("token", access_token, httponly=True)
|
||||||
|
response.set_cookie("username", username, httponly=True)
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.get("/logout")
|
||||||
|
async def logout():
|
||||||
|
response = RedirectResponse("/login")
|
||||||
|
response.delete_cookie("token")
|
||||||
|
response.delete_cookie("username")
|
||||||
|
return response
|
||||||
|
|
||||||
|
@app.post("/api/launch/{service_type}")
|
||||||
|
async def launch(service_type: str, request: Request):
|
||||||
|
username = request.cookies.get("username")
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
if service_type not in SERVICES:
|
||||||
|
raise HTTPException(status_code=400, detail="Service inconnu")
|
||||||
|
svc = SERVICES[service_type]
|
||||||
|
url = create_service(username, service_type, svc)
|
||||||
|
return JSONResponse({"status": "ok", "url": url})
|
||||||
|
|
||||||
|
@app.delete("/api/delete/{service_type}")
|
||||||
|
async def delete(service_type: str, request: Request):
|
||||||
|
username = request.cookies.get("username")
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
delete_service(username, service_type)
|
||||||
|
return JSONResponse({"status": "deleted"})
|
||||||
|
|
||||||
|
@app.get("/api/services")
|
||||||
|
async def services(request: Request):
|
||||||
|
username = request.cookies.get("username")
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
return JSONResponse(list_user_services(username))
|
||||||
|
|
||||||
|
@app.get("/api/minio-credentials")
|
||||||
|
async def minio_credentials(request: Request):
|
||||||
|
username = request.cookies.get("username")
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
from minio_client import get_minio_credentials
|
||||||
|
return JSONResponse(get_minio_credentials(username))
|
||||||
|
|
||||||
|
@app.get("/api/minio-credentials")
|
||||||
|
async def minio_credentials(request: Request):
|
||||||
|
username = request.cookies.get("username")
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
from minio_client import get_minio_credentials
|
||||||
|
return JSONResponse(get_minio_credentials(username))
|
||||||
|
|
||||||
|
@app.post("/api/chat")
|
||||||
|
async def chat(request: Request):
|
||||||
|
username = request.cookies.get("username")
|
||||||
|
if not username:
|
||||||
|
raise HTTPException(status_code=401)
|
||||||
|
body = await request.json()
|
||||||
|
messages = body.get("messages", [])
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=60) as client:
|
||||||
|
resp = await client.post(
|
||||||
|
"http://10.233.9.253:4000/v1/chat/completions",
|
||||||
|
headers={"Authorization": "Bearer sk-local123test",
|
||||||
|
"Content-Type": "application/json"},
|
||||||
|
json={
|
||||||
|
"model": "llama3.2:3b",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": f"Tu es un assistant expert Data Science pour {username}. Tu aides avec Python, pandas, SQL, machine learning, et l'utilisation de MinIO S3. Réponds en français de manière concise."}
|
||||||
|
] + messages,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"max_tokens": 512,
|
||||||
|
"stream": False
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
raise HTTPException(status_code=502, detail="LiteLLM error")
|
||||||
|
result = resp.json()
|
||||||
|
return JSONResponse({"response": result["choices"][0]["message"]["content"]})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
from minio import Minio
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import subprocess
|
||||||
|
import json
|
||||||
|
|
||||||
|
MINIO_URL = "minio.lab.leadwire.dev"
|
||||||
|
MINIO_CONSOLE_URL = "https://minio-console.lab.leadwire.dev"
|
||||||
|
ADMIN_KEY = "admin"
|
||||||
|
ADMIN_SECRET = "Minio@Leadwire2026!"
|
||||||
|
MC_ALIAS = "minio-local"
|
||||||
|
|
||||||
|
mc = Minio(
|
||||||
|
MINIO_URL,
|
||||||
|
access_key=ADMIN_KEY,
|
||||||
|
secret_key=ADMIN_SECRET,
|
||||||
|
secure=True,
|
||||||
|
cert_check=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def generate_password(username: str) -> str:
|
||||||
|
seed = f"datalab-{username}-2026"
|
||||||
|
return hashlib.sha256(seed.encode()).hexdigest()[:16]
|
||||||
|
|
||||||
|
def mc_cmd(*args):
|
||||||
|
cmd = ["mc", "--insecure"] + list(args)
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
print(f"mc {' '.join(args)}: code={result.returncode} out={result.stdout.strip()} err={result.stderr.strip()}")
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
def ensure_bucket(username):
|
||||||
|
bucket = f"user-{username}"
|
||||||
|
password = generate_password(username)
|
||||||
|
try:
|
||||||
|
# Créer le bucket
|
||||||
|
if not mc.bucket_exists(bucket):
|
||||||
|
mc.make_bucket(bucket)
|
||||||
|
print(f"Bucket {bucket} créé")
|
||||||
|
|
||||||
|
# Créer le user MinIO
|
||||||
|
mc_cmd("admin", "user", "add", MC_ALIAS, username, password)
|
||||||
|
|
||||||
|
# Créer la policy
|
||||||
|
policy = json.dumps({
|
||||||
|
"Version": "2012-10-17",
|
||||||
|
"Statement": [{
|
||||||
|
"Effect": "Allow",
|
||||||
|
"Action": ["s3:*"],
|
||||||
|
"Resource": [
|
||||||
|
f"arn:aws:s3:::{bucket}",
|
||||||
|
f"arn:aws:s3:::{bucket}/*"
|
||||||
|
]
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
policy_file = f"/tmp/policy-{username}.json"
|
||||||
|
with open(policy_file, "w") as f:
|
||||||
|
f.write(policy)
|
||||||
|
|
||||||
|
mc_cmd("admin", "policy", "create", MC_ALIAS, f"policy-{username}", policy_file)
|
||||||
|
mc_cmd("admin", "policy", "attach", MC_ALIAS, f"policy-{username}", "--user", username)
|
||||||
|
|
||||||
|
print(f"User MinIO {username} configuré ✅")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"MinIO error: {e}")
|
||||||
|
|
||||||
|
def get_minio_credentials(username: str) -> dict:
|
||||||
|
return {
|
||||||
|
"url": MINIO_CONSOLE_URL,
|
||||||
|
"bucket": f"user-{username}",
|
||||||
|
"access_key": username,
|
||||||
|
"secret_key": generate_password(username)
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>DTNum Labs</title>
|
||||||
|
<style>
|
||||||
|
* { margin:0; padding:0; box-sizing:border-box; }
|
||||||
|
body { font-family: 'Segoe UI', sans-serif; background:#0f172a; color:#e2e8f0; min-height:100vh; }
|
||||||
|
header { background:#1e293b; padding:16px 32px; display:flex; justify-content:space-between; align-items:center; border-bottom:1px solid #334155; }
|
||||||
|
header h1 { font-size:20px; color:#60a5fa; font-weight:700; }
|
||||||
|
.user-info { display:flex; align-items:center; gap:12px; font-size:14px; color:#94a3b8; }
|
||||||
|
.logout { color:#f87171; text-decoration:none; font-size:13px; }
|
||||||
|
.container { max-width:1100px; margin:0 auto; padding:32px 24px; }
|
||||||
|
h2 { font-size:16px; color:#94a3b8; text-transform:uppercase; letter-spacing:.1em; margin-bottom:16px; }
|
||||||
|
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(300px,1fr)); gap:16px; margin-bottom:40px; }
|
||||||
|
.card { background:#1e293b; border:1px solid #334155; border-radius:12px; padding:20px; }
|
||||||
|
.card-icon { font-size:32px; margin-bottom:12px; }
|
||||||
|
.card-name { font-size:16px; font-weight:700; margin-bottom:6px; }
|
||||||
|
.card-status { font-size:12px; color:#94a3b8; margin-bottom:16px; }
|
||||||
|
.card-status.running { color:#34d399; }
|
||||||
|
.btn { padding:8px 16px; border-radius:8px; border:none; cursor:pointer; font-size:13px; font-weight:600; transition:all .2s; }
|
||||||
|
.btn-launch { background:#3b82f6; color:#fff; }
|
||||||
|
.btn-launch:hover { background:#2563eb; }
|
||||||
|
.btn-open { background:#10b981; color:#fff; }
|
||||||
|
.btn-open:hover { background:#059669; }
|
||||||
|
.btn-delete { background:#ef4444; color:#fff; }
|
||||||
|
.btn-delete:hover { background:#dc2626; }
|
||||||
|
.btn-group { display:flex; gap:8px; flex-wrap:wrap; }
|
||||||
|
.badge { display:inline-block; padding:2px 8px; border-radius:999px; font-size:11px; font-weight:600; }
|
||||||
|
.badge-running { background:#064e3b; color:#34d399; }
|
||||||
|
.badge-stopped { background:#1e293b; color:#64748b; border:1px solid #334155; }
|
||||||
|
.minio-info { background:#1e293b; border:1px solid #334155; border-radius:10px; padding:16px 20px; margin-top:8px; font-size:13px; color:#94a3b8; }
|
||||||
|
.minio-info strong { color:#60a5fa; }
|
||||||
|
.token-box { display:flex; align-items:center; gap:8px; background:#0f172a; border:1px solid #334155; border-radius:6px; padding:6px 10px; margin-top:10px; font-family:monospace; font-size:12px; color:#94a3b8; }
|
||||||
|
.token-box span { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||||
|
.btn-copy { background:#334155; color:#94a3b8; border:none; border-radius:6px; padding:4px 8px; font-size:11px; cursor:pointer; flex-shrink:0; transition:all .2s; }
|
||||||
|
.btn-copy:hover { background:#475569; color:#e2e8f0; }
|
||||||
|
.btn-copy.copied { background:#064e3b; color:#34d399; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;"><img src="data:image/png;base64,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 truncated
|
||||||
|
<div class="user-info">
|
||||||
|
👤 {{ username }}
|
||||||
|
<a href="/logout" class="logout">Déconnexion</a>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="container">
|
||||||
|
|
||||||
|
<!-- Services actifs -->
|
||||||
|
<h2>Mes services actifs</h2>
|
||||||
|
<div class="grid" id="active-services">
|
||||||
|
{% if services %}
|
||||||
|
{% for svc in services %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-icon">{{ catalog[svc.type].icon if svc.type in catalog else '🔧' }}</div>
|
||||||
|
<div class="card-name">{{ catalog[svc.type].name if svc.type in catalog else svc.type }}</div>
|
||||||
|
<div class="card-status">
|
||||||
|
<span class="badge {% if svc.ready %}badge-running{% else %}badge-stopped{% endif %}">
|
||||||
|
{% if svc.ready %}● En cours{% else %}⏳ Démarrage...{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% if svc.token %}<div class="token-box"><span>{{ svc.token }}</span><button class="btn-copy" onclick="copyToken('{{ svc.token }}', this)">📋 Copier</button></div>{% endif %}<div class="btn-group">
|
||||||
|
{% if svc.ready %}
|
||||||
|
<a href="{{ svc.url }}" target="_blank"><button class="btn btn-open">🔗 Ouvrir</button></a>
|
||||||
|
{% endif %}
|
||||||
|
<button class="btn btn-delete" onclick="deleteService('{{ svc.name }}')">🗑 Supprimer</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div style="color:#64748b; font-size:14px; padding:16px 0;">Aucun service actif. Lance un service depuis le catalogue.</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Catalogue -->
|
||||||
|
<h2>Catalogue de services</h2>
|
||||||
|
<div class="grid">
|
||||||
|
{% for key, svc in catalog.items() %}
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-icon">{{ svc.icon }}</div>
|
||||||
|
<div class="card-name">{{ svc.name }}</div>
|
||||||
|
<div class="card-status">Port {{ svc.port }}</div>
|
||||||
|
<button class="btn btn-launch" onclick="launchService('{{ key }}')">▶ Lancer</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- MinIO info -->
|
||||||
|
<div class="minio-info" id="minio-block">
|
||||||
|
🪣 Ton stockage S3 : <strong>s3://user-{{ username }}/</strong> —
|
||||||
|
<a href="#" onclick="showMinioCredentials(event)" style="color:#60a5fa;">Voir les accès MinIO</a>
|
||||||
|
<div id="minio-creds" style="display:none; margin-top:12px;">
|
||||||
|
<div style="margin-bottom:6px;">
|
||||||
|
🔗 <a id="minio-url" href="#" target="_blank" style="color:#34d399;"></a>
|
||||||
|
</div>
|
||||||
|
<div style="margin-bottom:4px;">👤 Login : <span id="minio-login" style="color:#e2e8f0; font-family:monospace;"></span>
|
||||||
|
<button class="btn-copy" onclick="copyText(document.getElementById('minio-login').textContent, this)">📋</button>
|
||||||
|
</div>
|
||||||
|
<div>🔑 Password : <span id="minio-password" style="color:#e2e8f0; font-family:monospace;"></span>
|
||||||
|
<button class="btn-copy" onclick="copyText(document.getElementById('minio-password').textContent, this)">📋</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function launchService(type) {
|
||||||
|
const btn = event.target;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳ Lancement...';
|
||||||
|
const r = await fetch(`/api/launch/${type}`, { method: 'POST' });
|
||||||
|
const d = await r.json();
|
||||||
|
if (d.url) {
|
||||||
|
alert(`✅ Service lancé !\n\nURL : ${d.url}\n\nLe certificat TLS sera prêt dans ~2 minutes.`);
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Erreur lors du lancement');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '▶ Lancer';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyToken(token, btn) {
|
||||||
|
navigator.clipboard.writeText(token).then(() => {
|
||||||
|
btn.textContent = "✅ Copié !";
|
||||||
|
btn.classList.add("copied");
|
||||||
|
setTimeout(() => { btn.textContent = "📋 Copier"; btn.classList.remove("copied"); }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteService(type) {
|
||||||
|
if (!confirm('Supprimer ce service ?')) return;
|
||||||
|
await fetch(`/api/delete/${type}`, { method: 'DELETE' });
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showMinioCredentials(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const div = document.getElementById("minio-creds");
|
||||||
|
if (div.style.display === "none") {
|
||||||
|
const r = await fetch("/api/minio-credentials");
|
||||||
|
const d = await r.json();
|
||||||
|
document.getElementById("minio-url").href = d.url;
|
||||||
|
document.getElementById("minio-url").textContent = d.url;
|
||||||
|
document.getElementById("minio-login").textContent = d.access_key;
|
||||||
|
document.getElementById("minio-password").textContent = d.secret_key;
|
||||||
|
div.style.display = "block";
|
||||||
|
} else {
|
||||||
|
div.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text, btn) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
btn.textContent = "✅";
|
||||||
|
setTimeout(() => btn.textContent = "📋", 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
<button id="chat-btn" onclick="toggleChat()" style="position:fixed;bottom:24px;right:24px;z-index:9999;width:52px;height:52px;border-radius:50%;background:#3b82f6;color:#fff;border:none;cursor:pointer;font-size:22px;box-shadow:0 4px 16px rgba(59,130,246,0.4);">🤖</button>
|
||||||
|
|
||||||
|
<div id="chat-window" style="position:fixed;bottom:88px;right:24px;z-index:9998;width:360px;height:480px;background:#1e293b;border:1px solid #334155;border-radius:16px;display:none;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.4);overflow:hidden;">
|
||||||
|
<div style="padding:14px 16px;background:#0f172a;border-bottom:1px solid #334155;display:flex;align-items:center;justify-content:space-between;">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;">
|
||||||
|
<span style="font-size:18px;">🤖</span>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:14px;font-weight:600;color:#e2e8f0;">Assistant IA</div>
|
||||||
|
<div style="font-size:11px;color:#34d399;">● llama3.2:3b</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="clearChat()" style="background:none;border:none;color:#64748b;cursor:pointer;font-size:18px;">🗑</button>
|
||||||
|
</div>
|
||||||
|
<div id="chat-messages" style="flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:10px;">
|
||||||
|
<div style="background:#0f172a;border-radius:10px 10px 10px 2px;padding:10px 12px;font-size:13px;color:#94a3b8;max-width:85%;">Bonjour ! Je suis ton assistant Data Science. Pose-moi une question sur Python, pandas, SQL ou MinIO.</div>
|
||||||
|
</div>
|
||||||
|
<div style="padding:10px 12px;border-top:1px solid #334155;display:flex;gap:8px;">
|
||||||
|
<input id="chat-input" type="text" placeholder="Pose ta question..."
|
||||||
|
style="flex:1;background:#0f172a;border:1px solid #334155;border-radius:8px;color:#e2e8f0;padding:8px 12px;font-size:13px;outline:none;"/>
|
||||||
|
<button id="send-btn" onclick="sendMessage()" style="background:#3b82f6;border:none;border-radius:8px;color:#fff;padding:8px 14px;cursor:pointer;font-size:16px;">➤</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#chat-messages::-webkit-scrollbar{width:4px;}
|
||||||
|
#chat-messages::-webkit-scrollbar-thumb{background:#334155;border-radius:2px;}
|
||||||
|
.msg-user{background:#1e40af;border-radius:10px 10px 2px 10px;padding:10px 12px;font-size:13px;color:#e2e8f0;max-width:85%;align-self:flex-end;}
|
||||||
|
.msg-ai{background:#0f172a;border-radius:10px 10px 10px 2px;padding:10px 12px;font-size:13px;color:#e2e8f0;max-width:85%;white-space:pre-wrap;line-height:1.5;}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var chatHistory = [];
|
||||||
|
var chatOpen = false;
|
||||||
|
|
||||||
|
function toggleChat() {
|
||||||
|
chatOpen = !chatOpen;
|
||||||
|
var win = document.getElementById('chat-window');
|
||||||
|
var btn = document.getElementById('chat-btn');
|
||||||
|
win.style.display = chatOpen ? 'flex' : 'none';
|
||||||
|
btn.textContent = chatOpen ? 'X' : '🤖';
|
||||||
|
if (chatOpen) document.getElementById('chat-input').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearChat() {
|
||||||
|
chatHistory = [];
|
||||||
|
document.getElementById('chat-messages').innerHTML = '<div class="msg-ai">Chat effacé.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendMsg(text, type) {
|
||||||
|
var msgs = document.getElementById('chat-messages');
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = type === 'user' ? 'msg-user' : 'msg-ai';
|
||||||
|
div.textContent = text;
|
||||||
|
msgs.appendChild(div);
|
||||||
|
msgs.scrollTop = msgs.scrollHeight;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendMessage() {
|
||||||
|
var input = document.getElementById('chat-input');
|
||||||
|
var text = input.value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
input.value = '';
|
||||||
|
appendMsg(text, 'user');
|
||||||
|
chatHistory.push({role: 'user', content: text});
|
||||||
|
var thinking = appendMsg('En train de réfléchir...', 'ai');
|
||||||
|
thinking.style.color = '#64748b';
|
||||||
|
document.getElementById('send-btn').disabled = true;
|
||||||
|
fetch('/api/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({messages: chatHistory})
|
||||||
|
}).then(function(r) { return r.json(); }).then(function(d) {
|
||||||
|
thinking.remove();
|
||||||
|
var reply = d.response || 'Erreur.';
|
||||||
|
appendMsg(reply, 'ai');
|
||||||
|
chatHistory.push({role: 'assistant', content: reply});
|
||||||
|
document.getElementById('send-btn').disabled = false;
|
||||||
|
input.focus();
|
||||||
|
}).catch(function() {
|
||||||
|
thinking.textContent = "Erreur de connexion.";
|
||||||
|
thinking.style.color = '#f87171';
|
||||||
|
document.getElementById('send-btn').disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('chat-input').addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Enter') { e.preventDefault(); sendMessage(); }
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in new issue
Block a user