feat: service exec console

POST /services/:id/exec runs a one-off sh -c command inside the
service's container via ExecRun (30s ceiling, 64KB output cap), with
the standard service→project ownership check. Console section on the
service detail page submits commands and renders captured output with
exit codes.

Verified live: real exec output, stderr + exit code propagation, 404 on
foreign service, 401 unauthenticated, 400 on empty command.

Authored By: TDvorak <[email protected]>

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Tomas DvorakandDevin committed 2026-09-17 18:34:58 +02:00
1 parent 6d237eccf7
commit f357c7adee
7 files changed
+317 -4

No files matched your search

+5 -3
View File
@@ -404,9 +404,11 @@ reaches the upstream container and appears in the analytics tables.
with provider badge, VM/LXC lifecycle actions. with provider badge, VM/LXC lifecycle actions.
- [ ] **Environments** (per §2.4): env switcher, per-env variables/domains, - [ ] **Environments** (per §2.4): env switcher, per-env variables/domains,
promote flow — ships with preview environments. promote flow — ships with preview environments.
- [ ] **Service exec console**: `docker exec` machinery now exists - [x] **Service exec console**: `POST /services/:id/exec` + Console section
(`Client.ExecRun` for cron) — expose a per-service web terminal or on service detail — one-off `sh -c` commands via `ExecRun` (30s cap,
one-off command runner on the service detail page. 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; **Gate**: scaling and failover demonstrably work against a two-node lab;
security scans produce real reports, not empty tables. security scans produce real reports, not empty tables.
+79
View File
@@ -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,
})
}
+3
View File
@@ -211,6 +211,9 @@ func SetupRoutes(router *gin.Engine, db *database.DB, redis *database.Redis, cfg
protected.GET("/services/:id/logs", handleGetLogs) protected.GET("/services/:id/logs", handleGetLogs)
protected.GET("/deployments/:id/logs", handleGetDeploymentLogs) protected.GET("/deployments/:id/logs", handleGetDeploymentLogs)
// One-off exec console (docker exec, 30s ceiling)
protected.POST("/services/:id/exec", handleExecInService)
// Git integration routes // Git integration routes
protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL) protected.GET("/git/github-app/install-url", handleGetGitHubAppInstallURL)
protected.POST("/git/github-app/connect", handleConnectGitHubApp) protected.POST("/git/github-app/connect", handleConnectGitHubApp)
@@ -26,6 +26,7 @@ import {
deleteScalingPolicy, deleteScalingPolicy,
getServiceScalingState, getServiceScalingState,
manualScaleService, manualScaleService,
execInService,
rollbackDeployment, rollbackDeployment,
updateServiceVariables, updateServiceVariables,
type CronJobEntity, type CronJobEntity,
@@ -60,9 +61,10 @@ import {
ChevronDown, ChevronDown,
GitPullRequest, GitPullRequest,
ExternalLink, ExternalLink,
Terminal,
} from 'lucide-react'; } 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 }> = [ const sectionItems: Array<{ key: ServiceSection; label: string; icon: typeof Activity }> = [
{ key: 'metrics', label: 'Metrics', icon: 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: 'cron', label: 'Cron', icon: Clock },
{ key: 'previews', label: 'Previews', icon: GitPullRequest }, { key: 'previews', label: 'Previews', icon: GitPullRequest },
{ key: 'scaling', label: 'Scaling', icon: Layers }, { key: 'scaling', label: 'Scaling', icon: Layers },
{ key: 'console', label: 'Console', icon: Terminal },
{ key: 'settings', label: 'Settings', icon: Settings }, { key: 'settings', label: 'Settings', icon: Settings },
]; ];
@@ -283,6 +286,22 @@ export function ServiceDetailPage() {
mutationFn: (replicas: number) => manualScaleService(serviceId, replicas), mutationFn: (replicas: number) => manualScaleService(serviceId, replicas),
onSuccess: invalidateScaling, 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({ const triggerCronMutation = useMutation({
mutationFn: (id: string) => triggerCronJob(id), mutationFn: (id: string) => triggerCronJob(id),
onSuccess: () => { onSuccess: () => {
@@ -1660,6 +1679,75 @@ export function ServiceDetailPage() {
</div> </div>
)} )}
{activeSection === 'console' && (
<div className="panel p-6">
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-[var(--accent-primary-soft)] flex items-center justify-center">
<Terminal size={20} className="text-[var(--accent-primary)]" />
</div>
<div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Console</h2>
<p className="text-sm text-[var(--text-secondary)]">One-off commands via docker exec — 30s limit, 64KB output cap</p>
</div>
</div>
<form
className="flex items-center gap-2 mb-4"
onSubmit={(e) => {
e.preventDefault();
const cmd = consoleInput.trim();
if (cmd && !execMutation.isPending) execMutation.mutate(cmd);
}}
>
<span className="text-sm font-mono text-[var(--accent-primary)]">$</span>
<input
value={consoleInput}
onChange={(e) => 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"
/>
<button
type="submit"
disabled={execMutation.isPending || !consoleInput.trim()}
className="px-4 py-2 rounded-[var(--radius-md)] bg-[var(--accent-primary)] text-[var(--accent-contrast)] text-sm font-medium disabled:opacity-50"
>
{execMutation.isPending ? 'Running…' : 'Run'}
</button>
</form>
{execMutation.isError && (
<p className="mb-4 text-xs text-[var(--error)]">
{(execMutation.error as Error)?.message ?? 'Command failed'}
</p>
)}
{consoleHistory.length === 0 ? (
<p className="text-xs text-[var(--text-tertiary)]">
Commands run inside the service's running container as its default user. No interactive shell — stdout/stderr is captured and returned.
</p>
) : (
<div className="space-y-3">
{consoleHistory.map((entry, i) => (
<div key={i} className="rounded-[var(--radius-md)] border border-[var(--border-primary)] bg-[var(--bg-secondary)] p-3">
<div className="flex items-center justify-between mb-1">
<p className="text-xs font-mono text-[var(--text-primary)]">$ {entry.command}</p>
<span className={`text-xs font-mono ${entry.exitCode === 0 && !entry.error ? 'text-[var(--success)]' : 'text-[var(--error)]'}`}>
{entry.error ? 'error' : `exit ${entry.exitCode}`}
</span>
</div>
{(entry.output || entry.error) && (
<pre className="text-xs font-mono text-[var(--text-secondary)] whitespace-pre-wrap break-all max-h-64 overflow-y-auto">
{entry.output}{entry.error ? `\n${entry.error}` : ''}
</pre>
)}
</div>
))}
</div>
)}
</div>
)}
{activeSection === 'settings' && ( {activeSection === 'settings' && (
<div className="panel p-6"> <div className="panel p-6">
<div className="flex items-center gap-3 mb-6"> <div className="flex items-center gap-3 mb-6">
+81
View File
@@ -809,6 +809,87 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/deployments/{id}/logs": {
parameters: { parameters: {
query?: never; query?: never;
+9
View File
@@ -1658,3 +1658,12 @@ export async function updateVulnerability(id: string, status: 'open' | 'resolved
export async function getSecurityMetrics(projectId: string): Promise<SecurityMetrics> { export async function getSecurityMetrics(projectId: string): Promise<SecurityMetrics> {
return requestJson<SecurityMetrics>(`/projects/${encodeURIComponent(projectId)}/security/metrics`); return requestJson<SecurityMetrics>(`/projects/${encodeURIComponent(projectId)}/security/metrics`);
} }
export type ExecResult = { output?: string; exit_code?: number; error?: string };
export async function execInService(serviceId: string, command: string): Promise<ExecResult> {
return requestJson<ExecResult>(`/services/${encodeURIComponent(serviceId)}/exec`, {
method: 'POST',
body: JSON.stringify({ command }),
});
}
+51
View File
@@ -561,6 +561,57 @@ paths:
schema: schema:
$ref: '#/components/schemas/ServiceLogsResponse' $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: /deployments/{id}/logs:
get: get:
tags: tags: