Skip to content

Commit 672ec21

Browse files
authored
Merge pull request #86 from flatrun/feat/resource-and-server-health
feat(api): Add server info, network health, and resource management
2 parents cc79789 + d79dbbd commit 672ec21

9 files changed

Lines changed: 923 additions & 0 deletions

internal/api/resource_handlers.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/flatrun/agent/internal/docker"
7+
"github.com/gin-gonic/gin"
8+
)
9+
10+
func (s *Server) getContainerResources(c *gin.Context) {
11+
id := c.Param("id")
12+
13+
resources, err := docker.GetContainerResources(id)
14+
if err != nil {
15+
c.JSON(http.StatusInternalServerError, gin.H{
16+
"error": err.Error(),
17+
})
18+
return
19+
}
20+
21+
c.JSON(http.StatusOK, gin.H{
22+
"resources": resources,
23+
})
24+
}
25+
26+
func (s *Server) updateContainerResources(c *gin.Context) {
27+
id := c.Param("id")
28+
29+
var update docker.ResourceUpdate
30+
if err := c.ShouldBindJSON(&update); err != nil {
31+
c.JSON(http.StatusBadRequest, gin.H{
32+
"error": "Invalid request body: " + err.Error(),
33+
})
34+
return
35+
}
36+
37+
if update.MemoryLimit == nil && update.MemorySwap == nil &&
38+
update.CPUs == nil && update.CPUShares == nil {
39+
c.JSON(http.StatusBadRequest, gin.H{
40+
"error": "At least one resource limit must be specified",
41+
})
42+
return
43+
}
44+
45+
if err := docker.UpdateContainerResources(id, &update); err != nil {
46+
c.JSON(http.StatusInternalServerError, gin.H{
47+
"error": err.Error(),
48+
})
49+
return
50+
}
51+
52+
resources, _ := docker.GetContainerResources(id)
53+
54+
c.JSON(http.StatusOK, gin.H{
55+
"message": "Resources updated",
56+
"resources": resources,
57+
})
58+
}
59+
60+
func (s *Server) getDeploymentResources(c *gin.Context) {
61+
name := c.Param("name")
62+
63+
if _, err := s.manager.GetDeployment(name); err != nil {
64+
c.JSON(http.StatusNotFound, gin.H{
65+
"error": "deployment not found",
66+
})
67+
return
68+
}
69+
70+
resources, err := docker.GetDeploymentResources(name)
71+
if err != nil {
72+
c.JSON(http.StatusInternalServerError, gin.H{
73+
"error": err.Error(),
74+
})
75+
return
76+
}
77+
78+
c.JSON(http.StatusOK, gin.H{
79+
"deployment": name,
80+
"resources": resources,
81+
})
82+
}
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
package api
2+
3+
import (
4+
"bytes"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
9+
"testing"
10+
11+
"github.com/flatrun/agent/internal/auth"
12+
"github.com/flatrun/agent/internal/docker"
13+
"github.com/flatrun/agent/pkg/config"
14+
"github.com/gin-gonic/gin"
15+
)
16+
17+
func setupResourceTestServer(t *testing.T) (*gin.Engine, string, func()) {
18+
gin.SetMode(gin.TestMode)
19+
20+
tmpDir, err := os.MkdirTemp("", "resource_test")
21+
if err != nil {
22+
t.Fatalf("Failed to create temp dir: %v", err)
23+
}
24+
25+
cfg := &config.Config{
26+
DeploymentsPath: tmpDir,
27+
Auth: config.AuthConfig{
28+
Enabled: true,
29+
JWTSecret: "test-jwt-secret-for-resources",
30+
APIKeys: []string{"test-api-key"},
31+
},
32+
}
33+
34+
os.Setenv("FLATRUN_ADMIN_PASSWORD", "testadminpass")
35+
36+
authManager, err := auth.NewManager(tmpDir, &cfg.Auth)
37+
if err != nil {
38+
os.RemoveAll(tmpDir)
39+
t.Fatalf("Failed to create auth manager: %v", err)
40+
}
41+
42+
manager := docker.NewManager(tmpDir)
43+
44+
server := &Server{
45+
config: cfg,
46+
authManager: authManager,
47+
manager: manager,
48+
}
49+
50+
router := gin.New()
51+
authMiddleware := auth.NewMiddlewareWithManager(&cfg.Auth, authManager)
52+
53+
api := router.Group("/api")
54+
api.POST("/auth/login", authMiddleware.Login)
55+
56+
protected := api.Group("")
57+
protected.Use(authMiddleware.RequireAuth())
58+
{
59+
protected.GET("/containers/:id/resources", authMiddleware.RequirePermission(auth.PermContainersRead), server.getContainerResources)
60+
protected.PUT("/containers/:id/resources", authMiddleware.RequirePermission(auth.PermContainersWrite), server.updateContainerResources)
61+
protected.GET("/deployments/:name/resources", authMiddleware.RequirePermission(auth.PermDeploymentsRead), server.getDeploymentResources)
62+
}
63+
64+
cleanup := func() {
65+
authManager.Close()
66+
os.RemoveAll(tmpDir)
67+
os.Unsetenv("FLATRUN_ADMIN_PASSWORD")
68+
}
69+
70+
token := loginAndGetToken(t, router, "admin", "testadminpass")
71+
return router, token, cleanup
72+
}
73+
74+
func TestGetContainerResourcesRequiresAuth(t *testing.T) {
75+
router, _, cleanup := setupResourceTestServer(t)
76+
defer cleanup()
77+
78+
req := httptest.NewRequest(http.MethodGet, "/api/containers/abc123/resources", nil)
79+
80+
w := httptest.NewRecorder()
81+
router.ServeHTTP(w, req)
82+
83+
if w.Code == http.StatusOK {
84+
t.Error("Expected auth error, got 200")
85+
}
86+
}
87+
88+
func TestUpdateContainerResourcesEmptyBody(t *testing.T) {
89+
router, token, cleanup := setupResourceTestServer(t)
90+
defer cleanup()
91+
92+
body := map[string]interface{}{}
93+
jsonBody, _ := json.Marshal(body)
94+
95+
req := httptest.NewRequest(http.MethodPut, "/api/containers/abc123/resources", bytes.NewBuffer(jsonBody))
96+
req.Header.Set("Authorization", "Bearer "+token)
97+
req.Header.Set("Content-Type", "application/json")
98+
99+
w := httptest.NewRecorder()
100+
router.ServeHTTP(w, req)
101+
102+
if w.Code != http.StatusBadRequest {
103+
t.Errorf("Expected 400 for empty update, got %d: %s", w.Code, w.Body.String())
104+
}
105+
}
106+
107+
func TestUpdateContainerResourcesBadJSON(t *testing.T) {
108+
router, token, cleanup := setupResourceTestServer(t)
109+
defer cleanup()
110+
111+
req := httptest.NewRequest(http.MethodPut, "/api/containers/abc123/resources", bytes.NewBufferString("{invalid"))
112+
req.Header.Set("Authorization", "Bearer "+token)
113+
req.Header.Set("Content-Type", "application/json")
114+
115+
w := httptest.NewRecorder()
116+
router.ServeHTTP(w, req)
117+
118+
if w.Code != http.StatusBadRequest {
119+
t.Errorf("Expected 400 for bad JSON, got %d", w.Code)
120+
}
121+
}
122+
123+
func TestGetDeploymentResourcesNotFound(t *testing.T) {
124+
router, token, cleanup := setupResourceTestServer(t)
125+
defer cleanup()
126+
127+
req := httptest.NewRequest(http.MethodGet, "/api/deployments/nonexistent/resources", nil)
128+
req.Header.Set("Authorization", "Bearer "+token)
129+
130+
w := httptest.NewRecorder()
131+
router.ServeHTTP(w, req)
132+
133+
if w.Code != http.StatusNotFound {
134+
t.Errorf("Expected 404 for nonexistent deployment, got %d", w.Code)
135+
}
136+
}
137+
138+
func TestResourceUpdateStructSerialization(t *testing.T) {
139+
mem := int64(256 * 1024 * 1024)
140+
cpus := 0.5
141+
142+
update := docker.ResourceUpdate{
143+
MemoryLimit: &mem,
144+
CPUs: &cpus,
145+
}
146+
147+
data, err := json.Marshal(update)
148+
if err != nil {
149+
t.Fatalf("Failed to marshal: %v", err)
150+
}
151+
152+
var parsed docker.ResourceUpdate
153+
if err := json.Unmarshal(data, &parsed); err != nil {
154+
t.Fatalf("Failed to unmarshal: %v", err)
155+
}
156+
157+
if parsed.MemoryLimit == nil || *parsed.MemoryLimit != mem {
158+
t.Errorf("MemoryLimit = %v, want %d", parsed.MemoryLimit, mem)
159+
}
160+
if parsed.CPUs == nil || *parsed.CPUs != cpus {
161+
t.Errorf("CPUs = %v, want %f", parsed.CPUs, cpus)
162+
}
163+
if parsed.MemorySwap != nil {
164+
t.Error("MemorySwap should be nil")
165+
}
166+
if parsed.CPUShares != nil {
167+
t.Error("CPUShares should be nil")
168+
}
169+
}

internal/api/server.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,10 @@ func (s *Server) setupRoutes() {
306306
protected.POST("/compose/update", s.authMiddleware.RequirePermission(auth.PermDeploymentsWrite), s.updateCompose)
307307
protected.GET("/stats", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.getSystemStats)
308308

309+
// Server info and network health endpoints
310+
protected.GET("/server/info", s.authMiddleware.RequirePermission(auth.PermSystemRead), s.getServerInfo)
311+
protected.GET("/server/network-health", s.authMiddleware.RequirePermission(auth.PermSystemRead), s.getNetworkHealth)
312+
309313
// Template and plugin endpoints
310314
protected.GET("/plugins", s.authMiddleware.RequirePermission(auth.PermTemplatesRead), s.listPlugins)
311315
protected.GET("/plugins/:name", s.authMiddleware.RequirePermission(auth.PermTemplatesRead), s.getPlugin)
@@ -326,7 +330,10 @@ func (s *Server) setupRoutes() {
326330
protected.GET("/containers/:id/stats", s.authMiddleware.RequirePermission(auth.PermContainersRead), s.getContainerStats)
327331
protected.GET("/containers/stats", s.authMiddleware.RequirePermission(auth.PermContainersRead), s.getAllContainerStats)
328332
protected.POST("/containers/:id/exec", s.authMiddleware.RequirePermission(auth.PermContainersWrite), s.containerExecHTTP)
333+
protected.GET("/containers/:id/resources", s.authMiddleware.RequirePermission(auth.PermContainersRead), s.getContainerResources)
334+
protected.PUT("/containers/:id/resources", s.authMiddleware.RequirePermission(auth.PermContainersWrite), s.updateContainerResources)
329335
protected.GET("/deployments/:name/stats", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentContainerStats)
336+
protected.GET("/deployments/:name/resources", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelRead), s.getDeploymentResources)
330337

331338
// Image endpoints
332339
protected.GET("/images", s.authMiddleware.RequirePermission(auth.PermImagesRead), s.listImages)
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
6+
"github.com/flatrun/agent/internal/system"
7+
"github.com/gin-gonic/gin"
8+
)
9+
10+
func (s *Server) getServerInfo(c *gin.Context) {
11+
info, err := system.GetServerInfo()
12+
if err != nil {
13+
c.JSON(http.StatusInternalServerError, gin.H{
14+
"error": err.Error(),
15+
})
16+
return
17+
}
18+
19+
c.JSON(http.StatusOK, gin.H{
20+
"server": info,
21+
})
22+
}
23+
24+
func (s *Server) getNetworkHealth(c *gin.Context) {
25+
health, err := system.CheckNetworkHealth(c.Request.Context())
26+
if err != nil {
27+
c.JSON(http.StatusInternalServerError, gin.H{
28+
"error": err.Error(),
29+
})
30+
return
31+
}
32+
33+
c.JSON(http.StatusOK, gin.H{
34+
"network_health": health,
35+
})
36+
}

0 commit comments

Comments
 (0)