diff --git a/backend/routers/editor.py b/backend/routers/editor.py index 1346b33..b595790 100644 --- a/backend/routers/editor.py +++ b/backend/routers/editor.py @@ -7,10 +7,16 @@ from pydantic import BaseModel from auth import get_current_user from models.user import User from services import compose_edit_service as edit +from services import compose_service router = APIRouter(prefix="/api/editor", tags=["editor"]) +class ValidateBody(BaseModel): + yaml: str + env: str = "" + + class AddVolumeBody(BaseModel): yaml: str service: str @@ -52,6 +58,12 @@ def _run(fn, *args) -> dict: raise HTTPException(status_code=400, detail=str(exc)) from exc +@router.post("/validate") +async def validate(body: ValidateBody, _user: User = Depends(get_current_user)) -> dict: + """Run `docker compose config -q` on the supplied YAML (+ optional .env).""" + return await compose_service.validate_yaml(body.yaml, body.env) + + @router.post("/services") def services(body: dict, _user: User = Depends(get_current_user)) -> dict: try: diff --git a/backend/services/compose_service.py b/backend/services/compose_service.py index 2aa1629..4a5c5b6 100644 --- a/backend/services/compose_service.py +++ b/backend/services/compose_service.py @@ -9,6 +9,7 @@ import asyncio import os import re import shutil +import tempfile from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Optional @@ -313,6 +314,37 @@ async def run_compose( } +async def validate_yaml(content: str, env_content: str = "") -> dict: + """Validate compose YAML by running ``docker compose config -q`` against it + in a throwaway directory. Returns ``{"ok": bool, "errors": str}`` — the + errors string carries compose's own diagnostics (parse errors, unknown + keys, bad interpolation) so the editor can surface them before saving.""" + with tempfile.TemporaryDirectory(prefix="sp-validate-") as tmp: + compose_file = os.path.join(tmp, DEFAULT_COMPOSE_NAME) + with open(compose_file, "w", encoding="utf-8") as fh: + fh.write(content) + if env_content: + with open(os.path.join(tmp, ".env"), "w", encoding="utf-8") as fh: + fh.write(env_content) + cmd = [ + "docker", "compose", + "--project-directory", tmp, + "-f", compose_file, + "config", "-q", + ] + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + _out, err = await asyncio.wait_for(proc.communicate(), timeout=30.0) + except asyncio.TimeoutError as exc: + raise StackFileError("compose config validation timed out") from exc + ok = proc.returncode == 0 + return {"ok": ok, "errors": "" if ok else err.decode("utf-8", "replace").strip()} + + async def stream_compose( stack_id: str, args: list[str], override: Optional[str] = None ): diff --git a/frontend/src/api/editor.ts b/frontend/src/api/editor.ts index d052602..4fa6cb1 100644 --- a/frontend/src/api/editor.ts +++ b/frontend/src/api/editor.ts @@ -1,6 +1,10 @@ import api from "./client"; export const editorApi = { + validate: (yaml: string, env: string) => + api + .post<{ ok: boolean; errors: string }>("/api/editor/validate", { yaml, env }) + .then((r) => r.data), services: (yaml: string) => api .post<{ services: string[] }>("/api/editor/services", { yaml }) diff --git a/frontend/src/pages/StackEditor.tsx b/frontend/src/pages/StackEditor.tsx index 360c755..029bd2c 100644 --- a/frontend/src/pages/StackEditor.tsx +++ b/frontend/src/pages/StackEditor.tsx @@ -1,8 +1,8 @@ import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import Editor from "@monaco-editor/react"; -import { Rocket, Save, Wand2, FileCode } from "lucide-react"; +import Editor, { DiffEditor } from "@monaco-editor/react"; +import { Rocket, Save, Wand2, FileCode, CheckCircle2, GitCompare, X } from "lucide-react"; import { Button, Card, Input } from "@/components/ui"; import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel"; import { EnvEditor } from "@/components/env/EnvEditor"; @@ -10,6 +10,7 @@ import { PortConflictDialog } from "@/components/stacks/PortConflictDialog"; import { DeployConsole } from "@/components/stacks/DeployConsole"; import { stacksApi } from "@/api/stacks"; import { agentsApi } from "@/api/agents"; +import { editorApi } from "@/api/editor"; import { portsApi, type PortConflict } from "@/api/ports"; import { apiErrorMessage } from "@/api/client"; import { useThemeStore } from "@/store/theme"; @@ -43,6 +44,9 @@ export function StackEditor() { const [host, setHost] = useState("local"); const [deployId, setDeployId] = useState(null); const [deployAgentId, setDeployAgentId] = useState(undefined); + const [validating, setValidating] = useState(false); + const [validation, setValidation] = useState<{ ok: boolean; errors: string } | null>(null); + const [showDiff, setShowDiff] = useState(false); const existing = useQuery({ queryKey: ["stack", id], @@ -135,6 +139,23 @@ export function StackEditor() { save(true); }; + const originalYaml = existing.data?.yaml ?? ""; + const dirty = !isNew && originalYaml !== yaml; + + const validate = async () => { + setValidating(true); + setValidation(null); + try { + const result = await editorApi.validate(yaml, env); + setValidation(result); + if (result.ok) toast.success("Compose config is valid"); + } catch (err) { + toast.error(apiErrorMessage(err)); + } finally { + setValidating(false); + } + }; + const convert = async () => { try { const { yaml: converted } = await stacksApi.convert(runCmd); @@ -206,7 +227,21 @@ export function StackEditor() {
{/* Editor */}
- {tab === "compose" ? ( + {tab === "compose" && showDiff ? ( + + ) : tab === "compose" ? ( {/* Helper panel (Volumes / GPU / Devices) */} - {tab === "compose" && ( + {tab === "compose" && !showDiff && (
)}
-
+ {validation && !validation.ok && ( + +
+ Compose config errors +
+
+            {validation.errors}
+          
+
+ )} + +
+ + {dirty && ( + + )}