Skip to content

Commit eed4052

Browse files
committed
refactoring and consolidation
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent c6bb7da commit eed4052

21 files changed

Lines changed: 436 additions & 426 deletions

core/application/distributed.go

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type DistributedServices struct {
3232
FileMgr *storage.FileManager
3333
FileStager nodes.FileStager
3434
ModelAdapter *nodes.ModelRouterAdapter
35+
Unloader *nodes.RemoteUnloaderAdapter
3536
}
3637

3738
// initDistributed validates distributed mode prerequisites and initializes
@@ -118,13 +119,17 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB) (*Distribut
118119
}
119120
xlog.Info("Node registry initialized")
120121

121-
router := nodes.NewSmartRouter(registry)
122+
// Collect SmartRouter option values; the router itself is created after all
123+
// dependencies (including FileStager and Unloader) are ready.
124+
var routerAuthToken string
122125
if cfg.Distributed.RegistrationToken != "" {
123-
router.SetAuthToken(cfg.Distributed.RegistrationToken)
126+
routerAuthToken = cfg.Distributed.RegistrationToken
124127
}
128+
var routerGalleriesJSON string
125129
if galleriesJSON, err := json.Marshal(cfg.BackendGalleries); err == nil {
126-
router.SetGalleriesJSON(string(galleriesJSON))
130+
routerGalleriesJSON = string(galleriesJSON)
127131
}
132+
128133
healthMon := nodes.NewHealthMonitor(registry, authDB,
129134
cfg.Distributed.HealthCheckIntervalOrDefault(),
130135
cfg.Distributed.StaleNodeThresholdOrDefault(),
@@ -190,7 +195,17 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB) (*Distribut
190195
}, cfg.Distributed.RegistrationToken)
191196
xlog.Info("File stager initialized (HTTP direct transfer)")
192197
}
193-
router.SetFileStager(fileStager)
198+
// Create RemoteUnloaderAdapter — needed by SmartRouter and startup.go
199+
remoteUnloader := nodes.NewRemoteUnloaderAdapter(registry, natsClient)
200+
201+
// All dependencies ready — build SmartRouter with all options at once
202+
router := nodes.NewSmartRouter(registry, nodes.SmartRouterOptions{
203+
Unloader: remoteUnloader,
204+
FileStager: fileStager,
205+
GalleriesJSON: routerGalleriesJSON,
206+
AuthToken: routerAuthToken,
207+
DB: authDB,
208+
})
194209

195210
// Create ModelRouterAdapter to wire into ModelLoader
196211
modelAdapter := nodes.NewModelRouterAdapter(router)
@@ -210,6 +225,7 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB) (*Distribut
210225
FileMgr: fileMgr,
211226
FileStager: fileStager,
212227
ModelAdapter: modelAdapter,
228+
Unloader: remoteUnloader,
213229
}, nil
214230
}
215231

core/application/startup.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,7 @@ func New(opts ...config.AppOption) (*Application, error) {
146146
application.distributed = distSvc
147147
// Wire remote model unloader so ShutdownModel works for remote nodes
148148
// Uses NATS to tell serve-backend nodes to Free + kill their backend process
149-
remoteUnloader := nodes.NewRemoteUnloaderAdapter(distSvc.Registry, distSvc.Nats)
150-
application.modelLoader.SetRemoteUnloader(remoteUnloader)
151-
distSvc.Router.SetUnloader(remoteUnloader)
149+
application.modelLoader.SetRemoteUnloader(distSvc.Unloader)
152150
// Wire ModelRouter so grpcModel() delegates to SmartRouter in distributed mode
153151
application.modelLoader.SetModelRouter(distSvc.ModelAdapter.AsModelRouter())
154152
// Wire DistributedModelStore so shutdown/list/watchdog can find remote models
@@ -193,10 +191,10 @@ func New(opts ...config.AppOption) (*Application, error) {
193191
}
194192
// Wire distributed model/backend managers so delete propagates to workers
195193
application.galleryService.SetModelManager(
196-
nodes.NewDistributedModelManager(options, application.modelLoader, remoteUnloader),
194+
nodes.NewDistributedModelManager(options, application.modelLoader, distSvc.Unloader),
197195
)
198196
application.galleryService.SetBackendManager(
199-
nodes.NewDistributedBackendManager(options, application.modelLoader, remoteUnloader, distSvc.Registry),
197+
nodes.NewDistributedBackendManager(options, application.modelLoader, distSvc.Unloader, distSvc.Registry),
200198
)
201199
}
202200
}

core/cli/agent_worker.go

Lines changed: 10 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -90,9 +90,7 @@ func (cmd *AgentWorkerCMD) Run(ctx *cliContext.Context) error {
9090

9191
// Start heartbeat
9292
heartbeatInterval, _ := time.ParseDuration(cmd.HeartbeatInterval)
93-
if heartbeatInterval == 0 {
94-
heartbeatInterval = 10 * time.Second
95-
}
93+
heartbeatInterval = cmp.Or(heartbeatInterval, 10*time.Second)
9694
// Context cancelled on shutdown — used by heartbeat and other background goroutines
9795
shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
9896
defer shutdownCancel()
@@ -145,8 +143,8 @@ func (cmd *AgentWorkerCMD) Run(ctx *cliContext.Context) error {
145143
// Subscribe to MCP CI job execution (load-balanced across agent workers).
146144
// In distributed mode, MCP CI jobs are routed here because the frontend
147145
// cannot create MCP sessions (e.g., stdio servers using docker).
148-
natsClient.QueueSubscribe(messaging.SubjectJobsNew, messaging.QueueWorkers, func(data []byte) {
149-
handleMCPCIJob(data, apiURL, cmd.APIToken, natsClient)
146+
natsClient.QueueSubscribe(messaging.SubjectMCPCIJobsNew, messaging.QueueWorkers, func(data []byte) {
147+
handleMCPCIJob(shutdownCtx, data, apiURL, cmd.APIToken, natsClient)
150148
})
151149

152150
// Subscribe to backend stop events to clean up cached MCP sessions.
@@ -171,11 +169,7 @@ func (cmd *AgentWorkerCMD) Run(ctx *cliContext.Context) error {
171169
xlog.Info("Shutting down agent worker")
172170
dispatcher.Stop()
173171
mcpTools.CloseAllMCPSessions()
174-
if err := regClient.Deregister(context.Background(), nodeID); err != nil {
175-
xlog.Warn("Failed to deregister", "error", err)
176-
} else {
177-
xlog.Info("Deregistered from frontend")
178-
}
172+
regClient.GracefulDeregister(nodeID)
179173
return nil
180174
}
181175

@@ -279,7 +273,9 @@ func sendMCPDiscoveryReply(reply func([]byte), servers []mcpRemote.MCPServerInfo
279273
reply(data)
280274
}
281275

282-
// natsClientAdapter wraps messaging.Client to satisfy NATSClient interface.
276+
// natsClientAdapter wraps messaging.Client to satisfy agents.NATSClient.
277+
// The concrete Client.QueueSubscribe returns *nats.Subscription; this adapter
278+
// widens it to messaging.Subscription so it matches the interface.
283279
type natsClientAdapter struct {
284280
client *messaging.Client
285281
}
@@ -288,13 +284,13 @@ func (a *natsClientAdapter) Publish(subject string, data any) error {
288284
return a.client.Publish(subject, data)
289285
}
290286

291-
func (a *natsClientAdapter) QueueSubscribe(subject, queue string, handler func(data []byte)) (agents.NATSSub, error) {
287+
func (a *natsClientAdapter) QueueSubscribe(subject, queue string, handler func(data []byte)) (messaging.Subscription, error) {
292288
return a.client.QueueSubscribe(subject, queue, handler)
293289
}
294290

295291
// handleMCPCIJob processes an MCP CI job on the agent worker.
296292
// The agent worker can create MCP sessions (has docker) and call the LocalAI API for inference.
297-
func handleMCPCIJob(data []byte, apiURL, apiToken string, natsClient *messaging.Client) {
293+
func handleMCPCIJob(shutdownCtx context.Context, data []byte, apiURL, apiToken string, natsClient *messaging.Client) {
298294
var evt jobs.JobEvent
299295
if err := json.Unmarshal(data, &evt); err != nil {
300296
xlog.Error("Failed to unmarshal job event", "error", err)
@@ -370,7 +366,7 @@ func handleMCPCIJob(data []byte, apiURL, apiToken string, natsClient *messaging.
370366
llm := clients.NewLocalAILLM(task.Model, apiToken, apiURL)
371367

372368
// Build cogito options
373-
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
369+
ctx, cancel := context.WithTimeout(shutdownCtx, 10*time.Minute)
374370
defer cancel()
375371

376372
// Update job status to running in DB

core/cli/worker.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"cmp"
45
"context"
56
"encoding/json"
67
"fmt"
@@ -108,9 +109,7 @@ func (cmd *WorkerCMD) Run(ctx *cliContext.Context) error {
108109

109110
xlog.Info("Registered with frontend", "nodeID", nodeID, "frontend", cmd.RegisterTo)
110111
heartbeatInterval, _ := time.ParseDuration(cmd.HeartbeatInterval)
111-
if heartbeatInterval == 0 {
112-
heartbeatInterval = 10 * time.Second
113-
}
112+
heartbeatInterval = cmp.Or(heartbeatInterval, 10*time.Second)
114113
// Context cancelled on shutdown — used by heartbeat and other background goroutines
115114
shutdownCtx, shutdownCancel := context.WithCancel(context.Background())
116115
defer shutdownCancel()
@@ -121,7 +120,7 @@ func (cmd *WorkerCMD) Run(ctx *cliContext.Context) error {
121120
httpAddr := cmd.resolveHTTPAddr()
122121
stagingDir := filepath.Join(cmd.ModelsPath, "..", "staging")
123122
dataDir := filepath.Join(cmd.ModelsPath, "..", "data")
124-
httpServer, err := nodes.StartFileTransferServer(httpAddr, stagingDir, cmd.ModelsPath, dataDir, cmd.RegistrationToken, ml.BackendLogs())
123+
httpServer, err := nodes.StartFileTransferServer(httpAddr, stagingDir, cmd.ModelsPath, dataDir, cmd.RegistrationToken, config.DefaultMaxUploadSize, ml.BackendLogs())
125124
if err != nil {
126125
return fmt.Errorf("starting HTTP file transfer server: %w", err)
127126
}
@@ -177,9 +176,9 @@ func (cmd *WorkerCMD) Run(ctx *cliContext.Context) error {
177176
<-sigCh
178177

179178
xlog.Info("Shutting down worker")
179+
regClient.GracefulDeregister(nodeID)
180180
supervisor.stopAllBackends()
181181
nodes.ShutdownFileTransferServer(httpServer)
182-
regClient.GracefulDeregister(nodeID)
183182
return nil
184183
}
185184

core/config/distributed_config.go

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ type DistributedConfig struct {
2828
DrainTimeout time.Duration // Time to wait for in-flight requests during drain (default 30s)
2929
HealthCheckInterval time.Duration // Health monitor check interval (default 15s)
3030
StaleNodeThreshold time.Duration // Time before a node is considered stale (default 60s)
31+
32+
MaxUploadSize int64 // Maximum upload body size in bytes (default 50 GB)
3133
}
3234

3335
// Distributed config options
@@ -98,6 +100,9 @@ const (
98100
DefaultStaleNodeThreshold = 60 * time.Second
99101
)
100102

103+
// DefaultMaxUploadSize is the default maximum upload body size (50 GB).
104+
const DefaultMaxUploadSize int64 = 50 << 30
105+
101106
// MCPToolTimeoutOrDefault returns the configured timeout or the default.
102107
func (c DistributedConfig) MCPToolTimeoutOrDefault() time.Duration {
103108
return cmp.Or(c.MCPToolTimeout, DefaultMCPToolTimeout)
@@ -127,3 +132,11 @@ func (c DistributedConfig) HealthCheckIntervalOrDefault() time.Duration {
127132
func (c DistributedConfig) StaleNodeThresholdOrDefault() time.Duration {
128133
return cmp.Or(c.StaleNodeThreshold, DefaultStaleNodeThreshold)
129134
}
135+
136+
// MaxUploadSizeOrDefault returns the configured max upload size or the default.
137+
func (c DistributedConfig) MaxUploadSizeOrDefault() int64 {
138+
if c.MaxUploadSize > 0 {
139+
return c.MaxUploadSize
140+
}
141+
return DefaultMaxUploadSize
142+
}

core/config/model_config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ type AgentConfig struct {
105105
ForceReasoningTool bool `yaml:"force_reasoning_tool,omitempty" json:"force_reasoning_tool,omitempty"`
106106
}
107107

108+
// HasMCPServers returns true if any MCP servers (remote or stdio) are configured.
109+
func (c MCPConfig) HasMCPServers() bool {
110+
return c.Servers != "" || c.Stdio != ""
111+
}
112+
108113
func (c *MCPConfig) MCPConfigFromYAML() (MCPGenericConfig[MCPRemoteServers], MCPGenericConfig[MCPSTDIOServers], error) {
109114
var remote MCPGenericConfig[MCPRemoteServers]
110115
var stdio MCPGenericConfig[MCPSTDIOServers]

core/http/endpoints/localai/nodes.go

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,18 @@ func NodeBackendLogsLinesEndpoint(registry *nodes.NodeRegistry, registrationToke
473473
// /v1/backend-logs/{modelId}/ws endpoint for real-time log streaming.
474474
func NodeBackendLogsWSEndpoint(registry *nodes.NodeRegistry, registrationToken string) echo.HandlerFunc {
475475
browserUpgrader := websocket.Upgrader{
476-
CheckOrigin: func(r *http.Request) bool { return true },
476+
CheckOrigin: func(r *http.Request) bool {
477+
origin := r.Header.Get("Origin")
478+
if origin == "" {
479+
return true // no origin header = same-origin or non-browser
480+
}
481+
// Parse origin URL and compare host with request host
482+
u, err := url.Parse(origin)
483+
if err != nil {
484+
return false
485+
}
486+
return u.Host == r.Host
487+
},
477488
}
478489

479490
return func(c echo.Context) error {
@@ -507,14 +518,12 @@ func NodeBackendLogsWSEndpoint(registry *nodes.NodeRegistry, registrationToken s
507518
return nil
508519
}
509520

510-
// Use sync.Once wrappers to avoid double-close and ensure each
521+
// Use sync.OnceFunc wrappers to avoid double-close and ensure each
511522
// goroutine can safely close the *other* connection to unblock
512523
// its peer's ReadMessage call.
513524
done := make(chan struct{})
514-
var closeWorkerOnce sync.Once
515-
closeWorker := func() { closeWorkerOnce.Do(func() { workerWS.Close() }) }
516-
var closeBrowserOnce sync.Once
517-
closeBrowser := func() { closeBrowserOnce.Do(func() { browserWS.Close() }) }
525+
closeWorker := sync.OnceFunc(func() { workerWS.Close() })
526+
closeBrowser := sync.OnceFunc(func() { browserWS.Close() })
518527

519528
// Worker → Browser
520529
go func() {

core/http/routes/nodes.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,11 @@ func nodeReadyMiddleware(registry *nodes.NodeRegistry) echo.MiddlewareFunc {
2828
// RegisterNodeSelfServiceRoutes registers /api/node/ endpoints used by backend
2929
// nodes themselves (register, heartbeat, drain, query own models, deregister).
3030
// These are authenticated via the registration token, not admin middleware.
31+
//
32+
// TODO(security): Self-service routes currently accept any node :id with a valid
33+
// registration token. A compromised worker can heartbeat/drain/deregister other nodes.
34+
// To fix: issue per-node bearer tokens during registration and validate :id matches
35+
// the authenticated node. See security review item S2.
3136
func RegisterNodeSelfServiceRoutes(e *echo.Echo, registry *nodes.NodeRegistry, registrationToken string, autoApprove bool, authDB *gorm.DB, hmacSecret string) {
3237
if registry == nil {
3338
return

0 commit comments

Comments
 (0)