diff --git a/ROADMAP.md b/ROADMAP.md index a926cc9..a0e0fee 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -404,9 +404,11 @@ reaches the upstream container and appears in the analytics tables. with provider badge, VM/LXC lifecycle actions. - [ ] **Environments** (per §2.4): env switcher, per-env variables/domains, promote flow — ships with preview environments. -- [ ] **Service exec console**: `docker exec` machinery now exists - (`Client.ExecRun` for cron) — expose a per-service web terminal or - one-off command runner on the service detail page. +- [x] **Service exec console**: `POST /services/:id/exec` + Console section + on service detail — one-off `sh -c` commands via `ExecRun` (30s cap, + 64KB output), ownership-checked, captured output + exit code per run. + Verified live: real container exec, stderr capture, 404 foreign + service, 401 unauthenticated. Interactive PTY remains out of scope. **Gate**: scaling and failover demonstrably work against a two-node lab; security scans produce real reports, not empty tables. diff --git a/app/backend/internal/api/exec.go b/app/backend/internal/api/exec.go new file mode 100644 index 0000000..09a125d --- /dev/null +++ b/app/backend/internal/api/exec.go @@ -0,0 +1,79 @@ +package api + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + "containr/internal/database" + "containr/internal/docker" +) + +// handleExecInService runs a one-off shell command inside the service's +// running container and returns captured output. Intended for debugging; +// commands run as the container's default user with a 30s ceiling. +func handleExecInService(c *gin.Context) { + serviceID := c.Param("id") + userID := c.MustGet("user_id").(string) + db := c.MustGet("db").(*database.DB) + + var req struct { + Command string `json:"command" binding:"required"` + } + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + command := strings.TrimSpace(req.Command) + if command == "" || len(command) > 4096 { + c.JSON(http.StatusBadRequest, gin.H{"error": "command must be 1-4096 chars"}) + return + } + + var ownerID string + err := db.QueryRow( + `SELECT p.owner_id FROM services s + JOIN projects p ON p.id = s.project_id + WHERE s.id = $1`, + serviceID, + ).Scan(&ownerID) + if err != nil || ownerID != userID { + c.JSON(http.StatusNotFound, gin.H{"error": "Service not found"}) + return + } + + dockerClient, _ := c.Get("docker_client") + dockerCli, _ := dockerClient.(*docker.Client) + if dockerCli == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Docker unavailable"}) + return + } + + ctx, cancel := context.WithTimeout(c.Request.Context(), 30*time.Second) + defer cancel() + + containerID, err := findServiceContainer(ctx, dockerCli, serviceID) + if err != nil { + c.JSON(http.StatusConflict, gin.H{"error": "No running container for this service"}) + return + } + + output, exitCode, runErr := dockerCli.ExecRun(ctx, containerID, []string{"sh", "-c", command}) + if len(output) > 64*1024 { + output = output[:64*1024] + } + + errText := "" + if runErr != nil { + errText = runErr.Error() + } + + c.JSON(http.StatusOK, gin.H{ + "output": output, + "exit_code": exitCode, + "error": errText, + }) +} diff --git a/app/backend/internal/api/routes.go b/app/backend/internal/api/routes.go index 37c49c4..431fe42 100644 --- a/app/backend/internal/api/routes.go +++ b/app/backend/internal/api/routes.go @@ -211,6 +211,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg protected.GET("/services/:id/logs", handleGetLogs) protected.GET("/deployments/:id/logs", handleGetDeploymentLogs) + // One-off exec console (docker exec, 30s ceiling) + protected.POST("/services/:id/exec", handleExecInService) + // Git integration routes protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL) protected.POST("/git/github-app/connect", handleConnectGitHubApp) diff --git a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx index 5b40144..cdd30c9 100644 --- a/app/frontend/src/features/service/pages/ServiceDetailPage.tsx +++ b/app/frontend/src/features/service/pages/ServiceDetailPage.tsx @@ -26,6 +26,7 @@ import { deleteScalingPolicy, getServiceScalingState, manualScaleService, + execInService, rollbackDeployment, updateServiceVariables, type CronJobEntity, @@ -60,9 +61,10 @@ import { ChevronDown, GitPullRequest, ExternalLink, + Terminal, } from 'lucide-react'; -type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'settings'; +type ServiceSection = 'metrics' | 'logs' | 'config' | 'variables' | 'cron' | 'previews' | 'scaling' | 'console' | 'settings'; const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [ { key: 'metrics', label: 'Metrics', icon: Activity }, @@ -72,6 +74,7 @@ const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Act { key: 'cron', label: 'Cron', icon: Clock }, { key: 'previews', label: 'Previews', icon: GitPullRequest }, { key: 'scaling', label: 'Scaling', icon: Layers }, + { key: 'console', label: 'Console', icon: Terminal }, { key: 'settings', label: 'Settings', icon: Settings }, ]; @@ -283,6 +286,22 @@ export function ServiceDetailPage() { mutationFn: (replicas: number) => manualScaleService(serviceId, replicas), onSuccess: invalidateScaling, }); + + // --- Console --- + const [consoleInput, setConsoleInput] = useState(''); + const [consoleHistory, setConsoleHistory] = useState< + Array<{ command: string; output: string; exitCode: number; error: string }> + >([]); + const execMutation = useMutation({ + mutationFn: (command: string) => execInService(serviceId, command), + onSuccess: (result, command) => { + setConsoleHistory((prev) => [ + { command, output: result.output ?? '', exitCode: result.exit_code ?? 0, error: result.error ?? '' }, + ...prev, + ]); + setConsoleInput(''); + }, + }); const triggerCronMutation = useMutation({ mutationFn: (id: string) => triggerCronJob(id), onSuccess: () => { @@ -1660,6 +1679,75 @@ export function ServiceDetailPage() { )} + {activeSection === 'console' && ( +
+
+
+ +
+
+

Console

+

One-off commands via docker exec — 30s limit, 64KB output cap

+
+
+ +
{ + e.preventDefault(); + const cmd = consoleInput.trim(); + if (cmd && !execMutation.isPending) execMutation.mutate(cmd); + }} + > + $ + setConsoleInput(e.target.value)} + placeholder="e.g. env | sort | head -20" + disabled={execMutation.isPending} + className="flex-1 px-3 py-2 rounded-[var(--radius-md)] bg-[var(--bg-secondary)] border border-[var(--border-primary)] text-sm font-mono text-[var(--text-primary)] disabled:opacity-50" + /> + +
+ + {execMutation.isError && ( +

+ {(execMutation.error as Error)?.message ?? 'Command failed'} +

+ )} + + {consoleHistory.length === 0 ? ( +

+ Commands run inside the service's running container as its default user. No interactive shell — stdout/stderr is captured and returned. +

+ ) : ( +
+ {consoleHistory.map((entry, i) => ( +
+
+

$ {entry.command}

+ + {entry.error ? 'error' : `exit ${entry.exitCode}`} + +
+ {(entry.output || entry.error) && ( +
+                        {entry.output}{entry.error ? `\n${entry.error}` : ''}
+                      
+ )} +
+ ))} +
+ )} +
+ )} + {activeSection === 'settings' && (
diff --git a/app/frontend/src/generated/api-types.ts b/app/frontend/src/generated/api-types.ts index bce01de..459683f 100644 --- a/app/frontend/src/generated/api-types.ts +++ b/app/frontend/src/generated/api-types.ts @@ -809,6 +809,87 @@ export interface paths { patch?: never; trace?: never; }; + "/services/{id}/exec": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Execute command in service container + * @description Runs a one-off shell command via docker exec and returns captured output. 30s ceiling, 64KB output cap. For debugging only. + */ + post: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Service ID */ + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Shell command (executed via sh -c) */ + command: string; + }; + }; + }; + responses: { + /** @description Command executed (check exit_code/error for failure) */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + output?: string; + exit_code?: number; + error?: string; + }; + }; + }; + /** @description Invalid request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Service not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No running container for this service */ + 409: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Docker unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/deployments/{id}/logs": { parameters: { query?: never; diff --git a/app/frontend/src/lib/api-client.ts b/app/frontend/src/lib/api-client.ts index 5cbb325..8021c02 100644 --- a/app/frontend/src/lib/api-client.ts +++ b/app/frontend/src/lib/api-client.ts @@ -1658,3 +1658,12 @@ export async function updateVulnerability(id: string, status: 'open' | 'resolved export async function getSecurityMetrics(projectId: string): Promise { return requestJson(`/projects/${encodeURIComponent(projectId)}/security/metrics`); } + +export type ExecResult = { output?: string; exit_code?: number; error?: string }; + +export async function execInService(serviceId: string, command: string): Promise { + return requestJson(`/services/${encodeURIComponent(serviceId)}/exec`, { + method: 'POST', + body: JSON.stringify({ command }), + }); +} diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c0e4acc..7f398ac 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -561,6 +561,57 @@ paths: schema: $ref: '#/components/schemas/ServiceLogsResponse' + /services/{id}/exec: + post: + tags: + - Services + summary: Execute command in service container + description: Runs a one-off shell command via docker exec and returns captured output. 30s ceiling, 64KB output cap. For debugging only. + security: + - bearerAuth: [] + parameters: + - name: id + in: path + required: true + description: Service ID + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - command + properties: + command: + type: string + description: Shell command (executed via sh -c) + maxLength: 4096 + responses: + '200': + description: Command executed (check exit_code/error for failure) + content: + application/json: + schema: + type: object + properties: + output: + type: string + exit_code: + type: integer + error: + type: string + '400': + description: Invalid request + '404': + description: Service not found + '409': + description: No running container for this service + '503': + description: Docker unavailable + /deployments/{id}/logs: get: tags: