323 lines
12 KiB
Go
323 lines
12 KiB
Go
package transport
|
|
|
|
import (
|
|
toki "git.toki-labs.com/toki/proto-socket/go"
|
|
"go.uber.org/zap"
|
|
|
|
edgenode "iop/apps/edge/internal/node"
|
|
"iop/packages/go/auth"
|
|
"iop/packages/go/events"
|
|
iop "iop/proto/gen/iop"
|
|
)
|
|
|
|
// registerRunEventListener forwards node RunEvents to the current run-event
|
|
// handler after alias enrichment.
|
|
func (s *Server) registerRunEventListener(client *toki.TcpClient) {
|
|
toki.AddListenerTyped[*iop.RunEvent](&client.Communicator, func(e *iop.RunEvent) {
|
|
s.logger.Debug("run event received",
|
|
zap.String("run_id", e.GetRunId()),
|
|
zap.String("type", e.GetType()),
|
|
)
|
|
s.enrichRunEvent(e)
|
|
s.handlerMu.RLock()
|
|
lifecycle := s.onRunLifecycle
|
|
handler := s.onRunEvent
|
|
s.handlerMu.RUnlock()
|
|
// Correctness first: the lifecycle hook settles run accounting
|
|
// synchronously, then the event goes out for observation. Publishing
|
|
// first would make a dropped fanout lose the terminal signal.
|
|
if lifecycle != nil {
|
|
lifecycle(e)
|
|
}
|
|
if handler != nil {
|
|
handler(e)
|
|
}
|
|
})
|
|
}
|
|
|
|
// registerTunnelFrameListener routes raw provider tunnel frames to the current
|
|
// tunnel handler, dropping them when none is registered so they never reach the
|
|
// run event bus.
|
|
func (s *Server) registerTunnelFrameListener(client *toki.TcpClient) {
|
|
toki.AddListenerTyped[*iop.ProviderTunnelFrame](&client.Communicator, func(f *iop.ProviderTunnelFrame) {
|
|
s.handlerMu.RLock()
|
|
handler := s.onTunnelFrame
|
|
s.handlerMu.RUnlock()
|
|
if handler == nil {
|
|
s.logger.Warn("provider tunnel frame dropped: no tunnel handler",
|
|
zap.String("run_id", f.GetRunId()),
|
|
zap.String("tunnel_id", f.GetTunnelId()),
|
|
)
|
|
return
|
|
}
|
|
handler(f)
|
|
})
|
|
}
|
|
|
|
// registerNodeRequestListener installs the token-based RegisterRequest handler.
|
|
func (s *Server) registerNodeRequestListener(client *toki.TcpClient) {
|
|
toki.AddRequestListenerTyped[*iop.RegisterRequest, *iop.RegisterResponse](
|
|
&client.Communicator,
|
|
func(req *iop.RegisterRequest) (*iop.RegisterResponse, error) {
|
|
return s.handleRegisterRequest(client, req)
|
|
},
|
|
)
|
|
}
|
|
|
|
// handleRegisterRequest validates the node token, binds the registered-node
|
|
// listeners, and registers the node. The event/registry ordering is preserved:
|
|
// the node-event and disconnect listeners are bound before RegisterIfAbsent, and
|
|
// the connected event is emitted only after a successful registration.
|
|
func (s *Server) handleRegisterRequest(client *toki.TcpClient, req *iop.RegisterRequest) (*iop.RegisterResponse, error) {
|
|
nodeStore := s.nodeStoreSnapshot()
|
|
if nodeStore == nil {
|
|
return &iop.RegisterResponse{Accepted: false, Reason: "edge node store unavailable"}, nil
|
|
}
|
|
rec, ok := nodeStore.FindByToken(req.GetToken())
|
|
if !ok {
|
|
s.logger.Warn("unknown token", zap.String("token_prefix", safePrefix(req.GetToken())))
|
|
s.emitNodeEvent(events.NewEdgeNodeEvent(
|
|
events.SourceEdge,
|
|
events.TypeNodeRegistrationFailed,
|
|
"",
|
|
"",
|
|
events.ReasonUnknownToken,
|
|
map[string]string{
|
|
events.MetadataFailureReason: events.ReasonUnknownToken,
|
|
events.MetadataTokenPrefix: safePrefix(req.GetToken()),
|
|
},
|
|
))
|
|
return &iop.RegisterResponse{Accepted: false, Reason: "unknown token"}, nil
|
|
}
|
|
if s.requirePeerID {
|
|
conn, found := s.peerConnection(client)
|
|
identity, identityErr := auth.PeerWorkloadIdentity(conn)
|
|
if !found || identityErr != nil || identity.Role != "node" || identity.Name != rec.ID {
|
|
s.logger.Warn("node registration rejected: authenticated identity mismatch",
|
|
zap.String("node_id", rec.ID),
|
|
)
|
|
return &iop.RegisterResponse{Accepted: false, Reason: "authenticated node identity mismatch"}, nil
|
|
}
|
|
}
|
|
|
|
cfg, err := edgenode.BuildConfigPayload(rec)
|
|
if err != nil {
|
|
s.logger.Error("build config payload failed",
|
|
zap.String("node_id", rec.ID),
|
|
zap.Error(err),
|
|
)
|
|
return &iop.RegisterResponse{Accepted: false, Reason: "internal config error"}, nil
|
|
}
|
|
|
|
entry := &edgenode.NodeEntry{
|
|
NodeID: rec.ID,
|
|
Alias: rec.Alias,
|
|
LifecycleState: edgenode.LifecycleConnected,
|
|
Client: client,
|
|
Index: rec.Index,
|
|
HasIndex: true,
|
|
CredentialRecipientKeyID: req.GetCredentialRecipientKeyId(),
|
|
CredentialRecipientPublicKey: append([]byte(nil), req.GetCredentialRecipientPublicKey()...),
|
|
}
|
|
s.bindNodeEventListener(client, rec)
|
|
s.bindDisconnectListener(client, rec)
|
|
if !s.registry.RegisterIfAbsent(entry) {
|
|
return s.rejectDuplicateRegistration(rec), nil
|
|
}
|
|
// Accepted registration claims ownership and delivers config only. It does NOT
|
|
// open dispatch eligibility, pump stranded waiters, or emit the connected
|
|
// event yet: the node has not applied this config or installed its message
|
|
// handler, so any run/tunnel request sent now would be dropped on the node
|
|
// side. The node applies the config from this response, installs its handler,
|
|
// and then sends NodeReadyRequest — the authoritative connect lifecycle and
|
|
// the connected event run there, exactly once, once dispatch is safe.
|
|
s.logger.Info("node registration accepted, awaiting dispatch-ready",
|
|
zap.String("node_id", rec.ID),
|
|
zap.String("alias", rec.Alias),
|
|
zap.Uint64("connection_generation", entry.ConnectionGeneration),
|
|
)
|
|
|
|
return &iop.RegisterResponse{
|
|
Accepted: true,
|
|
NodeId: rec.ID,
|
|
Alias: rec.Alias,
|
|
Config: cfg,
|
|
}, nil
|
|
}
|
|
|
|
// registerNodeReadyListener installs the NodeReadyRequest handler. The node sends
|
|
// this after applying its config and installing its message handler.
|
|
func (s *Server) registerNodeReadyListener(client *toki.TcpClient) {
|
|
toki.AddRequestListenerTyped[*iop.NodeReadyRequest, *iop.NodeReadyResponse](
|
|
&client.Communicator,
|
|
func(req *iop.NodeReadyRequest) (*iop.NodeReadyResponse, error) {
|
|
return s.handleReadyRequest(client, req), nil
|
|
},
|
|
)
|
|
}
|
|
|
|
// handleReadyRequest transitions the accepted connection identified by
|
|
// (req.NodeId, client) to dispatch-ready. Only the current owner of that id — the
|
|
// exact client that still holds the registry entry — can ready it, and only the
|
|
// first ready runs the authoritative connect lifecycle (which re-activates the
|
|
// node's provider resources and pumps its stranded waiters) and emits the
|
|
// connected event. A duplicate ready acks success without repeating either; a
|
|
// stale ready (superseded by a reconnect, or the entry already gone) is rejected
|
|
// so the node closes and reconnects instead of sitting idle believing it is live.
|
|
func (s *Server) handleReadyRequest(client *toki.TcpClient, req *iop.NodeReadyRequest) *iop.NodeReadyResponse {
|
|
nodeID := req.GetNodeId()
|
|
entry, transitioned, ok := s.registry.MarkDispatchReadyOwner(nodeID, client)
|
|
if !ok {
|
|
s.logger.Warn("stale node ready signal rejected", zap.String("node_id", nodeID))
|
|
return &iop.NodeReadyResponse{Ready: false, Reason: "connection is not the current registry owner"}
|
|
}
|
|
if !transitioned {
|
|
// Duplicate ready for an already-ready owner: idempotent success, no extra
|
|
// pump or event.
|
|
return &iop.NodeReadyResponse{Ready: true}
|
|
}
|
|
// First ready for this connection. Authoritative connected lifecycle first,
|
|
// then the observability event.
|
|
//
|
|
// Both steps are fenced on this exact connection, but by two gates that share
|
|
// the same generation, not one lock: the activation gate lives inside the
|
|
// queue lock (HandleNodeConnect → activateNode re-checks the current owner
|
|
// generation atomically with the resource mutation and pump, serialized against
|
|
// a disconnect's releaseNode), and the event gate is WithCurrentOwner. They are
|
|
// deliberately not wrapped in a single WithCurrentOwner: notifyNodeConnected
|
|
// activates provider resources whose pump re-resolves candidates against the
|
|
// registry (queue-lock → registry-lock order), so holding the registry lock
|
|
// across it would invert that order and deadlock. Each gate independently makes
|
|
// a stale (superseded or already-disconnected) generation a no-op, so a
|
|
// reconnect that supersedes this connection between the two steps still yields
|
|
// zero activation and zero connected event for the stale generation, and
|
|
// exactly one of each for the current owner.
|
|
s.notifyNodeConnected(nodeID, entry.ConnectionGeneration)
|
|
s.registry.WithCurrentOwner(entry, func() {
|
|
s.emitNodeReady(entry)
|
|
})
|
|
return &iop.NodeReadyResponse{Ready: true}
|
|
}
|
|
|
|
// rejectDuplicateRegistration logs and emits the duplicate-connection failure
|
|
// event and returns the rejection response.
|
|
func (s *Server) rejectDuplicateRegistration(rec *edgenode.NodeRecord) *iop.RegisterResponse {
|
|
s.logger.Warn("duplicate registration rejected",
|
|
zap.String("node_id", rec.ID),
|
|
)
|
|
s.emitNodeEvent(events.NewEdgeNodeEvent(
|
|
events.SourceEdge,
|
|
events.TypeNodeRegistrationFailed,
|
|
rec.ID,
|
|
rec.Alias,
|
|
events.ReasonDuplicateConnection,
|
|
map[string]string{events.MetadataFailureReason: events.ReasonDuplicateConnection},
|
|
))
|
|
return &iop.RegisterResponse{Accepted: false, Reason: "node already connected"}
|
|
}
|
|
|
|
// notifyNodeConnected invokes the authoritative connect handler for an accepted
|
|
// registration, ahead of the observability fanout. The handler read mirrors the
|
|
// disconnect path: it runs synchronously so a reconnect's resource activation
|
|
// and queue pump cannot be lost to a dropped bus delivery.
|
|
func (s *Server) notifyNodeConnected(nodeID string, generation uint64) {
|
|
s.handlerMu.RLock()
|
|
handler := s.onNodeConnect
|
|
s.handlerMu.RUnlock()
|
|
if handler != nil {
|
|
handler(nodeID, generation)
|
|
}
|
|
}
|
|
|
|
// emitNodeReady logs and emits the node-connected event once the node has
|
|
// signalled dispatch readiness. It is driven off the live registry entry so the
|
|
// announced identity is exactly the connection that became ready.
|
|
func (s *Server) emitNodeReady(entry *edgenode.NodeEntry) {
|
|
s.logger.Info("node ready",
|
|
zap.String("node_id", entry.NodeID),
|
|
zap.String("alias", entry.Alias),
|
|
)
|
|
s.emitNodeEvent(events.NewEdgeNodeEvent(
|
|
events.SourceEdge,
|
|
events.TypeNodeConnected,
|
|
entry.NodeID,
|
|
entry.Alias,
|
|
events.ReasonRegistered,
|
|
map[string]string{events.MetadataLifecycleState: edgenode.LifecycleConnected},
|
|
))
|
|
}
|
|
|
|
// bindNodeEventListener forwards node-originated EdgeNodeEvents, backfilling the
|
|
// node id/alias from the registered record when the node omitted them.
|
|
func (s *Server) bindNodeEventListener(client *toki.TcpClient, rec *edgenode.NodeRecord) {
|
|
toki.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(e *iop.EdgeNodeEvent) {
|
|
if e.NodeId == "" {
|
|
e.NodeId = rec.ID
|
|
}
|
|
if e.Alias == "" {
|
|
e.Alias = rec.Alias
|
|
}
|
|
s.emitNodeEvent(e)
|
|
})
|
|
}
|
|
|
|
// bindDisconnectListener classifies the disconnect reason, removes the node from
|
|
// the registry, and — only when this client still owned the entry — settles
|
|
// lifecycle cleanup before emitting the disconnected event.
|
|
//
|
|
// The ownership check gates everything downstream: a stale duplicate connection
|
|
// closing must not release the live connection's leases, nor announce that the
|
|
// still-connected node went away.
|
|
func (s *Server) bindDisconnectListener(client *toki.TcpClient, rec *edgenode.NodeRecord) {
|
|
client.AddDisconnectListener(func(_ *toki.TcpClient) {
|
|
transportInfo := client.DisconnectInfo()
|
|
fields := []zap.Field{zap.String("node_id", rec.ID)}
|
|
fields = append(fields, transportDisconnectFields(transportInfo)...)
|
|
|
|
// UnregisterIfClient is the ownership gate: it succeeds only for the client
|
|
// that still owns the entry and returns that connection's generation. A
|
|
// rejected duplicate or a connection already superseded by a reconnect gets
|
|
// ok=false here, so nothing downstream — lifecycle callback, lease release,
|
|
// or disconnected event — runs for a stale close.
|
|
generation, ok := s.registry.UnregisterIfClient(rec.ID, client)
|
|
if !ok {
|
|
s.logger.Debug("stale node connection closed", fields...)
|
|
return
|
|
}
|
|
fields = append(fields, zap.Uint64("connection_generation", generation))
|
|
s.logger.Info("node unregistered", fields...)
|
|
|
|
reason := events.ReasonTransportClosed
|
|
if transportInfo.Reason == "heartbeat_timeout" {
|
|
reason = events.ReasonHeartbeatTimeout
|
|
} else if s.stopping.Load() {
|
|
reason = events.ReasonEdgeShutdown
|
|
}
|
|
|
|
s.handlerMu.RLock()
|
|
disconnectHandler := s.onNodeDisconnect
|
|
s.handlerMu.RUnlock()
|
|
if disconnectHandler != nil {
|
|
disconnectHandler(rec.ID, generation, reason)
|
|
}
|
|
|
|
meta := transportDisconnectMetadata(transportInfo)
|
|
if meta == nil {
|
|
meta = make(map[string]string)
|
|
}
|
|
if reason == events.ReasonHeartbeatTimeout {
|
|
meta[events.MetadataFailureReason] = events.ReasonHeartbeatTimeout
|
|
meta[events.MetadataLifecycleState] = edgenode.LifecycleFailed
|
|
} else {
|
|
meta[events.MetadataLifecycleState] = edgenode.LifecycleFailed
|
|
}
|
|
s.emitNodeEvent(events.NewEdgeNodeEvent(
|
|
events.SourceEdge,
|
|
events.TypeNodeDisconnected,
|
|
rec.ID,
|
|
rec.Alias,
|
|
reason,
|
|
meta,
|
|
))
|
|
})
|
|
}
|