- Add edge node unit tests and transport package - Add node test client and related artifacts - Update bootstrap, node, and config modules - Add proto generated files - Update Makefile and configuration files
1096 lines
28 KiB
Text
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/iop-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/iop-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("iop-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: "iop-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/iop-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/iop-edge/main.go` | REFACTOR-5 | 수정 |
|
|
| `configs/edge.yaml` | REFACTOR-5 | 수정 |
|
|
|
|
---
|
|
|
|
## 최종 검증
|
|
|
|
터미널 두 개가 필요하다.
|
|
|
|
**터미널 A — edge 서버 실행:**
|
|
|
|
```bash
|
|
PATH=/config/go-sdk/bin:$PATH go build -o bin/iop-edge ./apps/edge/cmd/iop-edge
|
|
./bin/iop-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/iop-node ./apps/node/cmd/iop-node
|
|
./bin/iop-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/...
|
|
```
|