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
86 changes: 86 additions & 0 deletions internal/api/config_handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package api

import (
"net/http"
"strings"

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

func (s *Server) listConfig(c *gin.Context) {
entries := config.Walk(s.config)
c.JSON(http.StatusOK, gin.H{
"config": entries,
"runtime": s.runtimeConfigKeys(),
})
}

func (s *Server) getConfigKey(c *gin.Context) {
key := normalizeConfigKey(c.Param("key"))
entry, err := config.Get(s.config, key)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"entry": entry,
"runtime": s.runtimeConfigKeys()[key],
})
}

func (s *Server) updateConfigKey(c *gin.Context) {
key := normalizeConfigKey(c.Param("key"))

var req struct {
Value interface{} `json:"value"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

if err := config.Set(s.config, key, req.Value); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}

if s.configPath != "" {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The code currently returns a 500 error if the configuration is updated in memory but fails to persist to disk. Since the in-memory state and the file are now out of sync, it is safer to return the error and avoid proceeding to apply the configuration to the runtime.

Suggested change
if s.configPath != "" {
if s.configPath != "" {
if err := config.Save(s.config, s.configPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to persist configuration: " + err.Error()})
return
}
}

if err := config.Save(s.config, s.configPath); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "value updated in memory but not persisted: " + err.Error()})
return
}
}

applied := false
if apply, ok := s.runtimeAppliers()[key]; ok {
apply(s)
applied = true
}

entry, _ := config.Get(s.config, key)
c.JSON(http.StatusOK, gin.H{
"entry": entry,
"applied": applied,
})
}

func (s *Server) runtimeAppliers() map[string]func(*Server) {
return map[string]func(*Server){
"cleanup.timeout": func(srv *Server) {
srv.manager.SetCleanupTimeout(srv.config.Cleanup.Timeout)
},
}
}

func (s *Server) runtimeConfigKeys() map[string]bool {
keys := make(map[string]bool)
for k := range s.runtimeAppliers() {
keys[k] = true
}
return keys
}

func normalizeConfigKey(raw string) string {
return strings.Trim(raw, "/")
}
105 changes: 95 additions & 10 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ func New(cfg *config.Config, configPath string) *Server {
}

manager := docker.NewManager(cfg.DeploymentsPath)
manager.SetCleanupTimeout(cfg.Cleanup.Timeout)
certsDiscovery := certs.NewDiscovery(cfg.DeploymentsPath)
networksManager := networks.NewManager()
pluginsDir := filepath.Join(cfg.DeploymentsPath, ".flatrun", "plugins")
Expand Down Expand Up @@ -375,6 +376,9 @@ func (s *Server) setupRoutes() {
protected.GET("/settings", s.authMiddleware.RequirePermission(auth.PermSettingsRead), s.getSettings)
protected.PUT("/settings", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.updateSettings)
protected.PUT("/settings/security", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.updateSecuritySettings)
protected.GET("/config", s.authMiddleware.RequirePermission(auth.PermConfigRead), s.listConfig)
protected.GET("/config/*key", s.authMiddleware.RequirePermission(auth.PermConfigRead), s.getConfigKey)
protected.PUT("/config/*key", s.authMiddleware.RequirePermission(auth.PermConfigWrite), s.updateConfigKey)

// Compose, stats, subdomain (deployment-scoped)
protected.GET("/subdomain/generate", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.generateSubdomain)
Expand Down Expand Up @@ -416,6 +420,8 @@ func (s *Server) setupRoutes() {
protected.GET("/images", s.authMiddleware.RequirePermission(auth.PermImagesRead), s.listImages)
protected.DELETE("/images/:id", s.authMiddleware.RequirePermission(auth.PermImagesDelete), s.removeImage)
protected.POST("/images/pull", s.authMiddleware.RequirePermission(auth.PermImagesWrite), s.pullImage)
protected.POST("/images/cleanup", s.authMiddleware.RequirePermission(auth.PermImagesDelete), s.cleanupSystemImages)
protected.POST("/deployments/:name/images/cleanup", s.authMiddleware.RequirePermission(auth.PermImagesWrite), s.authMiddleware.RequireDeploymentAccess(auth.AccessLevelWrite), s.cleanupDeploymentImages)

// Volume endpoints
protected.GET("/volumes", s.authMiddleware.RequirePermission(auth.PermVolumesRead), s.listVolumes)
Expand Down Expand Up @@ -1836,6 +1842,7 @@ func (s *Server) deployDeployment(c *gin.Context) {
Action string `json:"action"`
Pull *bool `json:"pull"`
OnlyLatest bool `json:"only_latest"`
Cleanup *bool `json:"cleanup"`
}{
Action: "restart",
}
Expand Down Expand Up @@ -1915,21 +1922,37 @@ func (s *Server) deployDeployment(c *gin.Context) {
return
}

cleanup := docker.CleanupResult{}
cleanupEnabled := pull
if req.Cleanup != nil {
cleanupEnabled = *req.Cleanup
}
if cleanupEnabled {
if r, err := s.manager.CleanupDeploymentImages(name, false); err == nil {
cleanup = r
} else {
log.Printf("Warning: post-deploy image cleanup for %s failed: %v", name, err)
}
}

c.JSON(http.StatusOK, gin.H{
"message": "Deployment completed",
"name": name,
"action": req.Action,
"pulled": pull,
"pull_output": pullOutput,
"deploy_output": output,
"message": "Deployment completed",
"name": name,
"action": req.Action,
"pulled": pull,
"pull_output": pullOutput,
"deploy_output": output,
"cleanup_removed": cleanup.Removed,
"cleanup_freed": cleanup.FreedBytes,
})
}

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

var req struct {
OnlyLatest bool `json:"only_latest"`
OnlyLatest bool `json:"only_latest"`
Cleanup *bool `json:"cleanup"`
}
_ = c.ShouldBindJSON(&req)

Expand All @@ -1952,10 +1975,25 @@ func (s *Server) pullDeploymentImage(c *gin.Context) {
return
}

cleanup := docker.CleanupResult{}
cleanupEnabled := true
if req.Cleanup != nil {
cleanupEnabled = *req.Cleanup
}
if cleanupEnabled {
if r, err := s.manager.CleanupDeploymentImages(name, false); err == nil {
cleanup = r
} else {
log.Printf("Warning: post-pull image cleanup for %s failed: %v", name, err)
}
}

c.JSON(http.StatusOK, gin.H{
"message": "Images pulled successfully",
"name": name,
"output": output,
"message": "Images pulled successfully",
"name": name,
"output": output,
"cleanup_removed": cleanup.Removed,
"cleanup_freed": cleanup.FreedBytes,
})
}

Expand Down Expand Up @@ -5072,6 +5110,53 @@ func (s *Server) pullImage(c *gin.Context) {
})
}

func (s *Server) cleanupSystemImages(c *gin.Context) {
var req struct {
DryRun bool `json:"dry_run"`
}
_ = c.ShouldBindJSON(&req)

result, err := s.manager.PruneDanglingImages(req.DryRun)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "System image cleanup complete",
"removed": result.Removed,
"freed_bytes": result.FreedBytes,
"dry_run": result.DryRun,
})
}

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

var req struct {
DryRun bool `json:"dry_run"`
}
_ = c.ShouldBindJSON(&req)

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

result, err := s.manager.CleanupDeploymentImages(name, req.DryRun)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"message": "Deployment image cleanup complete",
"name": name,
"removed": result.Removed,
"freed_bytes": result.FreedBytes,
"images_kept": result.ImagesKept,
"dry_run": result.DryRun,
})
}

func (s *Server) listVolumes(c *gin.Context) {
volumes, err := s.networksManager.ListVolumes()
if err != nil {
Expand Down
4 changes: 4 additions & 0 deletions internal/auth/permissions.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ const (
PermSettingsRead Permission = "settings:read"
PermSettingsWrite Permission = "settings:write"

PermConfigRead Permission = "config:read"
PermConfigWrite Permission = "config:write"

PermAuditRead Permission = "audit:read"

PermContainersRead Permission = "containers:read"
Expand Down Expand Up @@ -88,6 +91,7 @@ var adminPermissions = []Permission{
PermUsersRead, PermUsersWrite, PermUsersDelete,
PermAPIKeysRead, PermAPIKeysWrite, PermAPIKeysDelete,
PermSettingsRead, PermSettingsWrite,
PermConfigRead, PermConfigWrite,
PermAuditRead,
PermContainersRead, PermContainersWrite, PermContainersDelete,
PermImagesRead, PermImagesWrite, PermImagesDelete,
Expand Down
Loading
Loading