승인된 execution preset을 Edge 조정 경계와 Node workspace/tool 실행 경계로 연결해 단일 요청 수명주기와 관측 계약을 일관되게 처리한다.
206 lines
6.1 KiB
Go
206 lines
6.1 KiB
Go
package node
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/google/uuid"
|
|
"iop/packages/go/config"
|
|
)
|
|
|
|
// NodeRecord is the pre-registered node definition stored in edge.
|
|
// Workspaces is compiled immutably from config at LoadFromConfig time and
|
|
// carried through the store. Runtime mutation of workspace definitions is
|
|
// restart-required; the store returns deep copies so callers cannot affect
|
|
// the stored catalog.
|
|
type NodeRecord struct {
|
|
ID string
|
|
Alias string
|
|
Token string
|
|
Index int
|
|
Adapters config.AdaptersConf
|
|
Providers []config.NodeProviderConf
|
|
Runtime config.RuntimeConf
|
|
Workspaces []config.WorkspaceDefinition
|
|
}
|
|
|
|
// NodeStore holds pre-registered node definitions, keyed by token.
|
|
type NodeStore struct {
|
|
mu sync.RWMutex
|
|
byToken map[string]*NodeRecord
|
|
byID map[string]*NodeRecord
|
|
}
|
|
|
|
func NewNodeStore() *NodeStore {
|
|
return &NodeStore{
|
|
byToken: make(map[string]*NodeRecord),
|
|
byID: make(map[string]*NodeRecord),
|
|
}
|
|
}
|
|
|
|
func (s *NodeStore) Add(rec *NodeRecord) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.byToken[rec.Token] = rec
|
|
s.byID[rec.ID] = rec
|
|
}
|
|
|
|
func (s *NodeStore) FindByToken(token string) (*NodeRecord, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
r, ok := s.byToken[token]
|
|
return r, ok
|
|
}
|
|
|
|
func (s *NodeStore) FindByID(id string) (*NodeRecord, bool) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
r, ok := s.byID[id]
|
|
return r, ok
|
|
}
|
|
|
|
// All returns all configured NodeRecords in deterministic, ascending Index order.
|
|
// Callers (e.g. status snapshot builder) rely on this ordering so the surface
|
|
// exposed to CLI/HTTP/Control Plane is reproducible across calls.
|
|
func (s *NodeStore) All() []*NodeRecord {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]*NodeRecord, 0, len(s.byID))
|
|
for _, r := range s.byID {
|
|
out = append(out, r)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].Index < out[j].Index
|
|
})
|
|
return out
|
|
}
|
|
|
|
// ResolveWorkspace returns the NodeRecord and deep-copied WorkspaceDefinition
|
|
// for the given ref, or an error if no matching workspace is found. The
|
|
// returned WorkspaceDefinition is a deep copy so callers cannot mutate the
|
|
// store's immutable catalog. Exactly one workspace across all nodes must
|
|
// match the ref; this is enforced at load time.
|
|
func (s *NodeStore) ResolveWorkspace(ref string) (*NodeRecord, config.WorkspaceDefinition, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for _, rec := range s.byID {
|
|
for _, ws := range rec.Workspaces {
|
|
if ws.Ref == ref {
|
|
return cloneWorkspaceOwner(rec), cloneWorkspaceDefinition(ws), nil
|
|
}
|
|
}
|
|
}
|
|
return nil, config.WorkspaceDefinition{}, fmt.Errorf("workspace ref %q not found in any node", ref)
|
|
}
|
|
|
|
// LoadFromConfig seeds the store from EdgeConfig.Nodes.
|
|
func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) {
|
|
s := NewNodeStore()
|
|
seenToken := make(map[string]bool)
|
|
seenAlias := make(map[string]bool)
|
|
seenID := make(map[string]bool)
|
|
seenWorkspaceRef := make(map[string]struct{})
|
|
for i, d := range defs {
|
|
if d.Token == "" {
|
|
return nil, fmt.Errorf("node[%d] alias=%q: token must not be empty", i, d.Alias)
|
|
}
|
|
if seenToken[d.Token] {
|
|
return nil, fmt.Errorf("node[%d] alias=%q: duplicate token", i, d.Alias)
|
|
}
|
|
if seenAlias[d.Alias] {
|
|
return nil, fmt.Errorf("node[%d] alias=%q: duplicate alias", i, d.Alias)
|
|
}
|
|
if d.ID != "" && seenID[d.ID] {
|
|
return nil, fmt.Errorf("node[%d] alias=%q: duplicate id %q", i, d.Alias, d.ID)
|
|
}
|
|
seenToken[d.Token] = true
|
|
seenAlias[d.Alias] = true
|
|
nodeID := d.ID
|
|
if nodeID == "" {
|
|
nodeID = uuid.NewString()
|
|
} else {
|
|
seenID[d.ID] = true
|
|
}
|
|
adapters := d.Adapters
|
|
if err := config.NormalizeAdapters(&adapters); err != nil {
|
|
return nil, fmt.Errorf("node[%d] alias=%q: adapters: %w", i, d.Alias, err)
|
|
}
|
|
workspaces, err := cloneWorkspaceCatalog(d.Workspaces, seenWorkspaceRef, i)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
s.Add(&NodeRecord{
|
|
ID: nodeID,
|
|
Alias: d.Alias,
|
|
Token: d.Token,
|
|
Index: i,
|
|
Adapters: adapters,
|
|
Providers: d.Providers,
|
|
Runtime: d.Runtime,
|
|
Workspaces: workspaces,
|
|
})
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func cloneWorkspaceOwner(rec *NodeRecord) *NodeRecord {
|
|
owner := *rec
|
|
owner.Workspaces = cloneWorkspaceCatalogUnchecked(rec.Workspaces)
|
|
return &owner
|
|
}
|
|
|
|
func cloneWorkspaceCatalog(defs []config.WorkspaceDefinition, seenRefs map[string]struct{}, nodeIndex int) ([]config.WorkspaceDefinition, error) {
|
|
if len(defs) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
workspaces := make([]config.WorkspaceDefinition, len(defs))
|
|
for workspaceIndex, ws := range defs {
|
|
ws.Ref = strings.TrimSpace(ws.Ref)
|
|
if ws.Ref == "" {
|
|
return nil, fmt.Errorf("node[%d].workspaces[%d]: ref must not be empty after trim", nodeIndex, workspaceIndex)
|
|
}
|
|
if _, duplicate := seenRefs[ws.Ref]; duplicate {
|
|
return nil, fmt.Errorf("node[%d].workspaces[%d]: duplicate workspace ref %q", nodeIndex, workspaceIndex, ws.Ref)
|
|
}
|
|
seenRefs[ws.Ref] = struct{}{}
|
|
workspaces[workspaceIndex] = cloneWorkspaceDefinition(ws)
|
|
}
|
|
return workspaces, nil
|
|
}
|
|
|
|
func cloneWorkspaceCatalogUnchecked(defs []config.WorkspaceDefinition) []config.WorkspaceDefinition {
|
|
if len(defs) == 0 {
|
|
return nil
|
|
}
|
|
workspaces := make([]config.WorkspaceDefinition, len(defs))
|
|
for i, ws := range defs {
|
|
workspaces[i] = cloneWorkspaceDefinition(ws)
|
|
}
|
|
return workspaces
|
|
}
|
|
|
|
func cloneWorkspaceDefinition(ws config.WorkspaceDefinition) config.WorkspaceDefinition {
|
|
cp := config.WorkspaceDefinition{
|
|
Ref: ws.Ref,
|
|
Platform: ws.Platform,
|
|
Root: ws.Root,
|
|
MaxReadBytes: ws.MaxReadBytes,
|
|
MaxWriteBytes: ws.MaxWriteBytes,
|
|
MaxOutputBytes: ws.MaxOutputBytes,
|
|
MaxCommandTimeoutMS: ws.MaxCommandTimeoutMS,
|
|
}
|
|
cp.Operations = append([]config.WorkspaceOperation(nil), ws.Operations...)
|
|
cp.EnvironmentAllowlist = append([]string(nil), ws.EnvironmentAllowlist...)
|
|
cp.Commands = make([]config.WorkspaceCommandDefinition, len(ws.Commands))
|
|
for i, command := range ws.Commands {
|
|
cp.Commands[i] = config.WorkspaceCommandDefinition{
|
|
ID: command.ID,
|
|
Executable: command.Executable,
|
|
Args: append([]string(nil), command.Args...),
|
|
}
|
|
}
|
|
return cp
|
|
}
|