Phase 19: compose validate (docker compose config) + diff vs deployed in editor (0.25.0)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
menzelj
2026-06-09 11:32:55 +00:00
co-authored by Claude Opus 4.8
parent 34c5fffa85
commit 9c4d319f8f
4 changed files with 108 additions and 5 deletions
+12
View File
@@ -7,10 +7,16 @@ from pydantic import BaseModel
from auth import get_current_user from auth import get_current_user
from models.user import User from models.user import User
from services import compose_edit_service as edit from services import compose_edit_service as edit
from services import compose_service
router = APIRouter(prefix="/api/editor", tags=["editor"]) router = APIRouter(prefix="/api/editor", tags=["editor"])
class ValidateBody(BaseModel):
yaml: str
env: str = ""
class AddVolumeBody(BaseModel): class AddVolumeBody(BaseModel):
yaml: str yaml: str
service: str service: str
@@ -52,6 +58,12 @@ def _run(fn, *args) -> dict:
raise HTTPException(status_code=400, detail=str(exc)) from exc 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") @router.post("/services")
def services(body: dict, _user: User = Depends(get_current_user)) -> dict: def services(body: dict, _user: User = Depends(get_current_user)) -> dict:
try: try:
+32
View File
@@ -9,6 +9,7 @@ import asyncio
import os import os
import re import re
import shutil import shutil
import tempfile
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional 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( async def stream_compose(
stack_id: str, args: list[str], override: Optional[str] = None stack_id: str, args: list[str], override: Optional[str] = None
): ):
+4
View File
@@ -1,6 +1,10 @@
import api from "./client"; import api from "./client";
export const editorApi = { 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) => services: (yaml: string) =>
api api
.post<{ services: string[] }>("/api/editor/services", { yaml }) .post<{ services: string[] }>("/api/editor/services", { yaml })
+60 -5
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import Editor from "@monaco-editor/react"; import Editor, { DiffEditor } from "@monaco-editor/react";
import { Rocket, Save, Wand2, FileCode } from "lucide-react"; import { Rocket, Save, Wand2, FileCode, CheckCircle2, GitCompare, X } from "lucide-react";
import { Button, Card, Input } from "@/components/ui"; import { Button, Card, Input } from "@/components/ui";
import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel"; import { EditorHelperPanel } from "@/components/stacks/EditorHelperPanel";
import { EnvEditor } from "@/components/env/EnvEditor"; import { EnvEditor } from "@/components/env/EnvEditor";
@@ -10,6 +10,7 @@ import { PortConflictDialog } from "@/components/stacks/PortConflictDialog";
import { DeployConsole } from "@/components/stacks/DeployConsole"; import { DeployConsole } from "@/components/stacks/DeployConsole";
import { stacksApi } from "@/api/stacks"; import { stacksApi } from "@/api/stacks";
import { agentsApi } from "@/api/agents"; import { agentsApi } from "@/api/agents";
import { editorApi } from "@/api/editor";
import { portsApi, type PortConflict } from "@/api/ports"; import { portsApi, type PortConflict } from "@/api/ports";
import { apiErrorMessage } from "@/api/client"; import { apiErrorMessage } from "@/api/client";
import { useThemeStore } from "@/store/theme"; import { useThemeStore } from "@/store/theme";
@@ -43,6 +44,9 @@ export function StackEditor() {
const [host, setHost] = useState("local"); const [host, setHost] = useState("local");
const [deployId, setDeployId] = useState<string | null>(null); const [deployId, setDeployId] = useState<string | null>(null);
const [deployAgentId, setDeployAgentId] = useState<number | undefined>(undefined); const [deployAgentId, setDeployAgentId] = useState<number | undefined>(undefined);
const [validating, setValidating] = useState(false);
const [validation, setValidation] = useState<{ ok: boolean; errors: string } | null>(null);
const [showDiff, setShowDiff] = useState(false);
const existing = useQuery({ const existing = useQuery({
queryKey: ["stack", id], queryKey: ["stack", id],
@@ -135,6 +139,23 @@ export function StackEditor() {
save(true); 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 () => { const convert = async () => {
try { try {
const { yaml: converted } = await stacksApi.convert(runCmd); const { yaml: converted } = await stacksApi.convert(runCmd);
@@ -206,7 +227,21 @@ export function StackEditor() {
<div className="flex min-h-0 flex-1 gap-3"> <div className="flex min-h-0 flex-1 gap-3">
{/* Editor */} {/* Editor */}
<div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700"> <div className="min-h-0 flex-1 overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
{tab === "compose" ? ( {tab === "compose" && showDiff ? (
<DiffEditor
height="100%"
language="yaml"
theme={theme === "dark" ? "vs-dark" : "light"}
original={originalYaml}
modified={yaml}
options={{
minimap: { enabled: false },
fontSize: 13,
readOnly: true,
renderSideBySide: true,
}}
/>
) : tab === "compose" ? (
<Editor <Editor
height="100%" height="100%"
language="yaml" language="yaml"
@@ -223,14 +258,34 @@ export function StackEditor() {
</div> </div>
{/* Helper panel (Volumes / GPU / Devices) */} {/* Helper panel (Volumes / GPU / Devices) */}
{tab === "compose" && ( {tab === "compose" && !showDiff && (
<div className="w-[38%] min-w-[320px] overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700"> <div className="w-[38%] min-w-[320px] overflow-hidden rounded-lg border border-slate-200 dark:border-slate-700">
<EditorHelperPanel yaml={yaml} onYaml={setYaml} /> <EditorHelperPanel yaml={yaml} onYaml={setYaml} />
</div> </div>
)} )}
</div> </div>
<div className="flex justify-end gap-2"> {validation && !validation.ok && (
<Card className="border-red-300 bg-red-50 dark:border-red-900/60 dark:bg-red-950/30">
<div className="mb-1 text-sm font-medium text-red-600 dark:text-red-400">
Compose config errors
</div>
<pre className="max-h-40 overflow-auto whitespace-pre-wrap text-xs text-red-700 dark:text-red-300">
{validation.errors}
</pre>
</Card>
)}
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={validate} loading={validating}>
<CheckCircle2 className="h-4 w-4" /> Validate
</Button>
{dirty && (
<Button variant="outline" onClick={() => setShowDiff((v) => !v)}>
{showDiff ? <X className="h-4 w-4" /> : <GitCompare className="h-4 w-4" />}
{showDiff ? "Hide diff" : "Diff vs deployed"}
</Button>
)}
<Button variant="outline" onClick={() => save(false)} loading={saving}> <Button variant="outline" onClick={() => save(false)} loading={saving}>
<Save className="h-4 w-4" /> Save Draft <Save className="h-4 w-4" /> Save Draft
</Button> </Button>