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 }