iop/agent-task/edge_node_arch/plan_0.log

1096 lines
28 KiB
Text

<!-- task=edge_node_arch plan=1 tag=REFACTOR -->
# edge TCP 서버 + node TCP 클라이언트 구현 (proto-socket / protobuf)
## 이 파일을 읽는 구현 에이전트에게
각 체크리스트 항목을 완료한 뒤 `[x]`로 표시하세요.
중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md` 검증 결과 섹션에 붙여 넣으세요.
계획과 다르게 구현한 부분은 반드시 `계획 대비 변경 사항`에 기록하세요.
## 배경
이전 작업에서 node의 TCP 서버 코드(`transport/server.go`, `session.go`, `frame.go`,
`codec.go`, `tls.go`)와 JSON 기반 `packages/protocol/protocol.go`를 모두 삭제했다.
`apps/node/internal/node/node.go`는 삭제된 `protocol.*` 타입을 참조해 현재 컴파일
불가 상태다. 이 플랜은 올바른 아키텍처를 처음부터 구현한다: **edge가 TCP 서버로
여러 노드를 수용하고, node가 시작 시 edge에 클라이언트로 연결**한다. wire format은
`../proto-socket` 패키지 기반 protobuf이며 JSON을 사용하지 않는다.
## 의존 관계 및 구현 순서
```
REFACTOR-1 (proto gen + go.mod)
└─> REFACTOR-2 (node transport: client.go, session.go, parser.go)
└─> REFACTOR-3 (node.go — proto 타입으로 교체)
└─> REFACTOR-4 (node bootstrap — DialEdge 연결)
└─> REFACTOR-5 (edge server — 다중 노드 관리)
```
---
### [REFACTOR-1] proto 컴파일 및 go.mod에 proto-socket 추가
**문제**
`proto/gen/iop/` 디렉터리가 없어 proto 생성 타입을 사용할 수 없다.
`go.mod`에 `git.toki-labs.com/toki/common-proto-socket/go` 의존성이 없다.
**해결 방법**
`go.mod`에 require + replace를 추가하고 `make proto`로 Go 소스를 생성한다.
```
# Before — go.mod require 블록 (go.mod:5-14)
require (
github.com/prometheus/client_golang v1.20.5
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
go.uber.org/fx v1.22.2
go.uber.org/zap v1.27.0
google.golang.org/protobuf v1.36.5
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.33.1
)
# After
require (
git.toki-labs.com/toki/common-proto-socket/go v0.0.0-00010101000000-000000000000
github.com/prometheus/client_golang v1.20.5
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
go.uber.org/fx v1.22.2
go.uber.org/zap v1.27.0
google.golang.org/protobuf v1.36.5
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.33.1
)
replace git.toki-labs.com/toki/common-proto-socket/go => ../proto-socket/go
```
protoc 실행:
```bash
PATH=/config/go-sdk/bin:$PATH make proto
PATH=/config/go-sdk/bin:$PATH go mod tidy
```
`Makefile:20-28`의 `proto` 타겟이 이미 올바르게 정의되어 있으므로 그대로 사용한다.
**수정 파일 및 체크리스트**
- [x] `go.mod` — require + replace 추가
- [x] `proto/gen/iop/runtime.pb.go` — protoc 생성 (신규)
- [x] `proto/gen/iop/node.pb.go` — protoc 생성 (신규)
- [x] `proto/gen/iop/control.pb.go` — protoc 생성 (신규)
- [x] `proto/gen/iop/job.pb.go` — protoc 생성 (신규)
**테스트 작성**
생성 파일이므로 별도 테스트 불필요.
**중간 검증**
```bash
PATH=/config/go-sdk/bin:$PATH make proto
ls proto/gen/iop/
PATH=/config/go-sdk/bin:$PATH go mod tidy
```
기대 결과: `runtime.pb.go node.pb.go control.pb.go job.pb.go` 4개 파일 생성
---
### [REFACTOR-2] node transport 클라이언트 계층 구현
**문제**
`apps/node/internal/transport/` 디렉터리가 비어 있다.
proto-socket 기반의 edge 연결 클라이언트와 메시지 핸들러가 없다.
**해결 방법**
세 파일을 신규 생성한다.
**`apps/node/internal/transport/parser.go`**
node가 edge로부터 수신하는 메시지 타입의 파서를 등록한다.
```go
package transport
import (
"google.golang.org/protobuf/proto"
toki "git.toki-labs.com/toki/common-proto-socket/go"
iop "iop/proto/gen/iop"
)
func nodeParserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&iop.RunRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.RunRequest{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.CancelRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.CancelRequest{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.CapabilityRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.CapabilityRequest{}
return m, proto.Unmarshal(b, m)
},
}
}
```
**`apps/node/internal/transport/session.go`**
proto-socket `TcpClient`를 래핑하고, edge로부터 오는 메시지 리스너를 등록한다.
```go
package transport
import (
"context"
"sync"
toki "git.toki-labs.com/toki/common-proto-socket/go"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
iop "iop/proto/gen/iop"
)
// Handler processes IOP messages received from edge.
type Handler interface {
OnRunRequest(ctx context.Context, sess *Session, req *iop.RunRequest) error
OnCapabilityRequest(ctx context.Context, sess *Session) (*iop.CapabilityResponse, error)
OnCancel(ctx context.Context, sess *Session, req *iop.CancelRequest) error
}
// Session represents the node's persistent connection to edge.
type Session struct {
client *toki.TcpClient
nodeID string
logger *zap.Logger
cancelFns sync.Map // run_id → context.CancelFunc
}
func newSession(client *toki.TcpClient, handler Handler, nodeID string, logger *zap.Logger) *Session {
s := &Session{client: client, nodeID: nodeID, logger: logger}
toki.AddListenerTyped[*iop.RunRequest](&client.Communicator, func(req *iop.RunRequest) {
go func() {
if err := handler.OnRunRequest(context.Background(), s, req); err != nil {
logger.Warn("run request error",
zap.String("run_id", req.GetRunId()),
zap.Error(err),
)
}
}()
})
toki.AddRequestListenerTyped[*iop.CapabilityRequest, *iop.CapabilityResponse](
&client.Communicator,
func(_ *iop.CapabilityRequest) (*iop.CapabilityResponse, error) {
return handler.OnCapabilityRequest(context.Background(), s)
},
)
toki.AddListenerTyped[*iop.CancelRequest](&client.Communicator, func(req *iop.CancelRequest) {
if err := handler.OnCancel(context.Background(), s, req); err != nil {
logger.Warn("cancel error",
zap.String("run_id", req.GetRunId()),
zap.Error(err),
)
}
})
client.AddDisconnectListener(func(_ *toki.TcpClient) {
logger.Info("disconnected from edge", zap.String("node_id", nodeID))
})
return s
}
// Send transmits a proto message to edge.
func (s *Session) Send(m proto.Message) error {
return s.client.Send(m)
}
// IsAlive reports whether the connection is active.
func (s *Session) IsAlive() bool { return s.client.IsAlive() }
// Close terminates the connection to edge.
func (s *Session) Close() error { return s.client.Close() }
// RegisterCancel stores a cancel function for a running execution.
func (s *Session) RegisterCancel(runID string, cancel context.CancelFunc) {
s.cancelFns.Store(runID, cancel)
}
// DeregisterCancel removes a run's cancel function after completion.
func (s *Session) DeregisterCancel(runID string) {
s.cancelFns.Delete(runID)
}
// CancelRun invokes the cancel function for a run if one is registered.
func (s *Session) CancelRun(runID string) {
if v, ok := s.cancelFns.Load(runID); ok {
v.(context.CancelFunc)()
}
}
```
**`apps/node/internal/transport/client.go`**
edge 주소로 TCP 연결을 맺고 Session을 반환한다.
```go
package transport
import (
"context"
"fmt"
"net"
toki "git.toki-labs.com/toki/common-proto-socket/go"
"go.uber.org/zap"
)
const (
heartbeatIntervalSec = 30
heartbeatWaitSec = 10
)
// DialEdge connects to the edge TCP server and returns a Session.
func DialEdge(ctx context.Context, addr string, handler Handler, nodeID string, logger *zap.Logger) (*Session, error) {
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("transport: invalid addr %q: %w", addr, err)
}
port := 0
if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil {
return nil, fmt.Errorf("transport: invalid port %q: %w", portStr, err)
}
client, err := toki.DialTcp(ctx, host, port, heartbeatIntervalSec, heartbeatWaitSec, nodeParserMap())
if err != nil {
return nil, fmt.Errorf("transport: dial edge %s: %w", addr, err)
}
sess := newSession(client, handler, nodeID, logger)
logger.Info("connected to edge", zap.String("addr", addr), zap.String("node_id", nodeID))
return sess, nil
}
```
**수정 파일 및 체크리스트**
- [x] `apps/node/internal/transport/parser.go` — 신규 생성
- [x] `apps/node/internal/transport/session.go` — 신규 생성
- [x] `apps/node/internal/transport/client.go` — 신규 생성
**테스트 작성**
proto-socket의 TCP/heartbeat/disconnect 동작은 `../proto-socket/go/test/`에서 검증됨.
Session 연결 흐름은 REFACTOR-5 최종 통합 검증으로 커버. 단위 테스트 생략.
**중간 검증**
```bash
PATH=/config/go-sdk/bin:$PATH go build ./apps/node/internal/transport/...
```
기대 결과: 빌드 에러 없음
---
### [REFACTOR-3] node.go — proto 타입으로 교체
**문제**
`apps/node/internal/node/node.go`가 삭제된 `iop/packages/protocol` 패키지를 참조해
컴파일 불가 상태다. (node.go:18, :48, :108, :116, :126, :139-168)
**해결 방법**
`protocol.*` 참조를 `iop/proto/gen/iop` 생성 타입으로 교체한다.
```go
// Before (node.go:5-19 imports)
import (
"context"
"encoding/json"
"fmt"
"time"
"go.uber.org/zap"
"iop/apps/node/internal/adapters"
"iop/apps/node/internal/runtime"
"iop/apps/node/internal/store"
"iop/apps/node/internal/transport"
"iop/packages/config"
"iop/packages/protocol"
)
// After
import (
"context"
"fmt"
"time"
"go.uber.org/zap"
"iop/apps/node/internal/adapters"
"iop/apps/node/internal/runtime"
"iop/apps/node/internal/store"
"iop/apps/node/internal/transport"
"iop/packages/config"
iop "iop/proto/gen/iop"
)
```
```go
// Before (node.go:48)
func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *protocol.RunRequest) error {
n.logger.Info("run request received",
zap.String("run_id", req.RunID),
zap.String("adapter", req.Adapter),
zap.String("model", req.Model),
)
rr := runtime.RunRequest{
RunID: req.RunID,
Adapter: req.Adapter,
Model: req.Model,
Workspace: req.Workspace,
Policy: req.Policy,
Input: req.Input,
TimeoutSec: req.TimeoutSec,
Metadata: req.Metadata,
}
// After
// iop.RunRequest.Input / Policy 는 google.protobuf.Struct — AsMap()으로 변환.
func (n *Node) OnRunRequest(ctx context.Context, sess *transport.Session, req *iop.RunRequest) error {
n.logger.Info("run request received",
zap.String("run_id", req.GetRunId()),
zap.String("adapter", req.GetAdapter()),
zap.String("model", req.GetModel()),
)
rr := runtime.RunRequest{
RunID: req.GetRunId(),
Adapter: req.GetAdapter(),
Model: req.GetModel(),
Workspace: req.GetWorkspace(),
Policy: req.GetPolicy().AsMap(),
Input: req.GetInput().AsMap(),
TimeoutSec: int(req.GetTimeoutSec()),
Metadata: req.GetMetadata(),
}
```
```go
// Before (node.go:108-122)
func (n *Node) OnCapabilityRequest(_ context.Context, _ *transport.Session) (*protocol.CapabilityResponse, error) {
resp := &protocol.CapabilityResponse{NodeID: n.nodeID}
for _, a := range n.registry.All() {
caps, err := a.Capabilities(context.Background())
if err != nil {
n.logger.Warn("capabilities query failed", zap.String("adapter", a.Name()), zap.Error(err))
continue
}
resp.Adapters = append(resp.Adapters, protocol.AdapterInfo{
Name: caps.AdapterName,
Models: caps.Models,
MaxConcurrency: caps.MaxConcurrency,
})
}
return resp, nil
}
// After
func (n *Node) OnCapabilityRequest(_ context.Context, _ *transport.Session) (*iop.CapabilityResponse, error) {
resp := &iop.CapabilityResponse{NodeId: n.nodeID}
for _, a := range n.registry.All() {
caps, err := a.Capabilities(context.Background())
if err != nil {
n.logger.Warn("capabilities query failed", zap.String("adapter", a.Name()), zap.Error(err))
continue
}
resp.Adapters = append(resp.Adapters, &iop.AdapterInfo{
Name: caps.AdapterName,
Models: caps.Models,
MaxConcurrency: int32(caps.MaxConcurrency),
})
}
return resp, nil
}
```
```go
// Before (node.go:126-129)
func (n *Node) OnCancel(_ context.Context, sess *transport.Session, req *protocol.CancelRequest) error {
n.logger.Info("cancel request", zap.String("run_id", req.RunID))
sess.CancelRun(req.RunID)
return nil
}
// After
func (n *Node) OnCancel(_ context.Context, sess *transport.Session, req *iop.CancelRequest) error {
n.logger.Info("cancel request", zap.String("run_id", req.GetRunId()))
sess.CancelRun(req.GetRunId())
return nil
}
```
```go
// Before (node.go:138-169) sessionSink.Emit
func (s *sessionSink) Emit(_ context.Context, event runtime.RuntimeEvent) error {
var usageInfo *protocol.UsageInfo
if event.Usage != nil {
usageInfo = &protocol.UsageInfo{
InputTokens: event.Usage.InputTokens,
OutputTokens: event.Usage.OutputTokens,
}
}
re := protocol.RunEvent{...}
payload, err := json.Marshal(re)
if err != nil {
return err
}
return s.sess.Send(&protocol.Envelope{...})
}
// After — Envelope 없이 RunEvent 직접 전송 (proto-socket이 래핑)
func (s *sessionSink) Emit(_ context.Context, event runtime.RuntimeEvent) error {
re := &iop.RunEvent{
RunId: event.RunID,
Type: string(event.Type),
Delta: event.Delta,
Message: event.Message,
Error: event.Error,
Timestamp: event.Timestamp.UnixNano(),
}
if event.Usage != nil {
re.Usage = &iop.Usage{
InputTokens: int32(event.Usage.InputTokens),
OutputTokens: int32(event.Usage.OutputTokens),
}
}
return s.sess.Send(re)
}
```
**수정 파일 및 체크리스트**
- [x] `apps/node/internal/node/node.go` — imports 교체, 메서드 시그니처 및 본문 전체 교체
**테스트 작성**
타입 교체이며 로직은 동일하므로 기존 테스트 없음. 최종 통합으로 검증.
**중간 검증**
```bash
PATH=/config/go-sdk/bin:$PATH go build ./apps/node/...
```
기대 결과: 빌드 에러 없음
---
### [REFACTOR-4] node bootstrap — DialEdge 연결 추가
**문제**
`apps/node/internal/bootstrap/module.go:70-84`의 fx.Invoke가 metrics 서버만
기동하고 edge 연결을 하지 않는다.
**해결 방법**
fx OnStart에서 `transport.DialEdge`를 호출해 edge에 연결하고, OnStop에서 닫는다.
```go
// Before (bootstrap/module.go:70-84)
fx.Invoke(func(lc fx.Lifecycle, cfg *config.NodeConfig, logger *zap.Logger) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
go func() {
if err := observability.ServeMetrics(cfg.Metrics.Port); err != nil {
logger.Warn("metrics server exited", zap.Error(err))
}
}()
return nil
},
OnStop: func(_ context.Context) error { return nil },
})
}),
// After
fx.Invoke(func(lc fx.Lifecycle, n *node.Node, cfg *config.NodeConfig, logger *zap.Logger) {
var sess *transport.Session
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
s, err := transport.DialEdge(ctx, cfg.Transport.EdgeAddr, n, cfg.Node.ID, logger)
if err != nil {
return err
}
sess = s
go func() {
if err := observability.ServeMetrics(cfg.Metrics.Port); err != nil {
logger.Warn("metrics server exited", zap.Error(err))
}
}()
return nil
},
OnStop: func(_ context.Context) error {
if sess != nil {
return sess.Close()
}
return nil
},
})
}),
```
import에 `"iop/apps/node/internal/transport"` 추가.
**수정 파일 및 체크리스트**
- [x] `apps/node/internal/bootstrap/module.go` — import 추가, fx.Invoke 교체
**테스트 작성**
bootstrap 와이어링이므로 별도 테스트 불필요.
**중간 검증**
```bash
PATH=/config/go-sdk/bin:$PATH go build ./apps/node/...
```
기대 결과: 빌드 에러 없음
---
### [REFACTOR-5] edge TCP 서버 — 다중 노드 관리
**문제**
- `apps/edge/cmd/edge/main.go:27-29`의 serve 커맨드가 placeholder다.
- edge config 구조체가 없다.
- 여러 node가 연결될 수 있는 NodeRegistry가 없다.
**해결 방법**
proto-socket TcpServer로 여러 노드 연결을 수락하고,
각 노드의 nodeID·capabilities를 `NodeRegistry`에 등록·관리한다.
**`packages/config/config.go` — EdgeConfig 추가 (파일 끝에 append)**
```go
type EdgeConfig struct {
Server EdgeServerConf `mapstructure:"server" yaml:"server"`
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
}
type EdgeServerConf struct {
Listen string `mapstructure:"listen" yaml:"listen"`
}
func LoadEdge(cfgFile string) (*EdgeConfig, error) {
v := viper.New()
v.SetConfigFile(cfgFile)
v.SetDefault("server.listen", "0.0.0.0:9090")
v.SetDefault("logging.level", "info")
v.SetDefault("metrics.port", 9092)
if err := v.ReadInConfig(); err != nil {
return nil, err
}
var cfg EdgeConfig
return &cfg, v.Unmarshal(&cfg)
}
```
**`apps/edge/internal/node/registry.go` (신규)**
연결된 노드를 nodeID로 관리한다.
```go
package node
import (
"fmt"
"sync"
toki "git.toki-labs.com/toki/common-proto-socket/go"
iop "iop/proto/gen/iop"
)
// NodeEntry는 연결된 노드 하나를 나타낸다.
type NodeEntry struct {
NodeID string
Client *toki.TcpClient
Adapters []*iop.AdapterInfo
}
// Registry는 edge에 연결된 모든 node를 관리한다.
type Registry struct {
mu sync.RWMutex
nodes map[string]*NodeEntry // nodeID → entry
}
func NewRegistry() *Registry {
return &Registry{nodes: make(map[string]*NodeEntry)}
}
func (r *Registry) Register(entry *NodeEntry) {
r.mu.Lock()
defer r.mu.Unlock()
r.nodes[entry.NodeID] = entry
}
func (r *Registry) Unregister(nodeID string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.nodes, nodeID)
}
func (r *Registry) Get(nodeID string) (*NodeEntry, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
e, ok := r.nodes[nodeID]
return e, ok
}
func (r *Registry) All() []*NodeEntry {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*NodeEntry, 0, len(r.nodes))
for _, e := range r.nodes {
out = append(out, e)
}
return out
}
func (r *Registry) Count() int {
r.mu.RLock()
defer r.mu.RUnlock()
return len(r.nodes)
}
// Pick returns any one connected node (round-robin은 추후 구현).
func (r *Registry) Pick() (*NodeEntry, error) {
r.mu.RLock()
defer r.mu.RUnlock()
for _, e := range r.nodes {
return e, nil
}
return nil, fmt.Errorf("no nodes connected")
}
```
**`apps/edge/internal/transport/server.go` (신규)**
proto-socket TcpServer로 노드 연결을 수락하고 Registry에 등록한다.
연결 시 CapabilityRequest를 전송해 노드 정보를 확인한다.
```go
package transport
import (
"net"
"strconv"
"time"
toki "git.toki-labs.com/toki/common-proto-socket/go"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
edgenode "iop/apps/edge/internal/node"
iop "iop/proto/gen/iop"
)
const (
heartbeatIntervalSec = 30
heartbeatWaitSec = 10
capabilityTimeout = 5 * time.Second
)
func edgeParserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&iop.RunEvent{}): func(b []byte) (proto.Message, error) {
m := &iop.RunEvent{}
return m, proto.Unmarshal(b, m)
},
}
}
// Server wraps proto-socket TcpServer and manages node connections.
type Server struct {
tcp *toki.TcpServer
registry *edgenode.Registry
logger *zap.Logger
}
func NewServer(listen string, registry *edgenode.Registry, logger *zap.Logger) (*Server, error) {
host, portStr, err := net.SplitHostPort(listen)
if err != nil {
return nil, err
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, err
}
s := &Server{registry: registry, logger: logger}
s.tcp = toki.NewTcpServer(host, port, func(conn net.Conn) *toki.TcpClient {
return toki.NewTcpClient(conn, heartbeatIntervalSec, heartbeatWaitSec, edgeParserMap())
})
s.tcp.OnClientConnected = s.onNodeConnected
return s, nil
}
func (s *Server) Start(ctx context.Context) error {
if err := s.tcp.Start(ctx); err != nil {
return err
}
s.logger.Info("edge listening for nodes", zap.String("addr", s.tcp.Addr()))
return nil
}
func (s *Server) Stop() error {
return s.tcp.Stop()
}
func (s *Server) onNodeConnected(client *toki.TcpClient) {
s.logger.Info("node connection established")
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()),
)
})
go func() {
resp, err := toki.SendRequestTyped[*iop.CapabilityRequest, *iop.CapabilityResponse](
&client.Communicator,
&iop.CapabilityRequest{},
capabilityTimeout,
)
if err != nil {
s.logger.Warn("capability request failed", zap.Error(err))
_ = client.Close()
return
}
entry := &edgenode.NodeEntry{
NodeID: resp.GetNodeId(),
Client: client,
Adapters: resp.GetAdapters(),
}
s.registry.Register(entry)
s.logger.Info("node registered",
zap.String("node_id", entry.NodeID),
zap.Int("adapters", len(entry.Adapters)),
)
client.AddDisconnectListener(func(_ *toki.TcpClient) {
s.registry.Unregister(entry.NodeID)
s.logger.Info("node unregistered", zap.String("node_id", entry.NodeID))
})
}()
}
```
> **주의**: proto-socket `TcpServer`에 `Addr()` 메서드가 없으면 Start 로그에서
> 주소 출력을 생략하고 `logger.Info("edge listening for nodes")`만 사용한다.
**`apps/edge/internal/bootstrap/module.go` (신규)**
```go
package bootstrap
import (
"context"
"go.uber.org/fx"
"go.uber.org/zap"
edgenode "iop/apps/edge/internal/node"
"iop/apps/edge/internal/transport"
"iop/packages/config"
"iop/packages/observability"
)
func Module(cfg *config.EdgeConfig) fx.Option {
return fx.Options(
fx.Provide(
func() *config.EdgeConfig { return cfg },
func(cfg *config.EdgeConfig) (*zap.Logger, error) {
return observability.NewLogger(cfg.Logging.Level)
},
func() *edgenode.Registry {
return edgenode.NewRegistry()
},
func(cfg *config.EdgeConfig, reg *edgenode.Registry, logger *zap.Logger) (*transport.Server, error) {
return transport.NewServer(cfg.Server.Listen, reg, logger)
},
),
fx.Invoke(func(lc fx.Lifecycle, srv *transport.Server, cfg *config.EdgeConfig, logger *zap.Logger) {
lc.Append(fx.Hook{
OnStart: func(ctx context.Context) error {
if err := srv.Start(ctx); err != nil {
return err
}
go func() {
if err := observability.ServeMetrics(cfg.Metrics.Port); err != nil {
logger.Warn("metrics server exited", zap.Error(err))
}
}()
return nil
},
OnStop: func(_ context.Context) error {
return srv.Stop()
},
})
}),
)
}
```
**`apps/edge/cmd/edge/main.go` — serve 커맨드 구현**
```go
// Before (main.go:18-34) 전체
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
func main() { ... }
func rootCmd() *cobra.Command {
root := &cobra.Command{...}
root.AddCommand(&cobra.Command{
Use: "serve",
Run: func(...) { fmt.Println("edge serve: not yet implemented") },
})
return root
}
// After
package main
import (
"fmt"
"os"
"github.com/spf13/cobra"
"go.uber.org/fx"
"iop/apps/edge/internal/bootstrap"
"iop/packages/config"
)
var cfgFile string
func main() {
if err := rootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func rootCmd() *cobra.Command {
root := &cobra.Command{
Use: "edge",
Short: "IOP Edge — node gateway server",
}
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "configs/edge.yaml", "config file path")
root.AddCommand(serveCmd())
return root
}
func serveCmd() *cobra.Command {
return &cobra.Command{
Use: "serve",
Short: "Start the IOP edge server",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := config.LoadEdge(cfgFile)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
fx.New(bootstrap.Module(cfg)).Run()
return nil
},
}
}
```
**`configs/edge.yaml` 업데이트**
```yaml
# Before
server:
listen: "0.0.0.0:8080"
# After
server:
listen: "0.0.0.0:9090"
tls:
enabled: false
logging:
level: "info"
metrics:
port: 9092
```
**수정 파일 및 체크리스트**
- [x] `packages/config/config.go` — EdgeConfig, EdgeServerConf, LoadEdge 추가
- [x] `apps/edge/internal/node/registry.go` — 신규 생성
- [x] `apps/edge/internal/transport/server.go` — 신규 생성
- [x] `apps/edge/internal/bootstrap/module.go` — 신규 생성
- [x] `apps/edge/cmd/edge/main.go` — serve 커맨드 구현으로 교체
- [x] `configs/edge.yaml` — listen 포트 9090으로 수정
**테스트 작성**
NodeRegistry의 Register/Unregister/Pick 로직은 단위 테스트로 검증한다.
파일: `apps/edge/internal/node/registry_test.go`
```go
package node_test
import (
"testing"
toki "git.toki-labs.com/toki/common-proto-socket/go"
edgenode "iop/apps/edge/internal/node"
iop "iop/proto/gen/iop"
)
func TestRegistry_RegisterAndCount(t *testing.T) {
reg := edgenode.NewRegistry()
entry := &edgenode.NodeEntry{
NodeID: "node-001",
Client: &toki.TcpClient{},
Adapters: []*iop.AdapterInfo{{Name: "mock"}},
}
reg.Register(entry)
if reg.Count() != 1 {
t.Fatalf("expected 1 node, got %d", reg.Count())
}
}
func TestRegistry_Pick_Empty(t *testing.T) {
reg := edgenode.NewRegistry()
if _, err := reg.Pick(); err == nil {
t.Fatal("expected error on empty registry")
}
}
func TestRegistry_Unregister(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-001"})
reg.Unregister("node-001")
if reg.Count() != 0 {
t.Fatalf("expected 0 nodes after unregister, got %d", reg.Count())
}
}
```
**중간 검증**
```bash
PATH=/config/go-sdk/bin:$PATH go build ./apps/edge/...
PATH=/config/go-sdk/bin:$PATH go test ./apps/edge/internal/node/...
```
기대 결과: 빌드 및 테스트 통과
---
## 수정 파일 요약
| 파일 | 항목 | 변경 유형 |
|------|------|-----------|
| `go.mod` | REFACTOR-1 | 수정 |
| `proto/gen/iop/*.pb.go` | REFACTOR-1 | 신규(생성) |
| `apps/node/internal/transport/parser.go` | REFACTOR-2 | 신규 |
| `apps/node/internal/transport/session.go` | REFACTOR-2 | 신규 |
| `apps/node/internal/transport/client.go` | REFACTOR-2 | 신규 |
| `apps/node/internal/node/node.go` | REFACTOR-3 | 수정 |
| `apps/node/internal/bootstrap/module.go` | REFACTOR-4 | 수정 |
| `packages/config/config.go` | REFACTOR-5 | 수정 |
| `apps/edge/internal/node/registry.go` | REFACTOR-5 | 신규 |
| `apps/edge/internal/transport/server.go` | REFACTOR-5 | 신규 |
| `apps/edge/internal/bootstrap/module.go` | REFACTOR-5 | 신규 |
| `apps/edge/cmd/edge/main.go` | REFACTOR-5 | 수정 |
| `configs/edge.yaml` | REFACTOR-5 | 수정 |
---
## 최종 검증
터미널 두 개가 필요하다.
**터미널 A — edge 서버 실행:**
```bash
PATH=/config/go-sdk/bin:$PATH go build -o bin/edge ./apps/edge/cmd/edge
./bin/edge serve --config configs/edge.yaml
```
기대 결과:
```
{"level":"info",...,"msg":"edge listening for nodes"}
```
**터미널 B — node 실행 (edge에 연결):**
```bash
PATH=/config/go-sdk/bin:$PATH go build -o bin/node ./apps/node/cmd/node
./bin/node serve --config configs/node.yaml
```
기대 결과 — node 로그:
```
{"level":"info",...,"msg":"connected to edge","addr":"localhost:9090","node_id":"node-001"}
```
기대 결과 — edge 로그:
```
{"level":"info",...,"msg":"node connection established"}
{"level":"info",...,"msg":"node registered","node_id":"node-001","adapters":1}
```
**전체 빌드 및 테스트:**
```bash
PATH=/config/go-sdk/bin:$PATH go build ./...
PATH=/config/go-sdk/bin:$PATH go test ./apps/edge/internal/node/...
```