// Package bootstrap wires the IOP Node application using go.uber.org/fx. package bootstrap import ( "context" "crypto/ed25519" "fmt" "io" "os" stdruntime "runtime" "sync" "time" "go.uber.org/fx" "go.uber.org/zap" "iop/apps/node/internal/adapters" "iop/apps/node/internal/node" "iop/apps/node/internal/router" "iop/apps/node/internal/store" "iop/apps/node/internal/transport" "iop/apps/node/internal/workspace" "iop/packages/go/config" "iop/packages/go/credentiallease" "iop/packages/go/events" runtime "iop/packages/go/execution" "iop/packages/go/observability" iop "iop/proto/gen/iop" ) // readySignalTimeout bounds the post-handler NodeReadyRequest/ack round trip. A // ready failure is retryable (not a *transport.ConnectError), so the supervisor // reconnects rather than shutting the node down. const readySignalTimeout = 10 * time.Second // DialFunc is the signature used to connect to Edge. Can be replaced in tests. type DialFunc func(ctx context.Context, addr, token string, logger *zap.Logger) (*transport.RegisterResult, error) // Option configures Module behaviour; intended for testing only. type Option func(*moduleOpts) type moduleOpts struct { dialer DialFunc sleeper func(ctx context.Context, d time.Duration) metricsStarter func(port int) error } type connectRuntimeOptions struct { dialer DialFunc hostOS func() string setHandler func(*transport.Session, transport.Handler) signalReady func(*transport.Session, time.Duration) error } func (o connectRuntimeOptions) normalized() connectRuntimeOptions { if o.hostOS == nil { o.hostOS = func() string { return stdruntime.GOOS } } if o.setHandler == nil { o.setHandler = func(session *transport.Session, handler transport.Handler) { session.SetHandler(handler) } } if o.signalReady == nil { o.signalReady = func(session *transport.Session, timeout time.Duration) error { return session.SignalReady(timeout) } } return o } // WithDialer replaces the transport dial function. func WithDialer(fn DialFunc) Option { return func(o *moduleOpts) { o.dialer = fn } } // WithSleeper replaces the inter-retry sleep function. func WithSleeper(fn func(ctx context.Context, d time.Duration)) Option { return func(o *moduleOpts) { o.sleeper = fn } } // WithMetricsStarter replaces the metrics listener starter for tests. func WithMetricsStarter(fn func(port int) error) Option { return func(o *moduleOpts) { o.metricsStarter = fn } } // runtimeOwner holds one connection's resources and closes them idempotently. type runtimeOwner struct { reg *runtime.Registry sess *transport.Session st *store.Store workspace *workspace.Runtime closeRegistry func() closeWorkspace func() closeSession func() closeStore func() once sync.Once } func (r *runtimeOwner) close() { r.once.Do(func() { if r.closeRegistry != nil { r.closeRegistry() } else if r.reg != nil { _ = r.reg.Stop(context.Background()) } if r.closeWorkspace != nil { r.closeWorkspace() } else if r.workspace != nil { _ = r.workspace.Close() } if r.closeSession != nil { r.closeSession() } else if r.sess != nil { _ = r.sess.Close() } if r.closeStore != nil { r.closeStore() } else if r.st != nil { _ = r.st.Close() } }) } // connectRuntime dials edge and wires up adapters, store, router, and node handler. // On any failure after partial allocation the allocated resources are closed. func connectRuntime(ctx context.Context, cfg *config.NodeConfig, logger *zap.Logger, dialer DialFunc, overrides ...connectRuntimeOptions) (*runtimeOwner, error) { opts := connectRuntimeOptions{dialer: dialer} if len(overrides) > 0 { opts = overrides[0] if opts.dialer == nil { opts.dialer = dialer } } opts = opts.normalized() var ( result *transport.RegisterResult err error ) if opts.dialer == nil { result, err = transport.DialEdgeConfig(ctx, cfg, logger) } else { result, err = opts.dialer(ctx, cfg.Transport.EdgeAddr, cfg.Transport.Token, logger) } if err != nil { return nil, fmt.Errorf("dial edge: %w", err) } owner := &runtimeOwner{sess: result.Session} set, err := adapters.BuildConfigSet(result.Config, logger) if err != nil { owner.close() return nil, fmt.Errorf("build adapters: %w", err) } owner.reg = set.Registry workspaceRuntime, err := workspace.NewRuntime(result.Config.GetWorkspaces(), opts.hostOS(), logger) if err != nil { owner.close() return nil, fmt.Errorf("workspace catalog: %w", err) } owner.workspace = workspaceRuntime dsn, err := storeDSN() if err != nil { owner.close() return nil, err } st, err := store.New(dsn, logger) if err != nil { owner.close() return nil, fmt.Errorf("store: %w", err) } owner.st = st if err := set.Registry.Start(ctx); err != nil { owner.close() return nil, fmt.Errorf("start adapters: %w", err) } rtr := router.New(set.Registry, logger) globalConcurrency := int(result.Config.GetRuntime().GetConcurrency()) n := node.New(result.NodeID, rtr, st, globalConcurrency, os.Stdout, logger, set) n.SetWorkspaceRuntime(workspaceRuntime) if cfg.CredentialPlane.Enabled { recipientPrivate, loadErr := credentiallease.LoadPrivateKeyFile(cfg.CredentialPlane.RecipientPrivateKey, 32) if loadErr != nil { owner.close() return nil, fmt.Errorf("load credential recipient key: %w", loadErr) } defer zeroNodeKey(recipientPrivate) issuerPublic, loadErr := credentiallease.LoadPublicKeyFile(cfg.CredentialPlane.IssuerPublicKey, ed25519.PublicKeySize) if loadErr != nil { owner.close() return nil, fmt.Errorf("load credential issuer key: %w", loadErr) } defer zeroNodeKey(issuerPublic) consumer, consumerErr := credentiallease.NewConsumer(result.NodeID, cfg.CredentialPlane.RecipientKeyID, recipientPrivate, cfg.CredentialPlane.IssuerKeyID, ed25519.PublicKey(issuerPublic), cfg.CredentialPlane.ReplayCacheSize, nil) if consumerErr != nil { owner.close() return nil, fmt.Errorf("compose credential consumer: %w", consumerErr) } n.SetCredentialConsumer(consumer) } if result.Session != nil { result.Session.SetEventHandler(func(event *iop.EdgeNodeEvent) { printEdgeEvent(os.Stdout, event) }) } opts.setHandler(result.Session, n) // The handler is installed; only now tell edge we are ready to receive // dispatch. Edge opens run/tunnel/command eligibility and pumps any waiter // stranded while this node was offline strictly in response to this signal, so // the first request can never race ahead of the handler. A failed ready // handshake is retryable: close the partial owner and let the supervisor // reconnect rather than sit connected-but-unreachable. if err := opts.signalReady(result.Session, readySignalTimeout); err != nil { owner.close() return nil, fmt.Errorf("signal ready: %w", err) } logger.Info("connected to edge", zap.String("node_id", result.NodeID), zap.String("alias", result.Alias), ) return owner, nil } func zeroNodeKey(value []byte) { for i := range value { value[i] = 0 } } // Module returns the fx options that wire the node application. func Module(cfg *config.NodeConfig, opts ...Option) fx.Option { mo := &moduleOpts{ dialer: nil, metricsStarter: observability.ServeMetrics, sleeper: func(ctx context.Context, d time.Duration) { if d <= 0 { return } timer := time.NewTimer(d) select { case <-ctx.Done(): timer.Stop() case <-timer.C: } }, } for _, opt := range opts { opt(mo) } return fx.Options( fx.Provide( func() *config.NodeConfig { return cfg }, func(cfg *config.NodeConfig) (*zap.Logger, error) { return observability.NewLoggerWithFile(cfg.Logging.Level, cfg.Logging.Pretty, cfg.Logging.Path) }, ), fx.Invoke(func(lc fx.Lifecycle, cfg *config.NodeConfig, logger *zap.Logger, shutdowner fx.Shutdowner) { sup := &runtimeSupervisor{ cfg: cfg, logger: logger, dialer: mo.dialer, sleeper: mo.sleeper, metricsStarter: mo.metricsStarter, shutdowner: shutdowner, } lc.Append(fx.Hook{ OnStart: func(ctx context.Context) error { return sup.start(ctx) }, OnStop: func(_ context.Context) error { sup.stop() return nil }, }) }), ) } func storeDSN() (string, error) { return "file:iop.db?cache=shared&mode=rwc", nil } func printEdgeEvent(out io.Writer, event *iop.EdgeNodeEvent) { if out == nil { return } switch event.GetType() { case events.TypeEdgeDisconnected: fmt.Fprintf(out, "[edge-event] disconnected reason=%q%s\n", event.GetReason(), transportCloseDetail(event.GetMetadata())) default: fmt.Fprintf(out, "[edge-event] %s reason=%q%s\n", event.GetType(), event.GetReason(), transportCloseDetail(event.GetMetadata())) } } func transportCloseDetail(metadata map[string]string) string { if metadata == nil { return "" } detail := "" if reason := metadata[events.MetadataTransportCloseReason]; reason != "" { detail += fmt.Sprintf(" transport_close_reason=%q", reason) } if err := metadata[events.MetadataTransportCloseError]; err != "" { detail += fmt.Sprintf(" transport_close_error=%q", err) } return detail }