Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions internal/api/resource_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package api

import (
"net/http"

"github.com/flatrun/agent/internal/docker"
"github.com/gin-gonic/gin"
)

func (s *Server) getContainerResources(c *gin.Context) {
id := c.Param("id")

resources, err := docker.GetContainerResources(id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"resources": resources,
})
}

func (s *Server) updateContainerResources(c *gin.Context) {
id := c.Param("id")

var update docker.ResourceUpdate
if err := c.ShouldBindJSON(&update); err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "Invalid request body: " + err.Error(),
})
return
}

if update.MemoryLimit == nil && update.MemorySwap == nil &&
update.CPUs == nil && update.CPUShares == nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "At least one resource limit must be specified",
})
return
}

if err := docker.UpdateContainerResources(id, &update); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}

resources, _ := docker.GetContainerResources(id)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error from GetContainerResources is ignored here. If the update succeeded but the subsequent fetch fails, the client receives a partial/successful response that might be misleading.

Suggested change
resources, _ := docker.GetContainerResources(id)
resources, err := docker.GetContainerResources(id)
if err != nil {
c.JSON(http.StatusOK, gin.H{
"message": "Resources updated, but failed to fetch current limits",
"error": err.Error(),
})
return
}


c.JSON(http.StatusOK, gin.H{
"message": "Resources updated",
"resources": resources,
})
}

func (s *Server) getDeploymentResources(c *gin.Context) {
name := c.Param("name")

if _, err := s.manager.GetDeployment(name); err != nil {
c.JSON(http.StatusNotFound, gin.H{
"error": "deployment not found",
})
return
}

resources, err := docker.GetDeploymentResources(name)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"deployment": name,
"resources": resources,
})
}
169 changes: 169 additions & 0 deletions internal/api/resource_handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package api

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"testing"

"github.com/flatrun/agent/internal/auth"
"github.com/flatrun/agent/internal/docker"
"github.com/flatrun/agent/pkg/config"
"github.com/gin-gonic/gin"
)

func setupResourceTestServer(t *testing.T) (*gin.Engine, string, func()) {
gin.SetMode(gin.TestMode)

tmpDir, err := os.MkdirTemp("", "resource_test")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}

cfg := &config.Config{
DeploymentsPath: tmpDir,
Auth: config.AuthConfig{
Enabled: true,
JWTSecret: "test-jwt-secret-for-resources",
APIKeys: []string{"test-api-key"},
},
}

os.Setenv("FLATRUN_ADMIN_PASSWORD", "testadminpass")

authManager, err := auth.NewManager(tmpDir, &cfg.Auth)
if err != nil {
os.RemoveAll(tmpDir)
t.Fatalf("Failed to create auth manager: %v", err)
}

manager := docker.NewManager(tmpDir)

server := &Server{
config: cfg,
authManager: authManager,
manager: manager,
}

router := gin.New()
authMiddleware := auth.NewMiddlewareWithManager(&cfg.Auth, authManager)

api := router.Group("/api")
api.POST("/auth/login", authMiddleware.Login)

protected := api.Group("")
protected.Use(authMiddleware.RequireAuth())
{
protected.GET("/containers/:id/resources", authMiddleware.RequirePermission(auth.PermContainersRead), server.getContainerResources)
protected.PUT("/containers/:id/resources", authMiddleware.RequirePermission(auth.PermContainersWrite), server.updateContainerResources)
protected.GET("/deployments/:name/resources", authMiddleware.RequirePermission(auth.PermDeploymentsRead), server.getDeploymentResources)
}

cleanup := func() {
authManager.Close()
os.RemoveAll(tmpDir)
os.Unsetenv("FLATRUN_ADMIN_PASSWORD")
}

token := loginAndGetToken(t, router, "admin", "testadminpass")
return router, token, cleanup
}

func TestGetContainerResourcesRequiresAuth(t *testing.T) {
router, _, cleanup := setupResourceTestServer(t)
defer cleanup()

req := httptest.NewRequest(http.MethodGet, "/api/containers/abc123/resources", nil)

w := httptest.NewRecorder()
router.ServeHTTP(w, req)

if w.Code == http.StatusOK {
t.Error("Expected auth error, got 200")
}
}

func TestUpdateContainerResourcesEmptyBody(t *testing.T) {
router, token, cleanup := setupResourceTestServer(t)
defer cleanup()

body := map[string]interface{}{}
jsonBody, _ := json.Marshal(body)

req := httptest.NewRequest(http.MethodPut, "/api/containers/abc123/resources", bytes.NewBuffer(jsonBody))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")

w := httptest.NewRecorder()
router.ServeHTTP(w, req)

if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for empty update, got %d: %s", w.Code, w.Body.String())
}
}

func TestUpdateContainerResourcesBadJSON(t *testing.T) {
router, token, cleanup := setupResourceTestServer(t)
defer cleanup()

req := httptest.NewRequest(http.MethodPut, "/api/containers/abc123/resources", bytes.NewBufferString("{invalid"))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")

w := httptest.NewRecorder()
router.ServeHTTP(w, req)

if w.Code != http.StatusBadRequest {
t.Errorf("Expected 400 for bad JSON, got %d", w.Code)
}
}

func TestGetDeploymentResourcesNotFound(t *testing.T) {
router, token, cleanup := setupResourceTestServer(t)
defer cleanup()

req := httptest.NewRequest(http.MethodGet, "/api/deployments/nonexistent/resources", nil)
req.Header.Set("Authorization", "Bearer "+token)

w := httptest.NewRecorder()
router.ServeHTTP(w, req)

if w.Code != http.StatusNotFound {
t.Errorf("Expected 404 for nonexistent deployment, got %d", w.Code)
}
}

func TestResourceUpdateStructSerialization(t *testing.T) {
mem := int64(256 * 1024 * 1024)
cpus := 0.5

update := docker.ResourceUpdate{
MemoryLimit: &mem,
CPUs: &cpus,
}

data, err := json.Marshal(update)
if err != nil {
t.Fatalf("Failed to marshal: %v", err)
}

var parsed docker.ResourceUpdate
if err := json.Unmarshal(data, &parsed); err != nil {
t.Fatalf("Failed to unmarshal: %v", err)
}

if parsed.MemoryLimit == nil || *parsed.MemoryLimit != mem {
t.Errorf("MemoryLimit = %v, want %d", parsed.MemoryLimit, mem)
}
if parsed.CPUs == nil || *parsed.CPUs != cpus {
t.Errorf("CPUs = %v, want %f", parsed.CPUs, cpus)
}
if parsed.MemorySwap != nil {
t.Error("MemorySwap should be nil")
}
if parsed.CPUShares != nil {
t.Error("CPUShares should be nil")
}
}
7 changes: 7 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,10 @@ func (s *Server) setupRoutes() {
protected.POST("/compose/update", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.updateCompose)
protected.GET("/stats", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.getSystemStats)

// Server info and network health endpoints
protected.GET("/server/info", s.authMiddleware.RequirePermission(auth.PermSystemRead), s.getServerInfo)
protected.GET("/server/network-health", s.authMiddleware.RequirePermission(auth.PermSystemRead), s.getNetworkHealth)

// Template and plugin endpoints
protected.GET("/plugins", s.authMiddleware.RequirePermission(auth.PermTemplatesRead), s.listPlugins)
protected.GET("/plugins/:name", s.authMiddleware.RequirePermission(auth.PermTemplatesRead), s.getPlugin)
Expand All @@ -326,7 +330,10 @@ func (s *Server) setupRoutes() {
protected.GET("/containers/:id/stats", s.authMiddleware.RequirePermission(auth.PermContainersRead), s.getContainerStats)
protected.GET("/containers/stats", s.authMiddleware.RequirePermission(auth.PermContainersRead), s.getAllContainerStats)
protected.POST("/containers/:id/exec", s.authMiddleware.RequirePermission(auth.PermContainersWrite), s.containerExecHTTP)
protected.GET("/containers/:id/resources", s.authMiddleware.RequirePermission(auth.PermContainersRead), s.getContainerResources)
protected.PUT("/containers/:id/resources", s.authMiddleware.RequirePermission(auth.PermContainersWrite), s.updateContainerResources)
protected.GET("/deployments/:name/stats", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentContainerStats)
protected.GET("/deployments/:name/resources", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentResources)

// Image endpoints
protected.GET("/images", s.authMiddleware.RequirePermission(auth.PermImagesRead), s.listImages)
Expand Down
36 changes: 36 additions & 0 deletions internal/api/server_info_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package api

import (
"net/http"

"github.com/flatrun/agent/internal/system"
"github.com/gin-gonic/gin"
)

func (s *Server) getServerInfo(c *gin.Context) {
info, err := system.GetServerInfo()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"server": info,
})
}

func (s *Server) getNetworkHealth(c *gin.Context) {
health, err := system.CheckNetworkHealth(c.Request.Context())
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}

c.JSON(http.StatusOK, gin.H{
"network_health": health,
})
}
Loading
Loading