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' && ( +
One-off commands via docker exec — 30s limit, 64KB output cap
++ {(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. +
+ ) : ( +$ {entry.command}
+ + {entry.error ? 'error' : `exit ${entry.exitCode}`} + +
+ {entry.output}{entry.error ? `\n${entry.error}` : ''}
+
+ )}
+