- Add node store implementation for edge app - Add adapters factory for node app - Update edge and node transport layers - Update domain rules for edge and node - Add bin scripts for edge and node - Update configs and documentation - Add agent-task node_centralized_mgmt directory
263 lines
8.4 KiB
Text
263 lines
8.4 KiB
Text
<!-- task=node_centralized_mgmt plan=1 tag=REVIEW_REFACTOR -->
|
|
|
|
# CLI 어댑터 config 직렬화 버그 수정 + alias 중복 검사 추가
|
|
|
|
## 이 파일을 읽는 구현 에이전트에게
|
|
|
|
각 항목의 체크리스트를 완료하면 즉시 체크 표시한다. 중간 검증 명령을 실제로 실행하고 출력을 `CODE_REVIEW.md`의 검증 결과 섹션에 붙여 넣는다.
|
|
|
|
## 배경
|
|
|
|
이전 구현(plan=0)에서 `buildConfigPayload`가 CLI 어댑터의 `args`/`env` 필드를 `[]string` 타입으로 `structpb.NewStruct`에 전달한다. `structpb.NewValue`는 `[]interface{}`만 처리하므로 `[]string`은 `proto: invalid type: []string` 오류를 반환하고, 이 오류를 `_`로 무시해 CLI 설정 전체가 소실된다. 추가로 `LoadFromConfig`가 중복 alias를 검사하지 않아 동일 alias 등록 시 node ID가 충돌하고 한 노드가 silent하게 덮어써진다.
|
|
|
|
---
|
|
|
|
### [REVIEW_REFACTOR-1] buildConfigPayload: []string → structpb 오류 수정
|
|
|
|
#### 문제
|
|
|
|
`apps/edge/internal/transport/server.go:160-161`:
|
|
```go
|
|
profiles[name] = map[string]any{
|
|
"command": p.Command,
|
|
"args": p.Args, // []string — structpb 오류 발생
|
|
"env": p.Env, // []string — structpb 오류 발생
|
|
}
|
|
```
|
|
`p.Args`와 `p.Env`는 `[]string`이다. `structpb.NewValue`는 `[]interface{}`만 처리하고 `[]string`을 받으면 `proto: invalid type: []string` 오류를 반환한다.
|
|
`st, _ := structpb.NewStruct(settings)` 로 오류를 무시하면 `st == nil`이 되어 CLI 어댑터 설정 전체가 소실되고, 노드에서 CLI 어댑터가 빈 profile 맵으로 초기화된다.
|
|
|
|
#### 해결 방법
|
|
|
|
**`apps/edge/internal/transport/server.go` 변경:**
|
|
|
|
1. `buildConfigPayload`의 시그니처를 오류 반환형으로 변경.
|
|
2. CLI 프로필에서 `[]string` → `[]interface{}` 변환 헬퍼 추가.
|
|
3. `addAdapter`를 오류 반환형으로 변경해 `structpb.NewStruct` 오류를 전파.
|
|
4. `onNodeConnected` 내 `buildConfigPayload` 호출 시 오류 처리.
|
|
|
|
Before (`buildConfigPayload`, L130-167):
|
|
```go
|
|
func buildConfigPayload(rec *edgenode.NodeRecord) *iop.NodeConfigPayload {
|
|
payload := &iop.NodeConfigPayload{ ... }
|
|
addAdapter := func(typ string, enabled bool, settings map[string]any) {
|
|
if !enabled { return }
|
|
st, _ := structpb.NewStruct(settings)
|
|
payload.Adapters = append(...)
|
|
}
|
|
// ...
|
|
if rec.Adapters.CLI.Enabled {
|
|
profiles := make(map[string]any)
|
|
for name, p := range rec.Adapters.CLI.Profiles {
|
|
profiles[name] = map[string]any{
|
|
"command": p.Command,
|
|
"args": p.Args,
|
|
"env": p.Env,
|
|
}
|
|
}
|
|
addAdapter("cli", true, map[string]any{"profiles": profiles})
|
|
}
|
|
return payload
|
|
}
|
|
```
|
|
|
|
After:
|
|
```go
|
|
func buildConfigPayload(rec *edgenode.NodeRecord) (*iop.NodeConfigPayload, error) {
|
|
payload := &iop.NodeConfigPayload{
|
|
Runtime: &iop.NodeRuntimeConfig{
|
|
Concurrency: int32(rec.Runtime.Concurrency),
|
|
WorkspaceRoot: rec.Runtime.WorkspaceRoot,
|
|
},
|
|
}
|
|
addAdapter := func(typ string, enabled bool, settings map[string]any) error {
|
|
if !enabled {
|
|
return nil
|
|
}
|
|
st, err := structpb.NewStruct(settings)
|
|
if err != nil {
|
|
return fmt.Errorf("buildConfigPayload: %s: %w", typ, err)
|
|
}
|
|
payload.Adapters = append(payload.Adapters, &iop.AdapterConfig{
|
|
Type: typ,
|
|
Enabled: true,
|
|
Settings: st,
|
|
})
|
|
return nil
|
|
}
|
|
if err := addAdapter("mock", true, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := addAdapter("ollama", rec.Adapters.Ollama.Enabled, map[string]any{
|
|
"base_url": rec.Adapters.Ollama.BaseURL,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := addAdapter("vllm", rec.Adapters.Vllm.Enabled, map[string]any{
|
|
"endpoint": rec.Adapters.Vllm.Endpoint,
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
if rec.Adapters.CLI.Enabled {
|
|
profiles := make(map[string]any)
|
|
for name, p := range rec.Adapters.CLI.Profiles {
|
|
profiles[name] = map[string]any{
|
|
"command": p.Command,
|
|
"args": stringsToAny(p.Args),
|
|
"env": stringsToAny(p.Env),
|
|
}
|
|
}
|
|
if err := addAdapter("cli", true, map[string]any{"profiles": profiles}); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
return payload, nil
|
|
}
|
|
|
|
func stringsToAny(ss []string) []interface{} {
|
|
out := make([]interface{}, len(ss))
|
|
for i, s := range ss {
|
|
out[i] = s
|
|
}
|
|
return out
|
|
}
|
|
```
|
|
|
|
`onNodeConnected` 내 호출부 변경 (L113-118):
|
|
|
|
Before:
|
|
```go
|
|
return &iop.RegisterResponse{
|
|
Accepted: true,
|
|
NodeId: rec.ID,
|
|
Alias: rec.Alias,
|
|
Config: buildConfigPayload(rec),
|
|
}, nil
|
|
```
|
|
|
|
After:
|
|
```go
|
|
cfg, err := 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
|
|
}
|
|
return &iop.RegisterResponse{
|
|
Accepted: true,
|
|
NodeId: rec.ID,
|
|
Alias: rec.Alias,
|
|
Config: cfg,
|
|
}, nil
|
|
```
|
|
|
|
필요한 import 추가: `"fmt"` (buildConfigPayload 오류 포맷용)
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [x] `apps/edge/internal/transport/server.go` — `buildConfigPayload` 시그니처 변경, `addAdapter` 오류 반환, `stringsToAny` 추가, `onNodeConnected` 호출부 오류 처리
|
|
|
|
#### 테스트 작성
|
|
|
|
신규: `apps/edge/internal/transport/server_test.go` — `TestBuildConfigPayload_CLIArgsRoundtrip`:
|
|
- `NodeRecord`에 CLI adapter enabled, profile에 `args: ["--foo", "--bar"]`, `env: ["KEY=val"]` 설정
|
|
- `buildConfigPayload(rec)` 호출
|
|
- 오류 없이 반환되는지 확인
|
|
- 반환된 payload의 CLI AdapterConfig Settings에서 args, env 값이 올바르게 저장되는지 확인
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go build ./apps/edge/internal/transport/...
|
|
go test ./apps/edge/internal/transport/...
|
|
```
|
|
|
|
---
|
|
|
|
### [REVIEW_REFACTOR-2] LoadFromConfig: 중복 alias 검사 추가
|
|
|
|
#### 문제
|
|
|
|
`apps/edge/internal/node/store.go:LoadFromConfig`가 중복 token은 검사하지만 중복 alias는 검사하지 않는다.
|
|
Alias가 동일하면 `"node-" + alias`로 파생되는 ID가 충돌하고, `byID` 맵에서 후자가 전자를 덮어쓴다.
|
|
|
|
Before (L66-86):
|
|
```go
|
|
func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) {
|
|
s := NewNodeStore()
|
|
seen := make(map[string]bool)
|
|
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 seen[d.Token] {
|
|
return nil, fmt.Errorf("node[%d] alias=%q: duplicate token", i, d.Alias)
|
|
}
|
|
seen[d.Token] = true
|
|
// ...
|
|
}
|
|
return s, nil
|
|
}
|
|
```
|
|
|
|
#### 해결 방법
|
|
|
|
After:
|
|
```go
|
|
func LoadFromConfig(defs []config.NodeDefinition) (*NodeStore, error) {
|
|
s := NewNodeStore()
|
|
seenToken := make(map[string]bool)
|
|
seenAlias := make(map[string]bool)
|
|
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)
|
|
}
|
|
seenToken[d.Token] = true
|
|
seenAlias[d.Alias] = true
|
|
// ...
|
|
}
|
|
return s, nil
|
|
}
|
|
```
|
|
|
|
#### 수정 파일 및 체크리스트
|
|
|
|
- [x] `apps/edge/internal/node/store.go` — `LoadFromConfig`에 alias 중복 검사 추가
|
|
|
|
#### 테스트 작성
|
|
|
|
기존 `apps/edge/internal/node/store_test.go`에 케이스 추가:
|
|
|
|
- `TestLoadFromConfig_DuplicateAlias`: alias 동일, token 다른 두 정의 → 오류 반환
|
|
|
|
#### 중간 검증
|
|
|
|
```bash
|
|
go test ./apps/edge/internal/node/...
|
|
```
|
|
|
|
---
|
|
|
|
## 수정 파일 요약
|
|
|
|
| 파일 | 항목 |
|
|
|------|------|
|
|
| `apps/edge/internal/transport/server.go` | REVIEW_REFACTOR-1 |
|
|
| `apps/edge/internal/transport/server_test.go` (신규) | REVIEW_REFACTOR-1 |
|
|
| `apps/edge/internal/node/store.go` | REVIEW_REFACTOR-2 |
|
|
| `apps/edge/internal/node/store_test.go` | REVIEW_REFACTOR-2 |
|
|
|
|
## 최종 검증
|
|
|
|
```bash
|
|
go build ./...
|
|
go test ./...
|
|
```
|
|
|
|
모든 테스트 PASS, 컴파일 오류 없음이 기대 결과이다.
|