Files
Containr/app/backend/internal/api/exec.go
T
Tomas DvorakandDevin f357c7adee 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>
2026-09-17 18:34:58 +02:00

80 lines
1.9 KiB
Go

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,
})
}