Skip to content

Commit 262c8aa

Browse files
committed
feat(config): Add a key-based config API and a default timeout fallback
Clients can now list, fetch, and update the agent's YAML configuration through dotted keys instead of one bespoke endpoint per setting. Sensitive keys are masked, string inputs are coerced to the field's real type, and runtime-applicable keys take effect immediately. The new endpoints sit behind dedicated config permissions granted only to admins, so the discovery surface is admin-only by default. A top-level default timeout setting is the fallback for any block that does not specify its own, used today by the image cleanup loop. The legacy /settings endpoints are unchanged.
1 parent e416aa9 commit 262c8aa

8 files changed

Lines changed: 434 additions & 8 deletions

File tree

internal/api/config_handlers.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
"strings"
6+
7+
"github.com/flatrun/agent/pkg/config"
8+
"github.com/gin-gonic/gin"
9+
)
10+
11+
func (s *Server) listConfig(c *gin.Context) {
12+
entries := config.Walk(s.config)
13+
c.JSON(http.StatusOK, gin.H{
14+
"config": entries,
15+
"runtime": s.runtimeConfigKeys(),
16+
})
17+
}
18+
19+
func (s *Server) getConfigKey(c *gin.Context) {
20+
key := normalizeConfigKey(c.Param("key"))
21+
entry, err := config.Get(s.config, key)
22+
if err != nil {
23+
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
24+
return
25+
}
26+
c.JSON(http.StatusOK, gin.H{
27+
"entry": entry,
28+
"runtime": s.runtimeConfigKeys()[key],
29+
})
30+
}
31+
32+
func (s *Server) updateConfigKey(c *gin.Context) {
33+
key := normalizeConfigKey(c.Param("key"))
34+
35+
var req struct {
36+
Value interface{} `json:"value"`
37+
}
38+
if err := c.ShouldBindJSON(&req); err != nil {
39+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
40+
return
41+
}
42+
43+
if err := config.Set(s.config, key, req.Value); err != nil {
44+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
45+
return
46+
}
47+
48+
if s.configPath != "" {
49+
if err := config.Save(s.config, s.configPath); err != nil {
50+
c.JSON(http.StatusInternalServerError, gin.H{"error": "value updated in memory but not persisted: " + err.Error()})
51+
return
52+
}
53+
}
54+
55+
applied := false
56+
if apply, ok := s.runtimeAppliers()[key]; ok {
57+
apply(s)
58+
applied = true
59+
}
60+
61+
entry, _ := config.Get(s.config, key)
62+
c.JSON(http.StatusOK, gin.H{
63+
"entry": entry,
64+
"applied": applied,
65+
})
66+
}
67+
68+
func (s *Server) runtimeAppliers() map[string]func(*Server) {
69+
return map[string]func(*Server){
70+
"cleanup.timeout": func(srv *Server) {
71+
srv.manager.SetCleanupTimeout(srv.config.Cleanup.Timeout)
72+
},
73+
}
74+
}
75+
76+
func (s *Server) runtimeConfigKeys() map[string]bool {
77+
keys := make(map[string]bool)
78+
for k := range s.runtimeAppliers() {
79+
keys[k] = true
80+
}
81+
return keys
82+
}
83+
84+
func normalizeConfigKey(raw string) string {
85+
return strings.Trim(raw, "/")
86+
}

internal/api/server.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ func New(cfg *config.Config, configPath string) *Server {
127127
}
128128

129129
manager := docker.NewManager(cfg.DeploymentsPath)
130+
manager.SetCleanupTimeout(cfg.Cleanup.Timeout)
130131
certsDiscovery := certs.NewDiscovery(cfg.DeploymentsPath)
131132
networksManager := networks.NewManager()
132133
pluginsDir := filepath.Join(cfg.DeploymentsPath, ".flatrun", "plugins")
@@ -375,6 +376,9 @@ func (s *Server) setupRoutes() {
375376
protected.GET("/settings", s.authMiddleware.RequirePermission(auth.PermSettingsRead), s.getSettings)
376377
protected.PUT("/settings", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.updateSettings)
377378
protected.PUT("/settings/security", s.authMiddleware.RequirePermission(auth.PermSettingsWrite), s.updateSecuritySettings)
379+
protected.GET("/config", s.authMiddleware.RequirePermission(auth.PermConfigRead), s.listConfig)
380+
protected.GET("/config/*key", s.authMiddleware.RequirePermission(auth.PermConfigRead), s.getConfigKey)
381+
protected.PUT("/config/*key", s.authMiddleware.RequirePermission(auth.PermConfigWrite), s.updateConfigKey)
378382

379383
// Compose, stats, subdomain (deployment-scoped)
380384
protected.GET("/subdomain/generate", s.authMiddleware.RequirePermission(auth.PermDeploymentsRead), s.generateSubdomain)

internal/auth/permissions.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ const (
3333
PermSettingsRead Permission = "settings:read"
3434
PermSettingsWrite Permission = "settings:write"
3535

36+
PermConfigRead Permission = "config:read"
37+
PermConfigWrite Permission = "config:write"
38+
3639
PermAuditRead Permission = "audit:read"
3740

3841
PermContainersRead Permission = "containers:read"
@@ -88,6 +91,7 @@ var adminPermissions = []Permission{
8891
PermUsersRead, PermUsersWrite, PermUsersDelete,
8992
PermAPIKeysRead, PermAPIKeysWrite, PermAPIKeysDelete,
9093
PermSettingsRead, PermSettingsWrite,
94+
PermConfigRead, PermConfigWrite,
9195
PermAuditRead,
9296
PermContainersRead, PermContainersWrite, PermContainersDelete,
9397
PermImagesRead, PermImagesWrite, PermImagesDelete,

internal/docker/cleanup.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ package docker
22

33
import (
44
"context"
5+
"log"
56
"strings"
6-
"time"
77

88
"github.com/docker/docker/api/types/container"
99
"github.com/docker/docker/api/types/filters"
@@ -48,7 +48,7 @@ func (m *Manager) CleanupDeploymentImages(name string, dryRun bool) (CleanupResu
4848
return CleanupResult{}, nil
4949
}
5050

51-
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
51+
ctx, cancel := context.WithTimeout(context.Background(), m.CleanupTimeout())
5252
defer cancel()
5353

5454
hostImages, err := m.apiClient.listImageRecords(ctx)
@@ -73,6 +73,7 @@ func (m *Manager) CleanupDeploymentImages(name string, dryRun bool) (CleanupResu
7373
}
7474
if !dryRun {
7575
if _, err := m.apiClient.cli.ImageRemove(ctx, img.id, image.RemoveOptions{}); err != nil {
76+
log.Printf("cleanup: failed to remove image %s (%s) for deployment %s: %v", img.id, tagLabel, name, err)
7677
continue
7778
}
7879
}
@@ -92,7 +93,7 @@ func (m *Manager) PruneDanglingImages(dryRun bool) (CleanupResult, error) {
9293
return CleanupResult{}, nil
9394
}
9495

95-
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
96+
ctx, cancel := context.WithTimeout(context.Background(), m.CleanupTimeout())
9697
defer cancel()
9798

9899
if dryRun {

internal/docker/manager.go

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,25 @@ type composeContainer struct {
2323
}
2424

2525
type Manager struct {
26-
discovery *Discovery
27-
executor *ComposeExecutor
28-
apiClient *APIClient
29-
basePath string
30-
mu sync.RWMutex
26+
discovery *Discovery
27+
executor *ComposeExecutor
28+
apiClient *APIClient
29+
basePath string
30+
cleanupTimeout time.Duration
31+
mu sync.RWMutex
32+
}
33+
34+
func (m *Manager) SetCleanupTimeout(d time.Duration) {
35+
if d > 0 {
36+
m.cleanupTimeout = d
37+
}
38+
}
39+
40+
func (m *Manager) CleanupTimeout() time.Duration {
41+
if m.cleanupTimeout > 0 {
42+
return m.cleanupTimeout
43+
}
44+
return 2 * time.Minute
3145
}
3246

3347
func NewManager(deploymentsPath string) *Manager {

pkg/config/config.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type Config struct {
2525
DeploymentsPath string `yaml:"deployments_path"`
2626
SystemFilesRoot string `yaml:"system_files_root"`
2727
DockerSocket string `yaml:"docker_socket"`
28+
DefaultTimeout time.Duration `yaml:"default_timeout"`
2829
API APIConfig `yaml:"api"`
2930
Auth AuthConfig `yaml:"auth"`
3031
Domain DomainConfig `yaml:"domain"`
@@ -37,6 +38,7 @@ type Config struct {
3738
Audit AuditConfig `yaml:"audit"`
3839
Cluster ClusterConfig `yaml:"cluster"`
3940
SystemTerminal SystemTerminalConfig `yaml:"system_terminal"`
41+
Cleanup CleanupConfig `yaml:"cleanup"`
4042
}
4143

4244
type DomainConfig struct {
@@ -179,6 +181,10 @@ type SystemTerminalConfig struct {
179181
ProtectedMode models.ProtectedModeConfig `yaml:"protected_mode" json:"protected_mode"`
180182
}
181183

184+
type CleanupConfig struct {
185+
Timeout time.Duration `yaml:"timeout" json:"timeout"`
186+
}
187+
182188
func FindConfigPath(providedPath string) string {
183189
if providedPath != "" && providedPath != "config.yml" {
184190
return providedPath
@@ -265,6 +271,12 @@ func setDefaults(cfg *Config) {
265271
if cfg.Health.MetricsRetention == 0 {
266272
cfg.Health.MetricsRetention = 24 * time.Hour
267273
}
274+
if cfg.DefaultTimeout == 0 {
275+
cfg.DefaultTimeout = 2 * time.Minute
276+
}
277+
if cfg.Cleanup.Timeout == 0 {
278+
cfg.Cleanup.Timeout = cfg.DefaultTimeout
279+
}
268280
if cfg.Auth.JWTSecret == "" {
269281
cfg.Auth.JWTSecret = "default-secret-change-me"
270282
}

0 commit comments

Comments
 (0)