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
@@ -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("/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)
|
||||
|
||||
@@ -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() {
|
||||
</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' && (
|
||||
<div className="panel p-6">
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1658,3 +1658,12 @@ export async function updateVulnerability(id: string, status: 'open' | 'resolved
|
||||
export async function getSecurityMetrics(projectId: string): Promise<SecurityMetrics> {
|
||||
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 }),
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user