mirror of
https://github.com/Dvorinka/Containr.git
synced 2026-10-28 07:41:27 +00:00
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:
1 parent
6d237eccf7
commit
f357c7adee
7 files changed
+317
-4
No files matched your search
+5
-3
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
Reference in new issue
Block a user