diff --git a/backend/src/__tests__/convert.test.ts b/backend/src/__tests__/convert.test.ts new file mode 100644 index 00000000..53165f9d --- /dev/null +++ b/backend/src/__tests__/convert.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for the authenticated `POST /api/convert` endpoint that wraps the + * composerize library. Verifies input validation, auth gating, graceful + * handling of malformed commands, and output shape. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let cookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); + cookie = await loginAsTestAdmin(app); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +describe('POST /api/convert', () => { + describe('auth', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app) + .post('/api/convert') + .send({ dockerRun: 'docker run nginx' }); + expect(res.status).toBe(401); + }); + }); + + describe('happy path', () => { + it('converts a simple docker run command', async () => { + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: 'docker run nginx' }); + expect(res.status).toBe(200); + expect(typeof res.body.yaml).toBe('string'); + expect(res.body.yaml).toContain('services:'); + expect(res.body.yaml).toContain('nginx'); + }); + + it('handles common flags (-p, -v, -e, --name, --restart)', async () => { + const cmd = + 'docker run -d --name web -p 8080:80 -v /data:/usr/share/nginx/html -e TZ=UTC --restart unless-stopped nginx:alpine'; + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: cmd }); + expect(res.status).toBe(200); + expect(res.body.yaml).toContain('services:'); + expect(res.body.yaml).toContain('web'); + expect(res.body.yaml).toContain('nginx:alpine'); + expect(res.body.yaml).toContain('8080:80'); + }); + + it('handles --label and --network flags', async () => { + const cmd = + 'docker run --name api --label com.example.app=api --network bridge redis:7'; + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: cmd }); + expect(res.status).toBe(200); + expect(res.body.yaml).toContain('services:'); + expect(res.body.yaml).toContain('redis:7'); + }); + + it('trims surrounding whitespace', async () => { + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: ' \n docker run nginx \n ' }); + expect(res.status).toBe(200); + expect(res.body.yaml).toContain('services:'); + }); + }); + + describe('input validation', () => { + it('rejects missing body', async () => { + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({}); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/string/i); + }); + + it('rejects empty string', async () => { + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: '' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/required/i); + }); + + it('rejects whitespace-only input', async () => { + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: ' \n\t ' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/required/i); + }); + + it('rejects non-string input', async () => { + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: 12345 }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/string/i); + }); + + it('accepts input at the 8192-char boundary', async () => { + const prefix = 'docker run nginx '; + const filler = 'a'.repeat(8192 - prefix.length); + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: prefix + filler }); + // Either composerize accepts (200) or rejects as unparseable (422), but never + // a 400 "too long" at exactly the max length. + expect([200, 422]).toContain(res.status); + }); + + it('rejects input one byte over the 8192-char boundary', async () => { + const prefix = 'docker run nginx '; + const filler = 'a'.repeat(8193 - prefix.length); + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: prefix + filler }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/too long/i); + }); + + it('rejects oversized input (>8192 chars)', async () => { + const big = 'docker run ' + 'x'.repeat(9000); + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: big }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/too long/i); + }); + + it('rejects input with a trailing null byte', async () => { + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: 'docker run nginx\0' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid/i); + }); + + it('rejects input with a leading null byte', async () => { + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: '\0docker run nginx' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid/i); + }); + + it('rejects input with an embedded null byte', async () => { + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: 'docker run \0nginx' }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/invalid/i); + }); + }); + + describe('malformed commands', () => { + it('returns 422 when composerize cannot produce services', async () => { + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: 'this is not a docker run command' }); + expect(res.status).toBe(422); + expect(res.body.error).toMatch(/parse|supported/i); + }); + + it('returns 422 for pure gibberish', async () => { + const res = await request(app) + .post('/api/convert') + .set('Cookie', cookie) + .send({ dockerRun: '!!!@@@###$$$' }); + expect(res.status).toBe(422); + }); + }); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index 533bd908..c254eef3 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -4947,19 +4947,47 @@ app.get('/api/stacks/:stackName/backup', async (req: Request, res: Response) => } }); -// Docker Run to Compose converter endpoint -app.post('/api/convert', async (req: Request, res: Response) => { +// Docker Run to Compose converter endpoint. +// Accepts a raw `docker run ...` command and returns the equivalent compose +// YAML as a string. Authenticated, input-validated, and resilient to +// composerize throws / malformed output. +const MAX_DOCKER_RUN_LENGTH = 8192; +app.post('/api/convert', authMiddleware, async (req: Request, res: Response): Promise => { + const { dockerRun } = req.body ?? {}; + if (typeof dockerRun !== 'string') { + res.status(400).json({ error: 'dockerRun must be a string' }); + return; + } + const trimmed = dockerRun.trim(); + if (trimmed.length === 0) { + res.status(400).json({ error: 'dockerRun command is required' }); + return; + } + if (trimmed.length > MAX_DOCKER_RUN_LENGTH) { + res.status(400).json({ error: `dockerRun command is too long (max ${MAX_DOCKER_RUN_LENGTH} characters)` }); + return; + } + if (trimmed.includes('\0')) { + res.status(400).json({ error: 'dockerRun command contains invalid characters' }); + return; + } + + let yaml: unknown; try { - const { dockerRun } = req.body; - if (!dockerRun || typeof dockerRun !== 'string') { - return res.status(400).json({ error: 'dockerRun command is required' }); - } - const yaml = composerize(dockerRun); - res.json({ yaml }); + yaml = composerize(trimmed); } catch (error) { console.error('Conversion error:', error); - res.status(500).json({ error: 'Failed to convert docker run command' }); + res.status(422).json({ error: 'Could not parse command. Check syntax and supported flags.' }); + return; } + + if (typeof yaml !== 'string' || !yaml.includes('services:')) { + console.warn('Converter produced unexpected output for input:', trimmed.slice(0, 200)); + res.status(422).json({ error: 'Could not parse command. Check syntax and supported flags.' }); + return; + } + + res.json({ yaml }); }); // Get all containers stats for dashboard. diff --git a/docs/features/stack-management.mdx b/docs/features/stack-management.mdx index 6bda07b3..79d8d074 100644 --- a/docs/features/stack-management.mdx +++ b/docs/features/stack-management.mdx @@ -20,6 +20,72 @@ Click **Create Stack** in the left sidebar. Enter a name and click **Create**. Sencho creates a new directory inside `COMPOSE_DIR` with a blank `compose.yaml` file. You'll land in the editor automatically. +## Convert from a docker run command + +If you have a `docker run` command handy (from a README, a forum post, or your shell history), Sencho can turn it into a ready-to-deploy `compose.yaml` without hand translation. + +Open the **Create New Stack** dialog and switch to the **From Docker Run** tab. + + + Create stack dialog on the From Docker Run tab + + +### How to use it + +1. Enter a **Stack Name**. Same rules as the Empty tab (lowercase, hyphens, unique). +2. Paste your full command into **Paste your docker run command**. The whole command should be on one logical line; line continuations with `\` are fine. +3. Click **Convert**. Sencho parses the command and shows the resulting compose YAML in a read-only preview below. +4. Review the YAML. If it looks right, click **Create Stack**. Sencho creates the stack directory and writes the converted YAML into `compose.yaml`. +5. The editor opens on the new stack. Click **Start** to deploy it. + + + Converted compose YAML preview before creating the stack + + + + Newly created stack loaded in the editor after conversion + + +### Supported flags + +The converter handles the flags you reach for most often: + +| Flag | Purpose | +|------|---------| +| `-d` / `--detach` | Detached mode (implied by compose) | +| `--name` | Container name | +| `-p` / `--publish` | Port mappings (`host:container`) | +| `-v` / `--volume` | Volumes and bind mounts | +| `-e` / `--env` | Environment variables | +| `--env-file` | Env file reference | +| `--restart` | Restart policy (`no`, `always`, `unless-stopped`, `on-failure`) | +| `--network` | Attach to a named network | +| `--label` | Container labels | +| `--user` | Run as a specific user | +| `--workdir` | Set the working directory | +| `--entrypoint` | Override the entrypoint | +| `--cap-add` / `--cap-drop` | Linux capabilities | +| `--privileged` | Privileged mode | +| `--read-only` | Read-only root filesystem | +| `--tmpfs` | Temporary filesystem mount | + +The image tag is taken from the final positional argument (for example `nginx:alpine`). Any trailing command arguments are preserved as the service `command`. + +### Troubleshooting + + + Error toast shown when the converter cannot parse the input + + +If the converter cannot produce a usable compose file, the request fails with a clear error toast. Common causes: + +- **Not a docker run command.** The input must begin with `docker run` and include an image reference. Free-form text, `docker compose` commands, and shell pipelines are rejected. +- **Unrecognized flag.** The parser supports the flags listed above. Rare flags (such as `--userns`, `--ipc`, custom runtime options) may not be recognized. Remove the flag, convert the rest, and add it back by hand in the editor. +- **Quoting issues.** Multi-line commands with embedded quotes sometimes confuse the parser. Paste the command as a single line with escaped quotes, or simplify the command first. +- **Command is too long.** The endpoint accepts commands up to 8192 characters. Longer inputs are rejected; trim anything you do not need and convert in pieces. + +When a flag is not supported, paste the output you do get into the **Empty** tab as a starting point and fill in the rest by editing `compose.yaml` directly. + ## The stack list All discovered stacks appear in the left sidebar. Each shows a color-coded status indicator: diff --git a/docs/images/stack-management/convert-stack-created.png b/docs/images/stack-management/convert-stack-created.png new file mode 100644 index 00000000..35063520 Binary files /dev/null and b/docs/images/stack-management/convert-stack-created.png differ diff --git a/docs/images/stack-management/convert-tab-empty.png b/docs/images/stack-management/convert-tab-empty.png new file mode 100644 index 00000000..f44ba87a Binary files /dev/null and b/docs/images/stack-management/convert-tab-empty.png differ diff --git a/docs/images/stack-management/convert-tab-error.png b/docs/images/stack-management/convert-tab-error.png new file mode 100644 index 00000000..3a7b1e05 Binary files /dev/null and b/docs/images/stack-management/convert-tab-error.png differ diff --git a/docs/images/stack-management/convert-tab-result.png b/docs/images/stack-management/convert-tab-result.png new file mode 100644 index 00000000..16b54e71 Binary files /dev/null and b/docs/images/stack-management/convert-tab-result.png differ diff --git a/docs/operations/troubleshooting.mdx b/docs/operations/troubleshooting.mdx index bee69ee6..a7fddcb5 100644 --- a/docs/operations/troubleshooting.mdx +++ b/docs/operations/troubleshooting.mdx @@ -561,3 +561,20 @@ docker logs -f sencho ``` The backend logs all route errors and service failures to stdout. This is the first place to look when the UI shows an error with no useful message. + +--- + +## "Could not parse command" when converting docker run + +**Symptom:** The **From Docker Run** tab in the Create Stack dialog returns an error toast saying the command could not be parsed. + +**Cause:** The input is not a well-formed `docker run` command, or it uses a flag the converter does not recognize. + +**Fix:** + +- Make sure the command starts with `docker run` and ends with an image reference (e.g. `nginx:alpine`). +- Check the [list of supported flags](/features/stack-management#supported-flags). Rare flags (such as `--userns` or custom runtime options) are not recognized. Remove the flag before converting and add it back manually in the editor afterwards. +- Collapse multi-line commands into a single line. Mixed quoting across `\`-continued lines is a common source of parse errors. +- Commands longer than 8192 characters are rejected. Trim anything non-essential and run the converter on the reduced command. + +When only part of a command is supported, convert what you can, paste the resulting YAML into the **Empty** tab as a starting point, and hand-edit the rest in `compose.yaml`. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index a4217239..9363201f 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -21,7 +21,7 @@ import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highli import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, Bell, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2 } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { LabelPill, LabelDot } from './LabelPill'; import { type Label as StackLabel } from './label-types'; @@ -131,9 +131,14 @@ export default function EditorLayout() { const monacoEditorRef = useRef(null); const pendingStackLoadRef = useRef(null); const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [createMode, setCreateMode] = useState<'empty' | 'git'>('empty'); + const [createMode, setCreateMode] = useState<'empty' | 'git' | 'docker-run'>('empty'); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [newStackName, setNewStackName] = useState(''); + // "From Docker Run" tab state + const [dockerRunInput, setDockerRunInput] = useState(''); + const [convertedYaml, setConvertedYaml] = useState(null); + const [isConverting, setIsConverting] = useState(false); + const [creatingFromDockerRun, setCreatingFromDockerRun] = useState(false); // "From Git" tab state const [gitRepoUrl, setGitRepoUrl] = useState(''); const [gitBranch, setGitBranch] = useState('main'); @@ -1401,6 +1406,115 @@ export default function EditorLayout() { } }; + const resetCreateFromDockerRunForm = () => { + setDockerRunInput(''); + setConvertedYaml(null); + setIsConverting(false); + setCreatingFromDockerRun(false); + }; + + const handleConvertDockerRun = async () => { + const command = dockerRunInput.trim(); + if (!command) { + toast.error('Paste a docker run command first.'); + return; + } + setIsConverting(true); + try { + const response = await apiFetch('/convert', { + method: 'POST', + body: JSON.stringify({ dockerRun: command }), + }); + const data = await response.json().catch(() => ({})); + if (!response.ok) { + throw new Error(data?.error || 'Could not parse command.'); + } + if (typeof data?.yaml !== 'string' || data.yaml.length === 0) { + throw new Error('Converter returned an empty result.'); + } + setConvertedYaml(data.yaml); + toast.success('Converted to compose YAML.'); + } catch (error) { + setConvertedYaml(null); + const err = error as { message?: string; error?: string; data?: { error?: string } }; + toast.error( + err?.message || + err?.error || + err?.data?.error || + 'Failed to convert docker run command.', + ); + } finally { + setIsConverting(false); + } + }; + + const handleCreateStackFromDockerRun = async () => { + const stackName = newStackName.trim(); + if (!stackName) { + toast.error('Stack name is required.'); + return; + } + if (!convertedYaml) { + toast.error('Convert the command before creating the stack.'); + return; + } + setCreatingFromDockerRun(true); + const loadingId = toast.loading('Creating stack from converted YAML...'); + let createdStack = false; + try { + const createResponse = await apiFetch('/stacks', { + method: 'POST', + body: JSON.stringify({ stackName }), + }); + if (!createResponse.ok) { + if (createResponse.status === 409) { + throw new Error('Stack already exists.'); + } + if (createResponse.status === 400) { + throw new Error('Invalid stack name (use alphanumeric characters and hyphens only).'); + } + throw new Error('Failed to create stack.'); + } + createdStack = true; + + const saveResponse = await apiFetch(`/stacks/${encodeURIComponent(stackName)}`, { + method: 'PUT', + body: JSON.stringify({ content: convertedYaml }), + }); + if (!saveResponse.ok) { + // Roll back the empty stack we just created so we don't leave an orphan. + await apiFetch(`/stacks/${encodeURIComponent(stackName)}`, { method: 'DELETE' }).catch((cleanupError) => { + console.error('Failed to roll back orphan stack after save failure:', cleanupError); + }); + createdStack = false; + throw new Error('Could not save the converted YAML. Please try again.'); + } + + toast.success(`Stack "${stackName}" created from docker run.`); + setCreateDialogOpen(false); + resetCreateFromDockerRunForm(); + setNewStackName(''); + await refreshStacks(); + await loadFile(stackName); + } catch (error) { + console.error('Failed to create stack from docker run:', error); + const err = error as { message?: string; error?: string; data?: { error?: string } }; + toast.error( + err?.message || + err?.error || + err?.data?.error || + 'Failed to create stack from docker run.', + ); + // If we bailed before the createdStack flag got reset, surface that the stack still exists. + if (createdStack) { + await refreshStacks().catch(() => undefined); + } + } finally { + toast.dismiss(loadingId); + setCreatingFromDockerRun(false); + } + }; + const openBashModal = (containerId: string, containerName: string) => { setSelectedContainer({ id: containerId, name: containerName }); setBashModalOpen(true); @@ -1532,6 +1646,7 @@ export default function EditorLayout() { if (!o) { setCreateMode('empty'); resetCreateFromGitForm(); + resetCreateFromDockerRunForm(); } }}> @@ -1544,12 +1659,12 @@ export default function EditorLayout() { Create New Stack - Create a new stack, either empty or cloned from a Git repository. + Create a new stack: empty, cloned from a Git repository, or converted from a docker run command.
- setCreateMode(v as 'empty' | 'git')}> + setCreateMode(v as 'empty' | 'git' | 'docker-run')}> @@ -1564,12 +1679,18 @@ export default function EditorLayout() { From Git + + + + From Docker Run + +
- {createMode === 'empty' ? ( + {createMode === 'empty' && ( <>
@@ -1584,7 +1705,8 @@ export default function EditorLayout() { - ) : ( + )} + {createMode === 'git' && ( <>
@@ -1643,6 +1765,77 @@ export default function EditorLayout() { )} + {createMode === 'docker-run' && ( + <> + +
+
+ + setNewStackName(e.target.value)} + disabled={creatingFromDockerRun} + /> +
+
+ +