From 6f36449daaaeba61feeb4faa4639f9a4dae96748 Mon Sep 17 00:00:00 2001 From: toki Date: Sat, 30 May 2026 20:00:37 +0900 Subject: [PATCH] feat(control-plane): edge connector implementation and control proto updates - Add edge connector module (edge.go, edge_registry.go, edge_server.go) - Add edge server tests (edge_server_test.go, edge_test.go) - Update control.proto with edge-related messages and RPCs - Generate proto files for Go, Dart - Update control-plane Dockerfile and configuration - Add client-side proto bindings - Update roadmap and milestones for control-plane-edge-wire-baseline - Add agent-task documentation for edge connector and local smoke tests - Update docker-compose.yml for edge support --- agent-roadmap/current.md | 4 +- .../phase/control-plane-portal-ops/PHASE.md | 4 + .../milestones/control-plane-client.md | 12 +- .../control-plane-edge-wire-baseline.md | 92 +++++++ .../code_review_cloud_G08_0.log | 157 +++++++++++ .../code_review_cloud_G08_1.log | 211 +++++++++++++++ .../01_cp_edge_endpoint/complete.log | 43 +++ .../01_cp_edge_endpoint/plan_cloud_G08_0.log | 199 ++++++++++++++ .../01_cp_edge_endpoint/plan_cloud_G08_1.log | 137 ++++++++++ .../CODE_REVIEW-cloud-G08.md | 131 ++++++++++ .../02+01_edge_connector/PLAN-cloud-G08.md | 244 ++++++++++++++++++ .../CODE_REVIEW-cloud-G08.md | 124 +++++++++ .../03+01,02_local_smoke/PLAN-cloud-G08.md | 172 ++++++++++++ apps/client/lib/gen/proto/iop/control.pb.dart | 203 +++++++++++++++ .../lib/gen/proto/iop/control.pbjson.dart | 63 +++++ apps/control-plane/Dockerfile | 2 +- apps/control-plane/README.md | 5 +- apps/control-plane/cmd/control-plane/main.go | 26 +- .../cmd/control-plane/main_test.go | 44 ++++ apps/control-plane/internal/wire/edge.go | 23 ++ .../internal/wire/edge_registry.go | 96 +++++++ .../internal/wire/edge_server.go | 138 ++++++++++ .../internal/wire/edge_server_test.go | 233 +++++++++++++++++ apps/control-plane/internal/wire/edge_test.go | 131 ++++++++++ apps/control-plane/internal/wire/wire.go | 32 ++- configs/control-plane.yaml | 1 + docker-compose.yml | 1 + proto/gen/iop/control.pb.go | 201 ++++++++++++++- proto/iop/control.proto | 22 ++ 29 files changed, 2720 insertions(+), 31 deletions(-) create mode 100644 agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md create mode 100644 agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/code_review_cloud_G08_0.log create mode 100644 agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/code_review_cloud_G08_1.log create mode 100644 agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/complete.log create mode 100644 agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/plan_cloud_G08_0.log create mode 100644 agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/plan_cloud_G08_1.log create mode 100644 agent-task/m-control-plane-edge-wire-baseline/02+01_edge_connector/CODE_REVIEW-cloud-G08.md create mode 100644 agent-task/m-control-plane-edge-wire-baseline/02+01_edge_connector/PLAN-cloud-G08.md create mode 100644 agent-task/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/CODE_REVIEW-cloud-G08.md create mode 100644 agent-task/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/PLAN-cloud-G08.md create mode 100644 apps/control-plane/internal/wire/edge.go create mode 100644 apps/control-plane/internal/wire/edge_registry.go create mode 100644 apps/control-plane/internal/wire/edge_server.go create mode 100644 apps/control-plane/internal/wire/edge_server_test.go create mode 100644 apps/control-plane/internal/wire/edge_test.go diff --git a/agent-roadmap/current.md b/agent-roadmap/current.md index ef06abb..44fc496 100644 --- a/agent-roadmap/current.md +++ b/agent-roadmap/current.md @@ -7,9 +7,9 @@ ## 활성 Milestone -- [계획] Control Plane과 Client +- [진행중] Control Plane-Edge Wire Baseline - Phase: `agent-roadmap/phase/control-plane-portal-ops/PHASE.md` - - 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md` + - 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md` ## 선택 규칙 diff --git a/agent-roadmap/phase/control-plane-portal-ops/PHASE.md b/agent-roadmap/phase/control-plane-portal-ops/PHASE.md index 2afac9f..3f8aaac 100644 --- a/agent-roadmap/phase/control-plane-portal-ops/PHASE.md +++ b/agent-roadmap/phase/control-plane-portal-ops/PHASE.md @@ -33,6 +33,10 @@ - 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/edge-command-control-plane-ops-boundary.md` - 요약: `iop-edge` local/field fallback과 Control Plane 기본 운영면의 책임 경계, shared Edge operation 분류 기준, 사용자 가이드 갱신 범위를 확정한다. +- [진행중] Control Plane-Edge Wire Baseline + - 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md` + - 요약: Control Plane과 Edge가 proto-socket 기반 IOP Wire Protocol로 연결되는 최소 통신 레일을 구축한다. + - [계획] Control Plane과 Client - 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md` - 요약: 여러 Edge를 연결하고 관찰하며 Edge 설정 변경, 명령 전달, 이벤트 수신을 담당하는 중앙 제어면과 Flutter client 운영면을 구축한다. diff --git a/agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md b/agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md index a5b3620..bf6b477 100644 --- a/agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md +++ b/agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-client.md @@ -19,13 +19,12 @@ Client는 NomadCode 전용 화면이 아니라 IOP가 독립적으로 운영되 - 상태: 잠금 - 결정 필요: 아래 체크리스트 - [ ] Client의 첫 운영 화면 범위를 Edge/Node 상태 조회 중심으로 둘지, 명령/설정 변경까지 포함할지 결정한다. - - [ ] Control Plane-Edge 연결에서 우선 구현할 IOP Wire Protocol 범위를 결정한다. - [ ] IOP client가 범용 운영 UI로 남기 위해 제외할 NomadCode 전용 UX 경계를 결정한다. ## 범위 - Control Plane Go server의 Edge connection 관리 -- Client-Control Plane, Control Plane-Edge IOP Wire Protocol 확장 +- Client-Control Plane wire surface와 Control Plane-Edge Wire Baseline 위의 운영 표면 확장 - Flutter client의 Runtime/Automation/Serving 운영 화면 기초 - Edge 상태 조회, 설정 변경, 명령 전달, 이벤트 수신 - Edge agent 생성과 bootstrap command 발급 상태 조회/관리 @@ -37,8 +36,8 @@ Client는 NomadCode 전용 화면이 아니라 IOP가 독립적으로 운영되 ### Epic: [edge-ops-boundary] Edge Operations Boundary - [ ] [edge-mediated] Control Plane은 Node를 직접 연결하거나 직접 스케줄링하지 않는다. -- [ ] [edge-status] Control Plane은 하나 이상의 Edge 연결 상태를 조회할 수 있다. 검증: Edge 연결 상태 조회 경로가 동작하는지 확인한다. -- [ ] [edge-command-events] Edge 명령 전달과 이벤트 수신 흐름이 IOP Wire Protocol 기준으로 문서화된다. +- [ ] [edge-status] Control Plane은 선행 Wire Baseline으로 연결된 하나 이상의 Edge 상태를 조회할 수 있다. 검증: Edge 연결 상태 조회 경로가 동작하는지 확인한다. +- [ ] [edge-command-events] Edge 명령 전달과 이벤트 수신 흐름이 Control Plane-Edge Wire Baseline 위의 IOP Wire Protocol 기준으로 문서화된다. - [ ] [client-via-cp] Client는 Control Plane을 통해 Edge 운영 상태를 조회한다. ### Epic: [client-wire-surface] Client Wire Surface @@ -72,7 +71,8 @@ Client는 NomadCode 전용 화면이 아니라 IOP가 독립적으로 운영되 - 관련 경로: `apps/control-plane`, `apps/client`, `apps/control-plane/README.md`, `apps/client/README.md`, `agent-test/local/control-plane-smoke.md`, `docker-compose.yml`, `scripts/dev/web.sh` - 표준선(선택): Control Plane은 Node를 직접 스케줄링하지 않고 Edge를 통해 관찰/제어한다. +- 표준선(선택): Control Plane-Edge 통신 레일은 `Control Plane-Edge Wire Baseline` 선행 Milestone에서 먼저 구축한다. - 표준선(선택): Client-Control Plane의 첫 wire surface는 `/client` WebSocket hello handshake를 기준으로 둔다. -- 선행 작업: Flutter-first Client 마이그레이션, Edge Command와 Control Plane 운영 경계, Agent Bootstrap과 OTO 등록, 정책/이력/감사 일부 기준 +- 선행 작업: Control Plane-Edge Wire Baseline, Flutter-first Client 마이그레이션, Edge Command와 Control Plane 운영 경계, Agent Bootstrap과 OTO 등록, 정책/이력/감사 일부 기준 - 후속 작업: Multi-Edge 운영 -- 확인 필요: Client 첫 화면 범위, Control Plane-Edge wire 우선순위, NomadCode 전용 UX 제외 기준 +- 확인 필요: Client 첫 화면 범위, NomadCode 전용 UX 제외 기준 diff --git a/agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md b/agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md new file mode 100644 index 0000000..f31978f --- /dev/null +++ b/agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md @@ -0,0 +1,92 @@ +# Milestone: Control Plane-Edge Wire Baseline + +## 위치 + +- Roadmap: `agent-roadmap/ROADMAP.md` +- Phase: `agent-roadmap/phase/control-plane-portal-ops/PHASE.md` + +## 목표 + +Control Plane과 Edge가 proto-socket 기반 IOP Wire Protocol로 연결되는 최소 통신 레일을 구축한다. +이 Milestone이 끝나면 Control Plane은 연결된 Edge를 식별하고, 상태 snapshot과 lifecycle event를 Edge-owned operation 경계를 통해 관찰할 수 있어야 한다. + +## 상태 + +[진행중] + +## 구현 잠금 + +- 상태: 해제 +- 결정 필요: 없음 + +## 범위 + +- Control Plane-Edge proto-socket TCP wire endpoint와 Edge outbound connector +- Edge hello/enrollment, heartbeat, reconnect, disconnect lifecycle +- Control Plane의 connected Edge in-memory registry +- Edge 상태 snapshot 요청/응답의 최소 계약 +- Edge node lifecycle event relay의 최소 골격 +- Control Plane-Edge 설정값과 local smoke 검증 기준 + +## 기능 + +### Epic: [wire-contract] Wire Contract + +Control Plane-Edge 연결에서 공유할 protobuf 메시지와 transport 경계를 정의한다. + +- [x] [edge-hello] Edge hello/enrollment protobuf 계약은 `edge_id`, `edge_name`, version, capabilities를 포함한다. 실제 Edge outbound 송신은 `[edge-outbound]`에서 다룬다. 검증: proto roundtrip 또는 wire handshake 테스트가 통과한다. +- [x] [wire-protocol] Control Plane-Edge 통신은 proto-socket TCP 기반 IOP native protocol로 정의되고, HTTP는 health/readiness/bootstrap 보조 표면에 남는다. +- [x] [no-node-direct] Control Plane-Edge 계약은 Node 직접 연결이나 직접 스케줄링 계약을 만들지 않는다. + +### Epic: [edge-connector] Edge Connector + +Edge가 Control Plane에 outbound로 붙고 연결 상태를 안정적으로 유지한다. + +- [ ] [edge-outbound] Edge는 설정된 Control Plane wire endpoint로 outbound 연결을 시작할 수 있다. 검증: 로컬 Control Plane과 Edge를 함께 띄웠을 때 hello가 성공한다. +- [ ] [heartbeat] Control Plane과 Edge는 heartbeat 또는 동등한 keepalive로 연결 생존 상태를 판단할 수 있다. +- [ ] [disconnect] Edge 종료, transport close, reconnect 시 Control Plane registry의 Edge 상태가 갱신된다. + +### Epic: [cp-edge-registry] Control Plane Edge Registry + +Control Plane은 연결된 Edge를 제품 운영 표면에서 소비할 수 있는 상태로 보관한다. + +- [ ] [edge-registry] Control Plane은 연결된 Edge의 id, name, protocol, connected state, last seen time을 in-memory registry로 조회할 수 있다. +- [ ] [edge-status] Control Plane은 Edge에 상태 snapshot을 요청하고 Edge-owned 상태를 응답받을 수 있다. 검증: 최소 status request/response 테스트가 통과한다. +- [ ] [event-relay] Edge의 node lifecycle event가 Control Plane까지 relay되는 최소 경로가 있다. + +### Epic: [operation-boundary] Operation Boundary + +Control Plane wire handler가 Edge 내부 구현을 복제하지 않고 Edge-owned operation을 소비하도록 정리한다. + +- [ ] [service-boundary] Edge wire handler는 node registry, run dispatch, command request 로직을 복제하지 않고 `apps/edge/internal/service`와 `events.Bus` 같은 표면 중립 경계를 사용한다. +- [ ] [local-smoke] Control Plane-Edge wire baseline을 확인하는 local smoke 기준이 `agent-test/local` 또는 관련 README에 정리된다. + +## 완료 리뷰 + +- 상태: 없음 +- 요청일: 없음 +- 완료 근거: `[wire-contract]` Epic은 proto 계약과 parser roundtrip 기준으로 충족되었지만, Edge connector, Control Plane registry, operation boundary 기능 Task가 아직 남아 있다. +- 리뷰 필요: + - [ ] 사용자가 완료 결과를 확인했다 + - [ ] archive 이동을 승인했다 +- 리뷰 코멘트: 없음 + +## 범위 제외 + +- Control Plane을 Kubernetes식 중앙 스케줄러로 만드는 작업 +- Control Plane에서 Node를 직접 연결하거나 직접 스케줄링하는 작업 +- Edge 내부 DB나 Node registry 원본을 Control Plane DB로 대체하는 작업 +- Flutter client 운영 화면 구현 +- 정책, 권한, 감사 로그의 durable model +- Multi-Edge fleet-level routing 또는 federation + +## 작업 컨텍스트 + +- 관련 경로: `proto/iop`, `apps/control-plane/internal/wire`, `apps/control-plane/cmd/control-plane`, `apps/edge/internal/bootstrap`, `apps/edge/internal/service`, `apps/edge/internal/events`, `packages/config`, `configs/control-plane.yaml`, `configs/edge.yaml`, `agent-test/local/control-plane-smoke.md`, `agent-test/local/edge-smoke.md` +- 표준선(선택): Control Plane-Edge 통신은 proto-socket TCP 기반 IOP native protocol을 우선하고, 브라우저/앱 경계가 아니면 WebSocket을 기본 transport로 삼지 않는다. +- 표준선(선택): Edge가 소유한 node registry와 runtime state를 Control Plane이 복제하지 않고, Edge operation boundary를 통해 관찰/제어한다. +- 표준선(선택): Control Plane은 Node 직접 연결/직접 스케줄링 경로를 만들지 않는다. +- 진행 근거(2026-05-30): `proto/iop/control.proto`에 `EdgeHelloRequest`/`EdgeHelloResponse`를 추가하고 `make proto`, `make proto-dart`로 Go/Dart 생성물을 갱신했다. `apps/control-plane/internal/wire`에 Edge hello parser와 roundtrip/transport-boundary 테스트를 추가했으며 `go test ./apps/control-plane/... ./packages/... ./proto/gen/...`, `go test ./...`, `flutter test`가 통과했다. +- 선행 작업: Edge Command와 Control Plane 운영 경계 +- 후속 작업: Control Plane과 Client, 정책, 이력, 감사, Multi-Edge 운영 +- 확인 필요: 없음 diff --git a/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/code_review_cloud_G08_0.log b/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/code_review_cloud_G08_0.log new file mode 100644 index 0000000..8261681 --- /dev/null +++ b/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/code_review_cloud_G08_0.log @@ -0,0 +1,157 @@ + + +# Code Review Reference - EDGE_CONN + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-05-30 +task=m-control-plane-edge-wire-baseline/01_cp_edge_endpoint, plan=0, tag=EDGE_CONN + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G08.md` → `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` → `plan_cloud_G08_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-control-plane-edge-wire-baseline`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [EDGE_CONN-1] Control Plane config split | [x] | +| [EDGE_CONN-2] Edge TCP server and registry foundation | [x] | + +## 구현 체크리스트 + +- [x] [EDGE_CONN-1] Control Plane config에서 Client WS wire listen과 Edge TCP wire listen을 분리하고 둘 다 lifecycle에서 시작한다. +- [x] [EDGE_CONN-2] Control Plane Edge TCP server와 internal edge connection registry foundation을 구현하고 hello/disconnect 테스트를 추가한다. +- [x] 중간/최종 검증 명령을 실행하고 출력이 기대와 일치하는지 확인한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/`를 `agent-task/archive/YYYY/MM/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-edge-wire-baseline/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- heartbeat 상수 위치: 계획은 `wire.go`에 명확히 두라고 했고 그대로 `EdgeHeartbeatIntervalSec=30`, `EdgeHeartbeatWaitSec=45`를 `wire.go`에 추가했다. `NewTcpClient(conn, 30, 45, ...)`의 매직 넘버 대신 이 상수를 참조한다. +- hello 거부 처리: 계획의 "edge_id empty를 reject"를 응답 없는 error return이 아니라 `Accepted=false` + `Reason="edge_id is required"` 응답을 돌려주는 방식으로 구현했다. `AddRequestListenerTyped`는 handler가 error를 반환하면 응답을 전혀 보내지 않아 Edge가 timeout까지 대기하게 되므로, 명시적 거부 응답이 계약상 더 분명하다. registry에는 등록하지 않는다. +- run() 로그: 기존 단일 `wire endpoint reserved` 로그를 client/edge 두 경계로 나누고 각각 `transport`(`proto-socket-ws` / `proto-socket-tcp`)를 함께 남겨 두 listener가 분리되었음을 드러냈다. 계획 범위 내 보조 변경이다. +- 그 외 파일/구조/검증 명령은 계획과 동일하다. + +## 주요 설계 결정 + +- `EdgeServer`는 `proto_socket.NewTcpServer`로 `host:port`를 bind하고, 연결마다 `NewTcpClient(conn, EdgeHeartbeatIntervalSec, EdgeHeartbeatWaitSec, EdgeParserMap())`을 만든다. Client 경계는 기존 WS(`/client`) 그대로 두어 두 wire가 같은 listener를 공유하지 않는다. +- `EdgeRegistry`는 `edge_id` 키 `map[string]*EdgeConnectionState`를 mutex로 보관한다. hello에서 `MarkConnected`(connected=true, last seen, capabilities 복사), disconnect listener에서 `MarkDisconnected`(connected=false, last seen, `DisconnectInfo`)를 기록한다. `Snapshot`/`Len`은 방어적 복사로 내부 상태 노출을 막는다. +- connection별 `edgeID`는 hello 시점에 확정되어 disconnect listener가 어떤 entry를 끌지 알아야 하므로 per-connection 변수로 두고 `idMu`로 보호했다. hello/disconnect 콜백이 서로 다른 goroutine에서 실행될 수 있어 `-race`로도 검증했다. +- Edge hello 계약은 기존 `EdgeHelloRequest`/`EdgeHelloResponse` proto만 사용하고 Node 직접 스케줄링 필드를 요구하지 않는다. control-plane 도메인 규칙(Node 직접 연결/스케줄링 active contract 금지)을 준수한다. proto 변경 없음. +- registry는 internal foundation으로만 두고 public status query/event relay API는 후속 subtask로 남겼다(계획 범위 결정과 일치). + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Client WS `wire_listen`과 Edge TCP `edge_wire_listen`이 같은 listener를 공유하지 않는지 확인한다. +- Edge hello handler가 `edge_id` 없는 요청을 거부하고 Node scheduling 필드를 요구하지 않는지 확인한다. +- Disconnect listener가 registry connected state를 false로 바꾸는지 확인한다. +- `go test -count=1 ./apps/control-plane/...` 실제 출력이 붙었는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### EDGE_CONN-1 중간 검증 +``` +$ go test -count=1 ./apps/control-plane/cmd/control-plane +ok iop/apps/control-plane/cmd/control-plane 0.005s +``` + +### EDGE_CONN-2 중간 검증 +``` +$ go test -count=1 ./apps/control-plane/internal/wire +ok iop/apps/control-plane/internal/wire 0.287s +``` + +### 최종 검증 +``` +$ go test -count=1 ./apps/control-plane/... +ok iop/apps/control-plane/cmd/control-plane 0.006s +ok iop/apps/control-plane/internal/wire 0.285s +``` + +### 추가 검증 (참고) +``` +$ go vet ./apps/control-plane/... +(no output) + +$ go test -race -count=1 ./apps/control-plane/internal/wire +ok iop/apps/control-plane/internal/wire 1.304s +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## 코드리뷰 결과 + +- 종합 판정: FAIL +- 차원별 평가: + - Correctness: Fail + - Completeness: Fail + - Test coverage: Fail + - API contract: Fail + - Code quality: Pass + - Plan deviation: Fail + - Verification trust: Fail +- 발견된 문제: + - Required: `apps/control-plane/internal/wire/edge_registry.go:37` / `apps/control-plane/internal/wire/edge_registry.go:52` - registry가 `edge_id`만으로 현재 연결을 표현하고 disconnect도 `edge_id`만으로 `Connected=false`를 기록합니다. 같은 Edge가 재연결하거나 중복 연결된 뒤 오래된 connection의 disconnect listener가 늦게 실행되면 새 연결 상태를 disconnected로 덮을 수 있습니다. connection generation/token을 registry state에 저장하고 `MarkDisconnected`가 현재 connection token과 일치할 때만 false로 바꾸도록 수정한 뒤, stale disconnect가 새 연결을 끊지 않는 테스트를 추가하세요. + - Required: `docker-compose.yml:45` / `apps/control-plane/Dockerfile:25` - 새 기본 Edge TCP endpoint `0.0.0.0:19081`을 `run()`에서 시작하지만 Docker image와 compose service는 19081을 expose/publish하지 않습니다. container/compose 실행 환경에서 외부 Edge가 Control Plane Edge wire endpoint에 붙을 수 없으므로 `EXPOSE 9080 19080 19081`, compose port mapping `${IOP_CONTROL_PLANE_EDGE_WIRE_PORT:-19081}:19081`, 필요한 env/docs 표면을 함께 추가하세요. + - Required: `agent-task/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/CODE_REVIEW-cloud-G08.md:76` - review evidence가 "proto 변경 없음"이라고 기록했지만 실제 diff는 `proto/iop/control.proto`, Go 생성물, Dart 생성물을 변경했습니다. 구현 증거와 계획 대비 변경 사항을 실제 diff와 일치시키고, proto 생성물이 원본과 일치한다는 검증 출력까지 다음 review stub에 기록하세요. +- 다음 단계: FAIL 후속 plan/review를 작성한다. diff --git a/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/code_review_cloud_G08_1.log b/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/code_review_cloud_G08_1.log new file mode 100644 index 0000000..7a499c9 --- /dev/null +++ b/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/code_review_cloud_G08_1.log @@ -0,0 +1,211 @@ + + +# Code Review Reference - REVIEW_EDGE_CONN + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-05-30 +task=m-control-plane-edge-wire-baseline/01_cp_edge_endpoint, plan=1, tag=REVIEW_EDGE_CONN + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G08.md` → `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` → `plan_cloud_G08_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-control-plane-edge-wire-baseline`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목                        | 완료 여부 | +| -----------------------------------------------------| -----------| +| [REVIEW_EDGE_CONN-1] Registry generation guard   | [x]    | +| [REVIEW_EDGE_CONN-2] Container and operator surface | [x]    | +| [REVIEW_EDGE_CONN-3] Proto scope and evidence trust | [x]    | + +## 구현 체크리스트 + +- [x] [REVIEW_EDGE_CONN-1] Edge registry disconnect를 connection generation으로 보호하고 stale disconnect 테스트를 추가한다. +- [x] [REVIEW_EDGE_CONN-2] Edge TCP wire endpoint를 Docker/compose/docs 운영 표면에 노출한다. +- [x] [REVIEW_EDGE_CONN-3] proto 변경 범위와 생성물 검증 증거를 실제 diff와 일치하게 기록한다. +- [x] 중간/최종 검증 명령을 실행하고 출력이 기대와 일치하는지 확인한다. +- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/`를 `agent-task/archive/YYYY/MM/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-edge-wire-baseline/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +- REVIEW_EDGE_CONN-2 `docker compose config --quiet` 중간 검증은 이 환경에 `docker` 바이너리가 없어(`command not found`, exit 127) 실행하지 못했다. 명령 자체는 대체하지 않았고, 정적 표면 검증(`rg`)으로 19081 endpoint 노출을 확인했다. compose port 매핑 라인은 기존 `IOP_CONTROL_PLANE_WIRE_PORT` 라인과 동일한 형식이다. 이는 docker가 있는 환경에서 후속 에이전트가 재실행으로 닫을 수 있는 검증 증거 공백이며, 사용자 결정/외부 secret이 필요한 차단이 아니다. +- REVIEW_EDGE_CONN-3 Go 생성물 `cmp`는 byte 단위로는 exit 1이지만, 차이는 헤더 주석의 `protoc vX.Y.Z` 버전 스탬프 한 줄뿐이다(커밋본 `v5.29.3` vs 이 환경 `v3.21.12`). 메시지 정의는 source proto와 동일함을 버전 줄 제외 diff로 확인했다(`IDENTICAL_EXCEPT_VERSION_COMMENT`). Dart 생성물 두 개는 `cmp` exit 0으로 완전 일치한다. proto 파일은 추가 소스 변경이 불필요해 그대로 두고 증거만 정확히 기록했다(계획의 해결 방법과 일치). +- 그 외 파일/구조/검증 명령은 계획과 동일하다. + +## 주요 설계 결정 + +### REVIEW_EDGE_CONN-1 — registry generation guard +- `EdgeRegistry`에 monotonic `nextToken`을 두고 `MarkConnected`가 connection별 `token`(uint64)을 발급해 `EdgeConnectionState.token`(unexported, 내부 구현 세부)에 저장하고 반환한다. `MarkDisconnected(edgeID, token, ...)`는 현재 state의 token과 일치할 때만 `Connected=false`로 바꾼다. +- `edge_server.go`는 connection별로 hello 시 발급받은 token을 `idMu`로 보호해 보관하고, disconnect listener에서 같은 token을 전달한다. 같은 `edge_id`가 재연결하면 새 token이 현재값이 되어, 먼저 끊긴 stale connection의 늦은 disconnect는 무시된다. +- token은 외부 API 계약이 아니라 internal registry 구현 세부다(`Snapshot`이 반환하는 `EdgeConnectionState` 복사본에는 unexported로 남아 외부 표면에 노출되지 않는다). + +### REVIEW_EDGE_CONN-2 — container/operator surface +- `Dockerfile`은 `EXPOSE 9080 19080 19081`로 Edge TCP wire port를 추가 노출한다. +- `docker-compose.yml` control-plane 서비스는 `${IOP_CONTROL_PLANE_EDGE_WIRE_PORT:-19081}:19081`을 추가해 Client WS(`IOP_CONTROL_PLANE_WIRE_PORT`, 19080)와 host publish port 이름을 분리했다. +- `README.md`는 `server.wire_listen`(Client WS, `/client`)과 `server.edge_wire_listen`(Edge TCP) endpoint를 분리 설명하고, listen override(`IOP_WIRE_LISTEN`/`IOP_EDGE_WIRE_LISTEN`)와 compose host port override 이름을 반영한다. + +### REVIEW_EDGE_CONN-3 — proto scope and evidence trust +- 이번 milestone 작업의 실제 diff는 `proto/iop/control.proto`에 `EdgeHelloRequest`(edge_id, edge_name, version, capabilities, metadata)와 `EdgeHelloResponse`(accepted, protocol, server_time_unix_nano, message, reason) 메시지를 추가하고, 그 생성물 `proto/gen/iop/control.pb.go`, `apps/client/lib/gen/proto/iop/control.pb.dart`, `apps/client/lib/gen/proto/iop/control.pbjson.dart`를 함께 변경한다. (이전 archived review evidence의 "proto 변경 없음"은 이 milestone 작업 전체 diff 기준으로 부정확했고, 이번 follow-up에서 정정했다.) +- Edge hello 계약은 Node 직접 스케줄링 필드를 노출하지 않는다(`node_id`/`node_address`/`schedule_request`/`target` 없음). platform-common 규칙 "Control Plane hello 계열 proto는 Edge/Node scheduling 계약으로 확장하지 않는다"를 준수한다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- 같은 `edge_id`의 오래된 disconnect가 최신 connection state를 false로 바꾸지 않는지 확인한다. +- Dockerfile/compose/README가 `server.edge_wire_listen`과 19081 Edge TCP endpoint를 Client WS와 분리해 노출하는지 확인한다. +- proto 원본과 Go/Dart 생성물 변경이 review evidence와 생성물 비교 검증에 정확히 반영되었는지 확인한다. +- `go test -race -count=1 ./apps/control-plane/internal/wire` 실제 출력이 붙었는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### REVIEW_EDGE_CONN-1 중간 검증 +``` +$ go test -race -count=1 ./apps/control-plane/internal/wire +ok iop/apps/control-plane/internal/wire 1.652s +``` +(추가된 `TestEdgeServerStaleDisconnectDoesNotClearReconnect` 포함, race 무검출) + +### REVIEW_EDGE_CONN-2 중간 검증 +``` +$ docker compose config --quiet +/bin/bash: line 1: docker: command not found +(exit 127 — 이 환경에 docker 미설치. 정적 표면 검증으로 대체, 계획 대비 변경 사항 참조) + +$ rg --line-number "19081|IOP_CONTROL_PLANE_EDGE_WIRE_PORT|edge_wire_listen" apps/control-plane/Dockerfile docker-compose.yml apps/control-plane/README.md configs/control-plane.yaml +docker-compose.yml:47: - "${IOP_CONTROL_PLANE_EDGE_WIRE_PORT:-19081}:19081" +configs/control-plane.yaml:4: edge_wire_listen: "0.0.0.0:19081" +apps/control-plane/README.md:22:- Edge TCP proto-socket wire endpoint: `server.edge_wire_listen` (기본 `0.0.0.0:19081`). Edge가 outbound로 연결하는 Control Plane-Edge native wire 경계이며 Client WS와 같은 listener를 공유하지 않는다. +apps/control-plane/README.md:26:두 wire endpoint는 환경변수로 override한다. Client WS는 `IOP_WIRE_LISTEN`, Edge TCP는 `IOP_EDGE_WIRE_LISTEN`으로 listen 주소를 바꾼다. compose dev 환경은 host publish port를 `IOP_CONTROL_PLANE_WIRE_PORT`(기본 19080), `IOP_CONTROL_PLANE_EDGE_WIRE_PORT`(기본 19081)로 분리해 둔다. +apps/control-plane/Dockerfile:25:EXPOSE 9080 19080 19081 +``` +(Dockerfile/compose/README/config 모든 operator-facing 파일에 Edge TCP 19081 endpoint 노출 확인) + +### REVIEW_EDGE_CONN-3 중간 검증 +``` +$ tmpdir=$(mktemp -d) +$ protoc --go_out="$tmpdir" --go_opt=module=iop --proto_path=. proto/iop/runtime.proto proto/iop/node.proto proto/iop/control.proto proto/iop/job.proto +$ cmp -s proto/gen/iop/control.pb.go "$tmpdir/proto/gen/iop/control.pb.go"; echo "go cmp exit=$?" +go cmp exit=1 +$ diff proto/gen/iop/control.pb.go "$tmpdir/proto/gen/iop/control.pb.go" +4c4 +< // protoc v5.29.3 +--- +> // protoc v3.21.12 +$ diff <(grep -v 'protoc *v' proto/gen/iop/control.pb.go) <(grep -v 'protoc *v' "$tmpdir/proto/gen/iop/control.pb.go") && echo IDENTICAL_EXCEPT_VERSION_COMMENT +IDENTICAL_EXCEPT_VERSION_COMMENT +$ rm -rf "$tmpdir" +``` +(Go 생성물 차이는 헤더의 protoc 버전 스탬프 한 줄뿐. 메시지 정의는 source proto와 동일. 커밋본은 protoc v5.29.3, 이 환경은 v3.21.12. protoc-gen-go v1.36.11) + +``` +$ tmpdir=$(mktemp -d) +$ mkdir -p "$tmpdir/apps/client/lib/gen" +$ protoc --plugin=protoc-gen-dart=/config/.local/bin/protoc-gen-dart --dart_out="$tmpdir/apps/client/lib/gen" --proto_path=. --proto_path=/config/.local/include proto/iop/runtime.proto proto/iop/node.proto proto/iop/control.proto proto/iop/job.proto +$ cmp -s apps/client/lib/gen/proto/iop/control.pb.dart "$tmpdir/apps/client/lib/gen/proto/iop/control.pb.dart"; echo "pb.dart cmp exit=$?" +pb.dart cmp exit=0 +$ cmp -s apps/client/lib/gen/proto/iop/control.pbjson.dart "$tmpdir/apps/client/lib/gen/proto/iop/control.pbjson.dart"; echo "pbjson.dart cmp exit=$?" +pbjson.dart cmp exit=0 +$ rm -rf "$tmpdir" +``` +(Dart 생성물 두 개 모두 source proto 재생성 결과와 byte 단위 완전 일치) + +### 최종 검증 +``` +$ gofmt -l apps/control-plane/internal/wire/edge_registry.go apps/control-plane/internal/wire/edge_server.go apps/control-plane/internal/wire/edge_server_test.go +(no output) + +$ go test -count=1 ./apps/control-plane/... +ok iop/apps/control-plane/cmd/control-plane 0.006s +ok iop/apps/control-plane/internal/wire 0.640s + +$ go test -race -count=1 ./apps/control-plane/internal/wire +ok iop/apps/control-plane/internal/wire 1.652s + +$ go test -count=1 ./packages/... ./proto/gen/... +? iop/packages/auth [no test files] +ok iop/packages/config 0.009s +? iop/packages/events [no test files] +ok iop/packages/hostsetup 0.004s +? iop/packages/jobs [no test files] +? iop/packages/metadata [no test files] +ok iop/packages/observability 0.009s +? iop/packages/policy [no test files] +? iop/packages/version [no test files] +? iop/proto/gen/iop [no test files] +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - Correctness: Pass + - Completeness: Pass + - Test coverage: Pass + - API contract: Pass + - Code quality: Pass + - Plan deviation: Pass + - Verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS 종결. `complete.log` 작성 후 task directory를 archive로 이동한다. diff --git a/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/complete.log b/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/complete.log new file mode 100644 index 0000000..0edf8ec --- /dev/null +++ b/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/complete.log @@ -0,0 +1,43 @@ +# Complete - m-control-plane-edge-wire-baseline/01_cp_edge_endpoint + +## 완료 일시 + +2026-05-30 + +## 요약 + +Control Plane Edge TCP endpoint와 registry foundation 후속 리뷰 루프를 2회차에서 PASS로 종료했다. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_cloud_G08_0.log` | `code_review_cloud_G08_0.log` | FAIL | registry stale disconnect, container/compose port exposure, proto evidence mismatch Required 이슈 확인 | +| `plan_cloud_G08_1.log` | `code_review_cloud_G08_1.log` | PASS | connection token guard, 19081 operator surface, proto evidence correction 완료 | + +## 구현/정리 내용 + +- Control Plane config에 Client WS `wire_listen`과 Edge TCP `edge_wire_listen`을 분리하고 lifecycle에서 둘 다 시작했다. +- Edge TCP proto-socket hello server와 internal Edge registry foundation을 추가했다. +- registry disconnect를 connection token으로 보호해 같은 `edge_id`의 stale disconnect가 최신 연결 상태를 덮지 않게 했다. +- Dockerfile, compose, README, config 예시에 Edge TCP 19081 운영 표면을 반영했다. +- `EdgeHelloRequest`/`EdgeHelloResponse` proto와 Go/Dart 생성물 변경 범위 및 생성물 검증 증거를 정리했다. + +## 최종 검증 + +- `gofmt -l apps/control-plane/internal/wire/edge_registry.go apps/control-plane/internal/wire/edge_server.go apps/control-plane/internal/wire/edge_server_test.go apps/control-plane/cmd/control-plane/main.go apps/control-plane/cmd/control-plane/main_test.go apps/control-plane/internal/wire/wire.go apps/control-plane/internal/wire/edge.go apps/control-plane/internal/wire/edge_test.go` - PASS; 출력 없음. +- `go test -count=1 ./apps/control-plane/...` - PASS; `cmd/control-plane`과 `internal/wire` 통과. +- `go test -race -count=1 ./apps/control-plane/internal/wire` - PASS; race detector 통과. +- `go test -count=1 ./packages/... ./proto/gen/...` - PASS; 공통 패키지와 proto gen 패키지 통과. +- `go vet ./apps/control-plane/...` - PASS; 출력 없음. +- `docker compose config --quiet` - BLOCKED; review 환경에 `docker` 바이너리가 없어 실행 불가. 대신 `rg --line-number "19081|IOP_CONTROL_PLANE_EDGE_WIRE_PORT|edge_wire_listen" apps/control-plane/Dockerfile docker-compose.yml apps/control-plane/README.md configs/control-plane.yaml`로 operator-facing 정적 표면을 확인했다. +- 임시 디렉터리 Go proto 재생성 후 `cmp -s proto/gen/iop/control.pb.go "$tmpdir/proto/gen/iop/control.pb.go"` - PASS; byte 단위 일치. +- 임시 디렉터리 Dart proto 재생성 후 `cmp -s` for `control.pb.dart` and `control.pbjson.dart` - PASS; byte 단위 일치. + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/plan_cloud_G08_0.log b/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/plan_cloud_G08_0.log new file mode 100644 index 0000000..3b34a5c --- /dev/null +++ b/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/plan_cloud_G08_0.log @@ -0,0 +1,199 @@ + + +# Plan - EDGE_CONN Control Plane Edge Endpoint + +## 이 파일을 읽는 구현 에이전트에게 + +구현의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 실제 stdout/stderr를 붙이고, active 파일을 유지한 채 리뷰 준비를 보고한다. 사용자만 결정할 수 있는 범위 충돌, 외부 환경/secret 준비, 제품 결정이 필요하면 review stub의 `사용자 리뷰 요청`을 근거와 함께 채우고 멈춘다. 단순 증거 공백은 후속 에이전트가 재실행할 수 있으므로 사용자 리뷰 요청으로 올리지 않는다. + +## 배경 + +두 번째 Epic `edge-connector`는 Edge outbound 연결 전에 Control Plane이 받을 native TCP endpoint를 필요로 한다. 현재 Control Plane의 `wire_listen`은 Client WebSocket `/client` 서버에만 쓰이고 있어 Edge TCP wire와 같은 port에서 공존할 수 없다. 먼저 Control Plane에 별도 Edge TCP wire endpoint와 hello 기반 registry foundation을 만든다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사유가 생기면 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션을 사용한다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/control-plane-portal-ops/PHASE.md` +- `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md` +- `agent-ops/rules/project/domain/control-plane/rules.md` +- `agent-ops/rules/project/domain/platform-common/rules.md` +- `agent-test/local/control-plane-smoke.md` +- `agent-test/local/platform-common-smoke.md` +- `apps/control-plane/cmd/control-plane/main.go` +- `apps/control-plane/cmd/control-plane/main_test.go` +- `apps/control-plane/internal/wire/wire.go` +- `apps/control-plane/internal/wire/client.go` +- `apps/control-plane/internal/wire/client_test.go` +- `apps/control-plane/internal/wire/edge.go` +- `apps/control-plane/internal/wire/edge_test.go` +- `configs/control-plane.yaml` +- `go.mod` +- `../proto-socket/go/tcp_server.go` +- `../proto-socket/go/tcp_client.go` +- `../proto-socket/go/base_client.go` + +### 테스트 커버리지 공백 + +- Control Plane config loading은 `main_test.go`가 `wire_listen` override만 검증한다. `edge_wire_listen` default/YAML/env 테스트가 없다. +- `apps/control-plane/internal/wire/edge_test.go`는 proto roundtrip만 검증한다. 실제 `proto_socket.NewTcpServer` hello request/response와 disconnect registry 갱신 테스트가 없다. +- HTTP health/readiness lifecycle은 기존 `go test ./apps/control-plane/...` 범위에 남는다. + +### 심볼 참조 + +- 제거/rename할 심볼 없음. +- `WireListen` call site: `apps/control-plane/cmd/control-plane/main.go:26`, `main.go:89`, `main.go:116`, `main.go:141`, `main.go:152`, `main_test.go:171`, `main_test.go:186`, `configs/control-plane.yaml:3`. +- `EdgeParserMap` call site: `apps/control-plane/internal/wire/edge_test.go:15`, `edge_test.go:67`. + +### 분할 판단 + +Split policy를 먼저 평가했다. 이 작업은 shared task group `m-control-plane-edge-wire-baseline` 아래의 split 작업이다. + +- `01_cp_edge_endpoint`: Control Plane native TCP endpoint와 registry foundation. 선행 없음. +- `02+01_edge_connector`: Edge config/outbound connector/runtime lifecycle. `01_cp_edge_endpoint` 완료 후 시작한다. +- `03+01,02_local_smoke`: 실제 Control Plane + Edge process smoke. `01`, `02` 완료 후 시작한다. + +분할 사유: Control Plane endpoint와 Edge connector가 서로 다른 domain ownership이고, 마지막 smoke는 process orchestration/terminal-agent 성격이므로 별도 검증 단위가 필요하다. + +### 범위 결정 근거 + +이 subtask는 Control Plane이 Edge hello를 받을 수 있는 endpoint와 내부 connection state까지만 만든다. Edge outbound connector, Edge config, smoke script, public registry API, status snapshot, event relay는 후속 subtask 또는 다른 Epic으로 둔다. Node 직접 연결/스케줄링 계약은 만들지 않는다. + +### 빌드 등급 + +build=`cloud-G08`, review=`cloud-G08`. 이유: protocol endpoint, heartbeat/disconnect lifecycle, config split이 포함된 Control Plane wire 변경이다. + +## 구현 체크리스트 + +- [ ] [EDGE_CONN-1] Control Plane config에서 Client WS wire listen과 Edge TCP wire listen을 분리하고 둘 다 lifecycle에서 시작한다. +- [ ] [EDGE_CONN-2] Control Plane Edge TCP server와 internal edge connection registry foundation을 구현하고 hello/disconnect 테스트를 추가한다. +- [ ] 중간/최종 검증 명령을 실행하고 출력이 기대와 일치하는지 확인한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [EDGE_CONN-1] Control Plane config split + +#### 문제 + +[apps/control-plane/cmd/control-plane/main.go](/config/workspace/iop/apps/control-plane/cmd/control-plane/main.go:23)는 `server.wire_listen` 하나만 갖고, [main.go](/config/workspace/iop/apps/control-plane/cmd/control-plane/main.go:152)에서 그 주소를 Client WebSocket server에 사용한다. Edge TCP server를 같은 주소에 bind하면 충돌한다. + +Before: + +```go +// apps/control-plane/cmd/control-plane/main.go:23 +type controlPlaneConfig struct { + Server struct { + Listen string `yaml:"listen"` + WireListen string `yaml:"wire_listen"` + } `yaml:"server"` +``` + +```go +// apps/control-plane/cmd/control-plane/main.go:152 +clientServer, err := wire.NewClientServer(cfg.Server.WireListen, logger) +``` + +#### 해결 방법 + +`WireListen`은 기존 Client WebSocket 경계로 유지하고 `EdgeWireListen string yaml:"edge_wire_listen"`을 추가한다. 기본값은 `0.0.0.0:19081`로 둔다. `IOP_EDGE_WIRE_LISTEN` env override를 추가한다. `configs/control-plane.yaml`에 `edge_wire_listen`을 추가한다. `run()`은 ClientServer와 새 EdgeServer를 각각 시작하고 shutdown 시 둘 다 stop한다. + +After shape: + +```go +type controlPlaneConfig struct { + Server struct { + Listen string `yaml:"listen"` + WireListen string `yaml:"wire_listen"` + EdgeWireListen string `yaml:"edge_wire_listen"` + } `yaml:"server"` +} +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/control-plane/cmd/control-plane/main.go`: config field/default/env override/start lifecycle 추가. +- [ ] `apps/control-plane/cmd/control-plane/main_test.go`: default, YAML, `IOP_EDGE_WIRE_LISTEN` override 테스트 추가. +- [ ] `configs/control-plane.yaml`: `server.edge_wire_listen` 예시 추가. + +#### 테스트 작성 + +테스트 작성. `TestLoadConfigEdgeWireListenDefault`, `TestLoadConfigEdgeWireListenEnvOverrides`를 추가하고 기존 `TestLoadConfigRepositoryLocalConfig`에서 tracked config가 edge wire listen을 로드하는지 확인한다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/control-plane/cmd/control-plane +``` + +기대: config loading tests pass. + +### [EDGE_CONN-2] Edge TCP server and registry foundation + +#### 문제 + +[apps/control-plane/internal/wire/edge.go](/config/workspace/iop/apps/control-plane/internal/wire/edge.go:12)는 parser map만 제공한다. TCP server, hello handler, connected/disconnected state 보관이 없다. + +Before: + +```go +// apps/control-plane/internal/wire/edge.go:12 +func EdgeParserMap() proto_socket.ParserMap { + return proto_socket.ParserMap{ +``` + +#### 해결 방법 + +`apps/control-plane/internal/wire/edge_server.go`와 `edge_registry.go`를 추가한다. + +- `EdgeServer`는 `proto_socket.NewTcpServer`로 `EdgeTransport` endpoint를 bind한다. +- 연결마다 `proto_socket.NewTcpClient(conn, 30, 45, EdgeParserMap())`을 만든다. +- `EdgeHelloRequest` listener가 `edge_id` empty를 reject하고, accepted response에 `Protocol`과 server time을 채운다. +- `EdgeRegistry`는 `edge_id`, `edge_name`, capabilities, protocol, connected, last seen, disconnect info를 mutex로 보관한다. +- disconnect listener는 connected=false와 last seen update를 기록한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/control-plane/internal/wire/edge_server.go`: TCP server, hello handler, disconnect listener 구현. +- [ ] `apps/control-plane/internal/wire/edge_registry.go`: internal registry model과 snapshot helpers 구현. +- [ ] `apps/control-plane/internal/wire/edge_test.go`: 기존 parser tests 유지. +- [ ] `apps/control-plane/internal/wire/edge_server_test.go`: hello accepted/rejected, disconnect state 테스트 추가. +- [ ] `apps/control-plane/internal/wire/wire.go`: heartbeat constants가 필요하면 이 패키지 안에서 명확히 둔다. + +#### 테스트 작성 + +테스트 작성. `TestEdgeServerHelloRegistersEdge`, `TestEdgeServerRejectsMissingEdgeID`, `TestEdgeServerDisconnectMarksEdgeDisconnected`, `TestEdgeHeartbeatWaitExceedsInterval`를 추가한다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/control-plane/internal/wire +``` + +기대: parser, client WS, edge TCP server tests pass. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/control-plane/cmd/control-plane/main.go` | EDGE_CONN-1 | +| `apps/control-plane/cmd/control-plane/main_test.go` | EDGE_CONN-1 | +| `configs/control-plane.yaml` | EDGE_CONN-1 | +| `apps/control-plane/internal/wire/edge_server.go` | EDGE_CONN-2 | +| `apps/control-plane/internal/wire/edge_registry.go` | EDGE_CONN-2 | +| `apps/control-plane/internal/wire/edge_server_test.go` | EDGE_CONN-2 | +| `apps/control-plane/internal/wire/edge_test.go` | EDGE_CONN-2 | +| `apps/control-plane/internal/wire/wire.go` | EDGE_CONN-2 | + +## 최종 검증 + +```bash +go test -count=1 ./apps/control-plane/... +``` + +기대: Control Plane command and wire package tests pass with fresh execution. Go test cache output is not acceptable for this plan; use `-count=1`. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/plan_cloud_G08_1.log b/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/plan_cloud_G08_1.log new file mode 100644 index 0000000..36e1585 --- /dev/null +++ b/agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/01_cp_edge_endpoint/plan_cloud_G08_1.log @@ -0,0 +1,137 @@ + + +# Plan - REVIEW_EDGE_CONN Follow-up + +## 이 파일을 읽는 구현 에이전트에게 + +이 plan은 `code_review_cloud_G08_0.log`의 FAIL 판정을 해소하는 좁은 후속 작업이다. 구현의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 검증 출력으로 채우는 것이다. 사용자만 결정할 수 있는 범위 충돌, 외부 환경/secret 준비, 제품 결정이 필요하면 review stub의 `사용자 리뷰 요청`을 근거와 함께 채우고 멈춘다. 단순 증거 공백은 후속 에이전트가 재실행할 수 있으므로 사용자 리뷰 요청으로 올리지 않는다. + +## 배경 + +첫 번째 리뷰에서 Control Plane Edge TCP endpoint 구현은 기본 동작 테스트를 통과했지만, registry reconnect correctness, container/compose 운영 표면, proto 변경 증거 신뢰성에서 Required 문제가 발견되었다. 이 후속 작업은 기존 subtask 범위 안에서 해당 문제만 수정한다. + +## 구현 체크리스트 + +- [ ] [REVIEW_EDGE_CONN-1] Edge registry disconnect를 connection generation으로 보호하고 stale disconnect 테스트를 추가한다. +- [ ] [REVIEW_EDGE_CONN-2] Edge TCP wire endpoint를 Docker/compose/docs 운영 표면에 노출한다. +- [ ] [REVIEW_EDGE_CONN-3] proto 변경 범위와 생성물 검증 증거를 실제 diff와 일치하게 기록한다. +- [ ] 중간/최종 검증 명령을 실행하고 출력이 기대와 일치하는지 확인한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REVIEW_EDGE_CONN-1] Registry generation guard + +#### 문제 + +`EdgeRegistry`가 `edge_id`만으로 현재 연결 상태를 저장하고 `MarkDisconnected(edgeID, ...)`도 같은 키만 사용한다. 같은 Edge가 재연결하거나 중복 연결된 뒤 오래된 connection의 disconnect listener가 늦게 실행되면 새 연결 상태를 disconnected로 덮을 수 있다. + +#### 해결 방법 + +connection별 generation/token을 도입한다. hello 처리 시 해당 connection token으로 `MarkConnected`를 기록하고, disconnect 시 같은 token이 현재 state의 token과 일치할 때만 `Connected=false`로 바꾼다. token은 외부 API 계약이 아니라 internal registry 구현 세부로 둔다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/control-plane/internal/wire/edge_registry.go`: state에 internal connection token을 저장하고 stale disconnect를 무시한다. +- [ ] `apps/control-plane/internal/wire/edge_server.go`: connection별 token을 생성해 registry connect/disconnect에 전달한다. +- [ ] `apps/control-plane/internal/wire/edge_server_test.go`: 같은 `edge_id`로 새 hello가 들어온 뒤 오래된 disconnect가 새 연결을 disconnected로 만들지 않는 테스트를 추가한다. + +#### 테스트 작성 + +테스트 작성. `TestEdgeServerStaleDisconnectDoesNotClearReconnect` 또는 동등한 이름으로 같은 `edge_id`의 두 connection을 열고, 첫 connection close 이후 registry가 두 번째 connection의 connected state를 유지하는지 확인한다. + +#### 중간 검증 + +```bash +go test -race -count=1 ./apps/control-plane/internal/wire +``` + +기대: wire tests pass and race detector reports no race. + +### [REVIEW_EDGE_CONN-2] Container and operator surface + +#### 문제 + +`run()`은 `server.edge_wire_listen` 기본값 `0.0.0.0:19081`을 시작하지만 `apps/control-plane/Dockerfile`은 19081을 expose하지 않고 `docker-compose.yml`도 19081을 publish하지 않는다. compose/container 실행에서 외부 Edge가 새 endpoint에 연결할 수 없다. `apps/control-plane/README.md`도 현재 실행 표면에 `edge_wire_listen`을 설명하지 않는다. + +#### 해결 방법 + +Control Plane Docker image와 compose dev service에 Edge wire port를 노출한다. compose에는 별도 host port env 이름을 사용해 Client WS port와 Edge TCP port가 섞이지 않게 한다. README의 현재 실행 표면과 local/dev override 설명도 새 config/env 이름과 일치시킨다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/control-plane/Dockerfile`: `EXPOSE 9080 19080 19081`로 갱신한다. +- [ ] `docker-compose.yml`: `${IOP_CONTROL_PLANE_EDGE_WIRE_PORT:-19081}:19081` port mapping을 추가한다. +- [ ] `apps/control-plane/README.md`: `server.wire_listen`은 Client WS, `server.edge_wire_listen`은 Edge TCP로 분리해 설명하고 env override 이름을 반영한다. + +#### 테스트 작성 + +신규 Go 테스트는 필요하지 않다. compose syntax와 정적 표면을 검증한다. + +#### 중간 검증 + +```bash +docker compose config --quiet +rg --line-number "19081|IOP_CONTROL_PLANE_EDGE_WIRE_PORT|edge_wire_listen" apps/control-plane/Dockerfile docker-compose.yml apps/control-plane/README.md configs/control-plane.yaml +``` + +기대: compose config exits 0, and every operator-facing file shows the Edge TCP endpoint. + +### [REVIEW_EDGE_CONN-3] Proto scope and evidence trust + +#### 문제 + +현재 archived review evidence는 "proto 변경 없음"이라고 기록하지만 실제 diff는 `proto/iop/control.proto`, `proto/gen/iop/control.pb.go`, `apps/client/lib/gen/proto/iop/control.pb.dart`, `apps/client/lib/gen/proto/iop/control.pbjson.dart`를 변경한다. follow-up review evidence가 실제 변경 범위와 생성물 검증을 정확히 기록해야 한다. + +#### 해결 방법 + +소스 변경이 추가로 필요 없다면 proto 파일은 그대로 두되, `CODE_REVIEW-*-G??.md`의 `계획 대비 변경 사항`, `주요 설계 결정`, `검증 결과`에 proto 원본과 Go/Dart 생성물 변경을 정확히 기록한다. 생성물은 workspace를 직접 덮어쓰지 않는 임시 디렉터리 방식으로 원본 proto와 일치하는지 비교한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `CODE_REVIEW-cloud-G08.md`: proto 원본/생성물 변경과 검증 결과를 실제 diff와 일치하게 기록한다. + +#### 테스트 작성 + +신규 테스트는 필요하지 않다. 생성물 일치 검증을 수행한다. + +#### 중간 검증 + +```bash +tmpdir=$(mktemp -d) +protoc --go_out="$tmpdir" --go_opt=module=iop --proto_path=. proto/iop/runtime.proto proto/iop/node.proto proto/iop/control.proto proto/iop/job.proto +cmp -s proto/gen/iop/control.pb.go "$tmpdir/proto/gen/iop/control.pb.go" +rm -rf "$tmpdir" + +tmpdir=$(mktemp -d) +mkdir -p "$tmpdir/apps/client/lib/gen" +protoc --plugin=protoc-gen-dart=/config/.local/bin/protoc-gen-dart --dart_out="$tmpdir/apps/client/lib/gen" --proto_path=. --proto_path=/config/.local/include proto/iop/runtime.proto proto/iop/node.proto proto/iop/control.proto proto/iop/job.proto +cmp -s apps/client/lib/gen/proto/iop/control.pb.dart "$tmpdir/apps/client/lib/gen/proto/iop/control.pb.dart" +cmp -s apps/client/lib/gen/proto/iop/control.pbjson.dart "$tmpdir/apps/client/lib/gen/proto/iop/control.pbjson.dart" +rm -rf "$tmpdir" +``` + +기대: all `cmp` commands exit 0. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/control-plane/internal/wire/edge_registry.go` | REVIEW_EDGE_CONN-1 | +| `apps/control-plane/internal/wire/edge_server.go` | REVIEW_EDGE_CONN-1 | +| `apps/control-plane/internal/wire/edge_server_test.go` | REVIEW_EDGE_CONN-1 | +| `apps/control-plane/Dockerfile` | REVIEW_EDGE_CONN-2 | +| `docker-compose.yml` | REVIEW_EDGE_CONN-2 | +| `apps/control-plane/README.md` | REVIEW_EDGE_CONN-2 | +| `CODE_REVIEW-cloud-G08.md` | REVIEW_EDGE_CONN-3 | + +## 최종 검증 + +```bash +gofmt -l apps/control-plane/internal/wire/edge_registry.go apps/control-plane/internal/wire/edge_server.go apps/control-plane/internal/wire/edge_server_test.go +go test -count=1 ./apps/control-plane/... +go test -race -count=1 ./apps/control-plane/internal/wire +go test -count=1 ./packages/... ./proto/gen/... +``` + +기대: `gofmt -l` prints no files and all tests pass. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-control-plane-edge-wire-baseline/02+01_edge_connector/CODE_REVIEW-cloud-G08.md b/agent-task/m-control-plane-edge-wire-baseline/02+01_edge_connector/CODE_REVIEW-cloud-G08.md new file mode 100644 index 0000000..1dd4e8d --- /dev/null +++ b/agent-task/m-control-plane-edge-wire-baseline/02+01_edge_connector/CODE_REVIEW-cloud-G08.md @@ -0,0 +1,131 @@ + + +# Code Review Reference - EDGE_CONN + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-05-30 +task=m-control-plane-edge-wire-baseline/02+01_edge_connector, plan=0, tag=EDGE_CONN + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G08.md` → `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` → `plan_cloud_G08_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-control-plane-edge-wire-baseline/02+01_edge_connector/`로 이동한다. +4. PASS이고 task group이 `m-control-plane-edge-wire-baseline`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [EDGE_CONN-1] Edge connector config | [ ] | +| [EDGE_CONN-2] Edge outbound connector | [ ] | +| [EDGE_CONN-3] Runtime lifecycle wiring | [ ] | + +## 구현 체크리스트 + +- [ ] [EDGE_CONN-1] Edge config와 hostsetup/config templates에 Control Plane connector 설정을 추가하고 loader/default/tests를 맞춘다. +- [ ] [EDGE_CONN-2] Edge outbound connector를 구현해 hello/enrollment, heartbeat 기반 생존 상태, reconnect/disconnect 상태를 처리한다. +- [ ] [EDGE_CONN-3] `bootstrap.Runtime`에 connector lifecycle을 연결하고 start/stop rollback을 테스트한다. +- [ ] 중간/최종 검증 명령을 실행하고 출력이 기대와 일치하는지 확인한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-control-plane-edge-wire-baseline/02+01_edge_connector/`를 `agent-task/archive/YYYY/MM/m-control-plane-edge-wire-baseline/02+01_edge_connector/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-edge-wire-baseline/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `control_plane.enabled=false`와 empty `wire_addr`가 기존 Edge startup을 깨지 않는지 확인한다. +- Edge connector가 `edge_id`, `edge_name`, version, capabilities를 hello에 담는지 확인한다. +- Reconnect loop가 Stop 후 재시도하지 않는지 확인한다. +- Runtime start/stop error handling이 기존 server/input/artifact lifecycle을 누수시키지 않는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### EDGE_CONN-1 중간 검증 +``` +$ go test -count=1 ./packages/config ./packages/hostsetup +(output) +``` + +### EDGE_CONN-2 중간 검증 +``` +$ go test -count=1 ./apps/edge/internal/controlplane +(output) +``` + +### EDGE_CONN-3 중간 검증 +``` +$ go test -count=1 ./apps/edge/internal/bootstrap +(output) +``` + +### 최종 검증 +``` +$ go test -count=1 ./packages/config ./packages/hostsetup ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap +(output) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. diff --git a/agent-task/m-control-plane-edge-wire-baseline/02+01_edge_connector/PLAN-cloud-G08.md b/agent-task/m-control-plane-edge-wire-baseline/02+01_edge_connector/PLAN-cloud-G08.md new file mode 100644 index 0000000..0ecce94 --- /dev/null +++ b/agent-task/m-control-plane-edge-wire-baseline/02+01_edge_connector/PLAN-cloud-G08.md @@ -0,0 +1,244 @@ + + +# Plan - EDGE_CONN Edge Connector + +## 이 파일을 읽는 구현 에이전트에게 + +구현의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 실제 stdout/stderr를 붙이고, active 파일을 유지한 채 리뷰 준비를 보고한다. 사용자만 결정할 수 있는 범위 충돌, 외부 환경/secret 준비, 제품 결정이 필요하면 review stub의 `사용자 리뷰 요청`을 근거와 함께 채우고 멈춘다. 단순 증거 공백은 후속 에이전트가 재실행할 수 있으므로 사용자 리뷰 요청으로 올리지 않는다. + +## 배경 + +Control Plane endpoint가 준비되면 Edge는 설정된 endpoint로 outbound 연결하고 hello/enrollment를 보내야 한다. 이 작업은 Edge config 계약, outbound proto-socket client, runtime lifecycle을 함께 바꾼다. Node bootstrap/local fallback은 유지하고 Control Plane이 Node와 직접 연결되는 경로는 만들지 않는다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사유가 생기면 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션을 사용한다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/control-plane-portal-ops/PHASE.md` +- `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/project/domain/platform-common/rules.md` +- `agent-test/local/edge-smoke.md` +- `agent-test/local/platform-common-smoke.md` +- `packages/config/config.go` +- `packages/config/config_test.go` +- `packages/hostsetup/templates.go` +- `packages/hostsetup/setup.go` +- `packages/hostsetup/setup_test.go` +- `configs/edge.yaml` +- `apps/edge/internal/bootstrap/runtime.go` +- `apps/edge/internal/bootstrap/runtime_test.go` +- `apps/edge/internal/bootstrap/module.go` +- `apps/edge/internal/transport/server.go` +- `apps/edge/internal/transport/integration_test.go` +- `apps/edge/internal/transport/heartbeat_test.go` +- `apps/node/internal/transport/client.go` +- `apps/node/internal/transport/session.go` +- `go.mod` +- `../proto-socket/go/tcp_client.go` +- `../proto-socket/go/base_client.go` + +### 테스트 커버리지 공백 + +- `packages/config`에는 Edge identity/default/openai/a2a tests가 있으나 Control Plane connector config tests가 없다. +- `packages/hostsetup` template tests check bootstrap/log fields but not Control Plane fields. +- Edge runtime tests cover node store, input manager, listener lifetime, logs, duplicate tokens; no Control Plane connector lifecycle wiring exists. +- No Edge outbound connector package exists, so hello, reconnect, heartbeat/disconnect tests are absent. + +### 심볼 참조 + +- 제거/rename할 심볼 없음. +- `config.EdgeConfig` call sites include `packages/config/config.go:13`, `apps/edge/internal/bootstrap/runtime.go:25`, `apps/edge/internal/input/manager.go`, `apps/edge/cmd/edge/main.go`, tests under `apps/edge/**` and `packages/config/config_test.go`. +- `bootstrap.Runtime` lifecycle call sites: `apps/edge/internal/bootstrap/module.go:15`, `apps/edge/internal/opsconsole/console.go:31`, `apps/edge/internal/bootstrap/runtime_test.go`. + +### 분할 판단 + +Split policy를 먼저 평가했다. 이 subtask는 `agent-task/m-control-plane-edge-wire-baseline/02+01_edge_connector`이며 `01_cp_edge_endpoint`의 `complete.log`가 선행되어야 한다. Config/API foundation과 Edge runtime rollout은 같은 Edge connector ownership 안에 있으므로 이 subtask 안에서 다루되, process-level smoke는 `03+01,02_local_smoke`로 분리한다. + +### 범위 결정 근거 + +이 subtask는 Edge가 Control Plane에 outbound로 붙고, proto-socket heartbeat를 사용하며, disconnect/reconnect를 자체 상태로 갱신하는 것까지 한다. Control Plane public registry API, status snapshot request/response, node lifecycle event relay, Flutter client UI는 제외한다. Edge local node transport, OpenAI/A2A input path, node bootstrap UX는 변경하지 않는다. + +### 빌드 등급 + +build=`cloud-G08`, review=`cloud-G08`. 이유: Edge runtime lifecycle, reconnect loop, shared config/template changes, proto-socket heartbeat behavior가 함께 걸린다. + +## 의존 관계 및 구현 순서 + +이 디렉터리명은 `02+01_edge_connector`다. 같은 task group의 `01_cp_edge_endpoint`가 `complete.log`를 만든 뒤 시작한다. + +## 구현 체크리스트 + +- [ ] [EDGE_CONN-1] Edge config와 hostsetup/config templates에 Control Plane connector 설정을 추가하고 loader/default/tests를 맞춘다. +- [ ] [EDGE_CONN-2] Edge outbound connector를 구현해 hello/enrollment, heartbeat 기반 생존 상태, reconnect/disconnect 상태를 처리한다. +- [ ] [EDGE_CONN-3] `bootstrap.Runtime`에 connector lifecycle을 연결하고 start/stop rollback을 테스트한다. +- [ ] 중간/최종 검증 명령을 실행하고 출력이 기대와 일치하는지 확인한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [EDGE_CONN-1] Edge connector config + +#### 문제 + +[packages/config/config.go](/config/workspace/iop/packages/config/config.go:13)의 `EdgeConfig`에는 Control Plane endpoint가 없다. [packages/hostsetup/templates.go](/config/workspace/iop/packages/hostsetup/templates.go:8)와 [configs/edge.yaml](/config/workspace/iop/configs/edge.yaml:1)도 connector 설정을 만들지 않는다. + +Before: + +```go +// packages/config/config.go:13 +type EdgeConfig struct { + Edge EdgeInfo `mapstructure:"edge" yaml:"edge"` + Server EdgeServerConf `mapstructure:"server" yaml:"server"` + Bootstrap EdgeBootstrapConf `mapstructure:"bootstrap" yaml:"bootstrap"` +``` + +#### 해결 방법 + +`EdgeControlPlaneConf`를 추가하고 `EdgeConfig`에 `ControlPlane EdgeControlPlaneConf mapstructure:"control_plane" yaml:"control_plane"`를 둔다. 필드는 다음 기준으로 시작한다. + +```go +type EdgeControlPlaneConf struct { + Enabled bool `mapstructure:"enabled" yaml:"enabled"` + WireAddr string `mapstructure:"wire_addr" yaml:"wire_addr"` + ReconnectIntervalSec int `mapstructure:"reconnect_interval_sec" yaml:"reconnect_interval_sec"` +} +``` + +기본값은 disabled, empty addr, reconnect 5초다. `configs/edge.yaml`, `EdgeSpec().ConfigTemplate`, `EdgeBundleConfigTemplate()`에 `control_plane` section을 추가한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `packages/config/config.go`: config struct/default 추가. +- [ ] `packages/config/config_test.go`: default/override tests 추가. +- [ ] `configs/edge.yaml`: tracked example section 추가. +- [ ] `packages/hostsetup/templates.go`: service/bundle template section 추가. +- [ ] `packages/hostsetup/setup_test.go`: template contains control plane fields assertion 추가. + +#### 테스트 작성 + +테스트 작성. `TestLoadEdge_ControlPlaneDefaults`, `TestLoadEdge_ControlPlaneOverride`, hostsetup template tests에 `control_plane`, `wire_addr`, `reconnect_interval_sec` assertions를 추가한다. + +#### 중간 검증 + +```bash +go test -count=1 ./packages/config ./packages/hostsetup +``` + +기대: config and hostsetup tests pass. + +### [EDGE_CONN-2] Edge outbound connector + +#### 문제 + +현재 Edge runtime은 Node listener/input/artifact만 소유한다. [apps/edge/internal/bootstrap/runtime.go](/config/workspace/iop/apps/edge/internal/bootstrap/runtime.go:24)에 Control Plane connector field가 없고, outbound hello/reconnect state를 담당하는 package도 없다. + +Before: + +```go +// apps/edge/internal/bootstrap/runtime.go:24 +type Runtime struct { + Cfg *config.EdgeConfig + Logger *zap.Logger + Registry *edgenode.Registry +``` + +#### 해결 방법 + +`apps/edge/internal/controlplane/connector.go`를 추가한다. + +- `Connector`는 `config.EdgeInfo`, `config.EdgeControlPlaneConf`, `version.Version`, logger를 받아 생성한다. +- `Start(ctx)`는 enabled=false 또는 empty `wire_addr`면 no-op한다. +- enabled이면 reconnect loop를 시작하고 `proto_socket.DialTcp`로 연결한다. +- 연결 직후 `EdgeHelloRequest{EdgeId, EdgeName, Version, Capabilities}`를 보내고 accepted response를 확인한다. +- proto-socket heartbeat는 `DialTcp` interval/wait로 켠다. interval/wait는 edge-node와 같은 30/45초 기준을 우선한다. +- disconnect listener는 connector state를 disconnected로 바꾸고 reconnect interval 후 다시 시도한다. +- `Stop()`은 loop context cancel과 active client close를 수행한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/edge/internal/controlplane/connector.go`: connector, state, reconnect loop 구현. +- [ ] `apps/edge/internal/controlplane/connector_test.go`: fake proto-socket TCP server 기반 hello/reconnect/stop tests 추가. +- [ ] `apps/edge/internal/controlplane/heartbeat_test.go`: wait > interval invariant test 추가. + +#### 테스트 작성 + +테스트 작성. `TestConnectorSendsEdgeHello`, `TestConnectorRejectsRejectedHello`, `TestConnectorReconnectsAfterDisconnect`, `TestConnectorStopClosesConnection`, `TestHeartbeatWaitExceedsInterval`를 추가한다. Fake Control Plane server는 `proto_socket.NewTcpServer`와 `iop.EdgeHelloRequest/Response`를 사용한다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/edge/internal/controlplane +``` + +기대: connector unit tests pass without external service. + +### [EDGE_CONN-3] Runtime lifecycle wiring + +#### 문제 + +[apps/edge/internal/bootstrap/runtime.go](/config/workspace/iop/apps/edge/internal/bootstrap/runtime.go:79)는 Edge node server, input manager, artifact server만 start한다. Connector가 추가되어도 runtime lifecycle에 연결되지 않으면 `iop-edge serve`가 Control Plane에 붙지 않는다. + +Before: + +```go +// apps/edge/internal/bootstrap/runtime.go:79 +func (r *Runtime) Start(ctx context.Context) error { + if err := ctx.Err(); err != nil { + return err + } + r.wireHandlers() + lifetimeCtx := r.newLifetimeContext() + if err := r.Server.Start(lifetimeCtx); err != nil { +``` + +#### 해결 방법 + +`Runtime`에 `ControlPlane *controlplane.Connector`를 추가하고 `NewRuntime`에서 생성한다. `Start()`는 node server/input/artifact 성공 후 connector를 start한다. connector start가 synchronous dial failure를 반환하지 않는 설계라면 no-op/reconnect loop start만 검증한다. `Stop()`은 connector stop을 input/artifact/server stop과 함께 error join 방식 또는 기존 precedence에 맞춰 반영한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/edge/internal/bootstrap/runtime.go`: field/create/start/stop 연결. +- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: disabled no-op, enabled connector presence, lifecycle start/stop tests 추가. +- [ ] `apps/edge/internal/bootstrap/module.go`: 변경 필요 시 import/lifecycle 영향 확인. + +#### 테스트 작성 + +테스트 작성. `TestNewRuntimeWiresControlPlaneConnector`, `TestRuntimeStartsControlPlaneConnectorWhenEnabled`, `TestRuntimeStopsControlPlaneConnector`를 추가한다. 외부 Control Plane은 fake connector server 또는 connector package의 test helper를 직접 import하지 않고, local TCP fake endpoint를 사용한다. + +#### 중간 검증 + +```bash +go test -count=1 ./apps/edge/internal/bootstrap +``` + +기대: runtime lifecycle tests pass. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `packages/config/config.go` | EDGE_CONN-1 | +| `packages/config/config_test.go` | EDGE_CONN-1 | +| `configs/edge.yaml` | EDGE_CONN-1 | +| `packages/hostsetup/templates.go` | EDGE_CONN-1 | +| `packages/hostsetup/setup_test.go` | EDGE_CONN-1 | +| `apps/edge/internal/controlplane/connector.go` | EDGE_CONN-2 | +| `apps/edge/internal/controlplane/connector_test.go` | EDGE_CONN-2 | +| `apps/edge/internal/controlplane/heartbeat_test.go` | EDGE_CONN-2 | +| `apps/edge/internal/bootstrap/runtime.go` | EDGE_CONN-3 | +| `apps/edge/internal/bootstrap/runtime_test.go` | EDGE_CONN-3 | +| `apps/edge/internal/bootstrap/module.go` | EDGE_CONN-3 | + +## 최종 검증 + +```bash +go test -count=1 ./packages/config ./packages/hostsetup ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap +``` + +기대: Edge connector config, connector, runtime lifecycle tests pass with fresh execution. Go test cache output is not acceptable for this plan; use `-count=1`. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/CODE_REVIEW-cloud-G08.md b/agent-task/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/CODE_REVIEW-cloud-G08.md new file mode 100644 index 0000000..5a3e4ab --- /dev/null +++ b/agent-task/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/CODE_REVIEW-cloud-G08.md @@ -0,0 +1,124 @@ + + +# Code Review Reference - EDGE_CONN + +> **[IMPLEMENTING AGENT — READ FIRST] Filling in this file is the mandatory final step of implementation.** +> The task is NOT complete until every implementation-owned section below is filled in. +> Complete the `구현 체크리스트`; the final checklist item is mandatory before saving. +> Fill implementation-owned sections, then stop with active files in place and report ready for review. +> If implementation is blocked by a user-only decision, user-owned external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`. Evidence gaps that a follow-up agent can close by rerunning commands or collecting artifacts are normal follow-up issues, not user-review blockers by themselves. +> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume. +> Follow the ownership table at the bottom of this file for which sections you own. + +## 개요 + +date=2026-05-30 +task=m-control-plane-edge-wire-baseline/03+01,02_local_smoke, plan=0, tag=EDGE_CONN + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G08.md` → `code_review_cloud_G08_N.log`, `PLAN-cloud-G08.md` → `plan_cloud_G08_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/`로 이동한다. +4. PASS이고 task group이 `m-control-plane-edge-wire-baseline`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [EDGE_CONN-1] Local process smoke script | [ ] | +| [EDGE_CONN-2] Makefile target | [ ] | + +## 구현 체크리스트 + +- [ ] [EDGE_CONN-1] Control Plane-Edge wire local smoke script를 추가해 실제 CP/Edge process hello 성공과 disconnect를 검증한다. +- [ ] [EDGE_CONN-2] Makefile에 smoke target을 추가하고 `test-e2e` 흐름에 포함할지 명시적으로 반영한다. +- [ ] 중간/최종 검증 명령을 실행하고 출력이 기대와 일치하는지 확인한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G08_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G08_M.log`로 아카이브한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/`를 `agent-task/archive/YYYY/MM/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-control-plane-edge-wire-baseline/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.md`를 작성하고 `complete.log`를 작성하지 않는다. +- [ ] USER_REVIEW이면 `agent-ops/skills/common/code-review/templates/user-review-template.md` 기준으로 `USER_REVIEW.md`를 작성하고 active `PLAN-*.md`, `CODE_REVIEW-*.md`, `complete.log`를 남기지 않는다. +- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다. + +## 계획 대비 변경 사항 + +_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._ + +## 주요 설계 결정 + +_구현 에이전트가 주요 설계 결정 사항을 기록한다._ + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- Script가 temp config만 쓰고 tracked config나 secret을 수정하지 않는지 확인한다. +- Background CP/Edge processes가 success/failure/trap 경로에서 cleanup되는지 확인한다. +- Smoke가 hello accepted와 disconnect evidence를 실제 log marker로 확인하는지 확인한다. +- `make test-control-plane-edge-wire` 출력이 붙었는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### EDGE_CONN-1 중간 검증 +``` +$ bash -n scripts/e2e-control-plane-edge-wire.sh +(output) +``` + +### EDGE_CONN-2 중간 검증 +``` +$ make test-control-plane-edge-wire +(output) +``` + +### 최종 검증 +``` +$ go test -count=1 ./apps/control-plane/... ./apps/edge/... ./packages/config ./packages/hostsetup +$ make test-control-plane-edge-wire +(output) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. diff --git a/agent-task/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/PLAN-cloud-G08.md b/agent-task/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/PLAN-cloud-G08.md new file mode 100644 index 0000000..1eead69 --- /dev/null +++ b/agent-task/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/PLAN-cloud-G08.md @@ -0,0 +1,172 @@ + + +# Plan - EDGE_CONN Local Smoke + +## 이 파일을 읽는 구현 에이전트에게 + +구현의 마지막 단계는 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 실제 내용과 검증 출력으로 채우는 것이다. 검증을 실행하고, 실제 stdout/stderr를 붙이고, active 파일을 유지한 채 리뷰 준비를 보고한다. 사용자만 결정할 수 있는 범위 충돌, 외부 환경/secret 준비, 제품 결정이 필요하면 review stub의 `사용자 리뷰 요청`을 근거와 함께 채우고 멈춘다. 단순 증거 공백은 후속 에이전트가 재실행할 수 있으므로 사용자 리뷰 요청으로 올리지 않는다. + +## 배경 + +두 번째 Epic의 완료 검증은 “로컬 Control Plane과 Edge를 함께 띄웠을 때 hello가 성공한다”이다. Control Plane과 Edge 내부 패키지는 서로 다른 `internal` 경계라 단일 Go integration test로 묶기 어렵다. 그래서 실제 binaries/process를 띄우는 local smoke를 추가해 사용자가 기대한 운영 경로를 검증한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사유가 생기면 active `CODE_REVIEW-*-G??.md`의 `사용자 리뷰 요청` 섹션을 사용한다. code-review가 요청 타당성을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정한다. + +## 분석 결과 + +### 읽은 파일 + +- `agent-roadmap/current.md` +- `agent-roadmap/phase/control-plane-portal-ops/PHASE.md` +- `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-test/local/testing-smoke.md` +- `agent-test/local/control-plane-smoke.md` +- `agent-test/local/edge-smoke.md` +- `Makefile` +- `scripts/e2e-smoke.sh` +- `scripts/dev/edge.sh` +- `scripts/dev/node.sh` +- `apps/control-plane/cmd/control-plane/main.go` +- `apps/edge/cmd/edge/main.go` +- `configs/control-plane.yaml` +- `configs/edge.yaml` + +### 테스트 커버리지 공백 + +- Existing `scripts/e2e-smoke.sh` validates Edge-Node runtime, not Control Plane-Edge wire hello. +- `Makefile test-e2e` does not run any Control Plane-Edge process smoke. +- Control Plane and Edge package tests can pass while config/process integration is broken. + +### 심볼 참조 + +- 제거/rename할 심볼 없음. +- `test-e2e` target: `Makefile:75`. +- Existing smoke script pattern: `scripts/e2e-smoke.sh:1`. + +### 분할 판단 + +Split policy를 먼저 평가했다. 이 subtask는 `agent-task/m-control-plane-edge-wire-baseline/03+01,02_local_smoke`이며 `01_cp_edge_endpoint`와 `02+01_edge_connector`의 `complete.log`가 모두 선행되어야 한다. Process orchestration, shell error handling, log evidence는 endpoint/connector implementation과 다른 risk profile이라 별도 plan으로 분리했다. + +### 범위 결정 근거 + +이 subtask는 local smoke script와 Makefile target만 추가한다. 기존 Edge-Node `scripts/e2e-smoke.sh`, OpenAI/Ollama smoke, Node bootstrap UX, Docker compose healthcheck는 바꾸지 않는다. Smoke는 secret이나 private endpoint를 tracked 파일에 쓰지 않는다. + +### 빌드 등급 + +build=`cloud-G08`, review=`cloud-G08`. 이유: shell process orchestration, port readiness, long-running process cleanup, log-based acceptance가 포함된다. + +## 의존 관계 및 구현 순서 + +이 디렉터리명은 `03+01,02_local_smoke`다. 같은 task group의 `01_cp_edge_endpoint`와 `02+01_edge_connector`가 모두 `complete.log`를 만든 뒤 시작한다. + +## 구현 체크리스트 + +- [ ] [EDGE_CONN-1] Control Plane-Edge wire local smoke script를 추가해 실제 CP/Edge process hello 성공과 disconnect를 검증한다. +- [ ] [EDGE_CONN-2] Makefile에 smoke target을 추가하고 `test-e2e` 흐름에 포함할지 명시적으로 반영한다. +- [ ] 중간/최종 검증 명령을 실행하고 출력이 기대와 일치하는지 확인한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [EDGE_CONN-1] Local process smoke script + +#### 문제 + +[scripts/e2e-smoke.sh](/config/workspace/iop/scripts/e2e-smoke.sh:1)는 Edge-Node smoke다. Control Plane-Edge hello success를 검증하는 script가 없다. + +Before: + +```bash +# scripts/e2e-smoke.sh:1 +#!/usr/bin/env bash +set -euo pipefail +``` + +#### 해결 방법 + +`scripts/e2e-control-plane-edge-wire.sh`를 추가한다. + +- `mktemp -d` 아래 `control-plane.yaml`, `edge.yaml`, log files를 생성한다. +- Random free ports를 배정한다. Control Plane HTTP, Client WS, Edge TCP wire, Edge node transport, Edge bootstrap, Edge metrics가 충돌하지 않아야 한다. +- `go run ./apps/control-plane/cmd/control-plane serve --config "$CP_CONFIG"`를 background로 시작한다. +- Edge wire TCP port가 열릴 때까지 wait한다. +- `go run ./apps/edge/cmd/edge serve --config "$EDGE_CONFIG"`를 background로 시작한다. +- CP/Edge logs에서 hello accepted marker와 connector connected marker를 기다린다. +- Edge process를 종료하고 CP log에서 disconnected marker를 기다린다. +- failure 시 두 로그를 출력하고 non-zero로 종료한다. +- trap에서 background process cleanup을 보장한다. + +#### 수정 파일 및 체크리스트 + +- [ ] `scripts/e2e-control-plane-edge-wire.sh`: strict shell script 추가. +- [ ] Script log marker는 구현된 CP/Edge logger message와 정확히 맞춘다. +- [ ] Script는 tracked config를 수정하지 않고 temp config만 사용한다. + +#### 테스트 작성 + +Shell smoke 자체가 테스트다. `bash -n scripts/e2e-control-plane-edge-wire.sh`와 실제 script 실행을 검증한다. Shellcheck는 있으면 실행하고 없으면 기존 smoke처럼 skip message를 남긴다. + +#### 중간 검증 + +```bash +bash -n scripts/e2e-control-plane-edge-wire.sh +``` + +기대: syntax check passes. + +### [EDGE_CONN-2] Makefile target + +#### 문제 + +[Makefile](/config/workspace/iop/Makefile:75)의 `test-e2e`는 기존 auxiliary smoke와 OpenAI/Ollama smoke만 실행한다. Control Plane-Edge wire smoke를 호출할 target이 없다. + +Before: + +```make +# Makefile:75 +test-e2e: + @echo "NOTE: test-e2e runs auxiliary smoke plus fake Ollama OpenAI serving; completion still requires user-flow verification when changing runtime paths." + ./scripts/e2e-smoke.sh + ./scripts/e2e-openai-ollama.sh +``` + +#### 해결 방법 + +`test-control-plane-edge-wire` phony target을 추가한다. `test-e2e`에 포함하되, 출력 note를 Control Plane-Edge wire smoke 포함으로 갱신한다. If runtime cost or flakiness is discovered during implementation, do not silently exclude it; record the reason in `계획 대비 변경 사항`. + +#### 수정 파일 및 체크리스트 + +- [ ] `Makefile`: `.PHONY`에 target 추가. +- [ ] `Makefile`: `test-control-plane-edge-wire` target 추가. +- [ ] `Makefile`: `test-e2e`에서 새 script 호출 추가. + +#### 테스트 작성 + +별도 unit test 없음. Make target 실행이 검증이다. + +#### 중간 검증 + +```bash +make test-control-plane-edge-wire +``` + +기대: Control Plane starts, Edge connects, hello succeeds, Edge disconnect updates CP state, script exits 0. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `scripts/e2e-control-plane-edge-wire.sh` | EDGE_CONN-1 | +| `Makefile` | EDGE_CONN-2 | + +## 최종 검증 + +```bash +go test -count=1 ./apps/control-plane/... ./apps/edge/... ./packages/config ./packages/hostsetup +make test-control-plane-edge-wire +``` + +기대: Go tests pass with fresh execution and the local Control Plane-Edge wire smoke exits 0. Go test cache output is not acceptable for this plan; use `-count=1`. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/client/lib/gen/proto/iop/control.pb.dart b/apps/client/lib/gen/proto/iop/control.pb.dart index 3b01ef1..f7efe48 100644 --- a/apps/client/lib/gen/proto/iop/control.pb.dart +++ b/apps/client/lib/gen/proto/iop/control.pb.dart @@ -415,6 +415,209 @@ class ClientHelloResponse extends $pb.GeneratedMessage { void clearMessage() => $_clearField(4); } +/// EdgeHelloRequest is sent by Edge to Control Plane immediately after a +/// Control Plane-Edge wire connection is established. It describes the Edge +/// itself and must not become a direct Node registration or scheduling contract. +class EdgeHelloRequest extends $pb.GeneratedMessage { + factory EdgeHelloRequest({ + $core.String? edgeId, + $core.String? edgeName, + $core.String? version, + $core.Iterable<$core.String>? capabilities, + $core.Iterable<$core.MapEntry<$core.String, $core.String>>? metadata, + }) { + final result = create(); + if (edgeId != null) result.edgeId = edgeId; + if (edgeName != null) result.edgeName = edgeName; + if (version != null) result.version = version; + if (capabilities != null) result.capabilities.addAll(capabilities); + if (metadata != null) result.metadata.addEntries(metadata); + return result; + } + + EdgeHelloRequest._(); + + factory EdgeHelloRequest.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory EdgeHelloRequest.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'EdgeHelloRequest', + package: const $pb.PackageName(_omitMessageNames ? '' : 'iop'), + createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'edgeId') + ..aOS(2, _omitFieldNames ? '' : 'edgeName') + ..aOS(3, _omitFieldNames ? '' : 'version') + ..pPS(4, _omitFieldNames ? '' : 'capabilities') + ..m<$core.String, $core.String>(5, _omitFieldNames ? '' : 'metadata', + entryClassName: 'EdgeHelloRequest.MetadataEntry', + keyFieldType: $pb.PbFieldType.OS, + valueFieldType: $pb.PbFieldType.OS, + packageName: const $pb.PackageName('iop')) + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeHelloRequest clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeHelloRequest copyWith(void Function(EdgeHelloRequest) updates) => + super.copyWith((message) => updates(message as EdgeHelloRequest)) + as EdgeHelloRequest; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static EdgeHelloRequest create() => EdgeHelloRequest._(); + @$core.override + EdgeHelloRequest createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static EdgeHelloRequest getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static EdgeHelloRequest? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get edgeId => $_getSZ(0); + @$pb.TagNumber(1) + set edgeId($core.String value) => $_setString(0, value); + @$pb.TagNumber(1) + $core.bool hasEdgeId() => $_has(0); + @$pb.TagNumber(1) + void clearEdgeId() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get edgeName => $_getSZ(1); + @$pb.TagNumber(2) + set edgeName($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasEdgeName() => $_has(1); + @$pb.TagNumber(2) + void clearEdgeName() => $_clearField(2); + + @$pb.TagNumber(3) + $core.String get version => $_getSZ(2); + @$pb.TagNumber(3) + set version($core.String value) => $_setString(2, value); + @$pb.TagNumber(3) + $core.bool hasVersion() => $_has(2); + @$pb.TagNumber(3) + void clearVersion() => $_clearField(3); + + @$pb.TagNumber(4) + $pb.PbList<$core.String> get capabilities => $_getList(3); + + @$pb.TagNumber(5) + $pb.PbMap<$core.String, $core.String> get metadata => $_getMap(4); +} + +/// EdgeHelloResponse acknowledges Edge enrollment on the Control Plane-Edge +/// wire. Rejection details stay at the Edge boundary rather than exposing Node +/// scheduling decisions. +class EdgeHelloResponse extends $pb.GeneratedMessage { + factory EdgeHelloResponse({ + $core.bool? accepted, + $core.String? protocol, + $fixnum.Int64? serverTimeUnixNano, + $core.String? message, + $core.String? reason, + }) { + final result = create(); + if (accepted != null) result.accepted = accepted; + if (protocol != null) result.protocol = protocol; + if (serverTimeUnixNano != null) + result.serverTimeUnixNano = serverTimeUnixNano; + if (message != null) result.message = message; + if (reason != null) result.reason = reason; + return result; + } + + EdgeHelloResponse._(); + + factory EdgeHelloResponse.fromBuffer($core.List<$core.int> data, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromBuffer(data, registry); + factory EdgeHelloResponse.fromJson($core.String json, + [$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) => + create()..mergeFromJson(json, registry); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo( + _omitMessageNames ? '' : 'EdgeHelloResponse', + package: const $pb.PackageName(_omitMessageNames ? '' : 'iop'), + createEmptyInstance: create) + ..aOB(1, _omitFieldNames ? '' : 'accepted') + ..aOS(2, _omitFieldNames ? '' : 'protocol') + ..aInt64(3, _omitFieldNames ? '' : 'serverTimeUnixNano') + ..aOS(4, _omitFieldNames ? '' : 'message') + ..aOS(5, _omitFieldNames ? '' : 'reason') + ..hasRequiredFields = false; + + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeHelloResponse clone() => deepCopy(); + @$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.') + EdgeHelloResponse copyWith(void Function(EdgeHelloResponse) updates) => + super.copyWith((message) => updates(message as EdgeHelloResponse)) + as EdgeHelloResponse; + + @$core.override + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static EdgeHelloResponse create() => EdgeHelloResponse._(); + @$core.override + EdgeHelloResponse createEmptyInstance() => create(); + @$core.pragma('dart2js:noInline') + static EdgeHelloResponse getDefault() => _defaultInstance ??= + $pb.GeneratedMessage.$_defaultFor(create); + static EdgeHelloResponse? _defaultInstance; + + @$pb.TagNumber(1) + $core.bool get accepted => $_getBF(0); + @$pb.TagNumber(1) + set accepted($core.bool value) => $_setBool(0, value); + @$pb.TagNumber(1) + $core.bool hasAccepted() => $_has(0); + @$pb.TagNumber(1) + void clearAccepted() => $_clearField(1); + + @$pb.TagNumber(2) + $core.String get protocol => $_getSZ(1); + @$pb.TagNumber(2) + set protocol($core.String value) => $_setString(1, value); + @$pb.TagNumber(2) + $core.bool hasProtocol() => $_has(1); + @$pb.TagNumber(2) + void clearProtocol() => $_clearField(2); + + @$pb.TagNumber(3) + $fixnum.Int64 get serverTimeUnixNano => $_getI64(2); + @$pb.TagNumber(3) + set serverTimeUnixNano($fixnum.Int64 value) => $_setInt64(2, value); + @$pb.TagNumber(3) + $core.bool hasServerTimeUnixNano() => $_has(2); + @$pb.TagNumber(3) + void clearServerTimeUnixNano() => $_clearField(3); + + @$pb.TagNumber(4) + $core.String get message => $_getSZ(3); + @$pb.TagNumber(4) + set message($core.String value) => $_setString(3, value); + @$pb.TagNumber(4) + $core.bool hasMessage() => $_has(3); + @$pb.TagNumber(4) + void clearMessage() => $_clearField(4); + + @$pb.TagNumber(5) + $core.String get reason => $_getSZ(4); + @$pb.TagNumber(5) + set reason($core.String value) => $_setString(4, value); + @$pb.TagNumber(5) + $core.bool hasReason() => $_has(4); + @$pb.TagNumber(5) + void clearReason() => $_clearField(5); +} + const $core.bool _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names'); const $core.bool _omitMessageNames = diff --git a/apps/client/lib/gen/proto/iop/control.pbjson.dart b/apps/client/lib/gen/proto/iop/control.pbjson.dart index 53bd55f..8475cc8 100644 --- a/apps/client/lib/gen/proto/iop/control.pbjson.dart +++ b/apps/client/lib/gen/proto/iop/control.pbjson.dart @@ -145,3 +145,66 @@ final $typed_data.Uint8List clientHelloResponseDescriptor = $convert.base64Decod 'ChNDbGllbnRIZWxsb1Jlc3BvbnNlEhQKBXJlYWR5GAEgASgIUgVyZWFkeRIaCghwcm90b2NvbB' 'gCIAEoCVIIcHJvdG9jb2wSMQoVc2VydmVyX3RpbWVfdW5peF9uYW5vGAMgASgDUhJzZXJ2ZXJU' 'aW1lVW5peE5hbm8SGAoHbWVzc2FnZRgEIAEoCVIHbWVzc2FnZQ=='); + +@$core.Deprecated('Use edgeHelloRequestDescriptor instead') +const EdgeHelloRequest$json = { + '1': 'EdgeHelloRequest', + '2': [ + {'1': 'edge_id', '3': 1, '4': 1, '5': 9, '10': 'edgeId'}, + {'1': 'edge_name', '3': 2, '4': 1, '5': 9, '10': 'edgeName'}, + {'1': 'version', '3': 3, '4': 1, '5': 9, '10': 'version'}, + {'1': 'capabilities', '3': 4, '4': 3, '5': 9, '10': 'capabilities'}, + { + '1': 'metadata', + '3': 5, + '4': 3, + '5': 11, + '6': '.iop.EdgeHelloRequest.MetadataEntry', + '10': 'metadata' + }, + ], + '3': [EdgeHelloRequest_MetadataEntry$json], +}; + +@$core.Deprecated('Use edgeHelloRequestDescriptor instead') +const EdgeHelloRequest_MetadataEntry$json = { + '1': 'MetadataEntry', + '2': [ + {'1': 'key', '3': 1, '4': 1, '5': 9, '10': 'key'}, + {'1': 'value', '3': 2, '4': 1, '5': 9, '10': 'value'}, + ], + '7': {'7': true}, +}; + +/// Descriptor for `EdgeHelloRequest`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List edgeHelloRequestDescriptor = $convert.base64Decode( + 'ChBFZGdlSGVsbG9SZXF1ZXN0EhcKB2VkZ2VfaWQYASABKAlSBmVkZ2VJZBIbCgllZGdlX25hbW' + 'UYAiABKAlSCGVkZ2VOYW1lEhgKB3ZlcnNpb24YAyABKAlSB3ZlcnNpb24SIgoMY2FwYWJpbGl0' + 'aWVzGAQgAygJUgxjYXBhYmlsaXRpZXMSPwoIbWV0YWRhdGEYBSADKAsyIy5pb3AuRWRnZUhlbG' + 'xvUmVxdWVzdC5NZXRhZGF0YUVudHJ5UghtZXRhZGF0YRo7Cg1NZXRhZGF0YUVudHJ5EhAKA2tl' + 'eRgBIAEoCVIDa2V5EhQKBXZhbHVlGAIgASgJUgV2YWx1ZToCOAE='); + +@$core.Deprecated('Use edgeHelloResponseDescriptor instead') +const EdgeHelloResponse$json = { + '1': 'EdgeHelloResponse', + '2': [ + {'1': 'accepted', '3': 1, '4': 1, '5': 8, '10': 'accepted'}, + {'1': 'protocol', '3': 2, '4': 1, '5': 9, '10': 'protocol'}, + { + '1': 'server_time_unix_nano', + '3': 3, + '4': 1, + '5': 3, + '10': 'serverTimeUnixNano' + }, + {'1': 'message', '3': 4, '4': 1, '5': 9, '10': 'message'}, + {'1': 'reason', '3': 5, '4': 1, '5': 9, '10': 'reason'}, + ], +}; + +/// Descriptor for `EdgeHelloResponse`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List edgeHelloResponseDescriptor = $convert.base64Decode( + 'ChFFZGdlSGVsbG9SZXNwb25zZRIaCghhY2NlcHRlZBgBIAEoCFIIYWNjZXB0ZWQSGgoIcHJvdG' + '9jb2wYAiABKAlSCHByb3RvY29sEjEKFXNlcnZlcl90aW1lX3VuaXhfbmFubxgDIAEoA1ISc2Vy' + 'dmVyVGltZVVuaXhOYW5vEhgKB21lc3NhZ2UYBCABKAlSB21lc3NhZ2USFgoGcmVhc29uGAUgAS' + 'gJUgZyZWFzb24='); diff --git a/apps/control-plane/Dockerfile b/apps/control-plane/Dockerfile index 97e14e8..61f30a6 100644 --- a/apps/control-plane/Dockerfile +++ b/apps/control-plane/Dockerfile @@ -22,7 +22,7 @@ WORKDIR /app COPY --from=builder /out/control-plane /usr/local/bin/control-plane COPY --from=builder /workspace/iop/configs/control-plane.yaml /app/configs/control-plane.yaml -EXPOSE 9080 19080 +EXPOSE 9080 19080 19081 USER iop ENTRYPOINT ["control-plane"] diff --git a/apps/control-plane/README.md b/apps/control-plane/README.md index 501f4c4..2068edc 100644 --- a/apps/control-plane/README.md +++ b/apps/control-plane/README.md @@ -18,10 +18,13 @@ Control Plane-Edge wire protocol은 Edge가 소유한 operation을 원격 운영 - `GET /healthz` - `GET /readyz` -- protobuf-socket wire endpoint 예약: `server.wire_listen` +- Client WS proto-socket wire endpoint: `server.wire_listen` (기본 `0.0.0.0:19080`, `/client` 경로). 브라우저/앱 Client 경계다. +- Edge TCP proto-socket wire endpoint: `server.edge_wire_listen` (기본 `0.0.0.0:19081`). Edge가 outbound로 연결하는 Control Plane-Edge native wire 경계이며 Client WS와 같은 listener를 공유하지 않는다. - Control Plane DB 접속 설정 예약: `database.url` - Control Plane Redis 접속 설정 예약: `redis.url`, `redis.key_prefix` +두 wire endpoint는 환경변수로 override한다. Client WS는 `IOP_WIRE_LISTEN`, Edge TCP는 `IOP_EDGE_WIRE_LISTEN`으로 listen 주소를 바꾼다. compose dev 환경은 host publish port를 `IOP_CONTROL_PLANE_WIRE_PORT`(기본 19080), `IOP_CONTROL_PLANE_EDGE_WIRE_PORT`(기본 19081)로 분리해 둔다. + 로컬 직접 실행 테스트는 `configs/control-plane.yaml`의 `database.url`, `redis.url`, `redis.key_prefix`를 사용한다. Postgres 기본 DB명은 `iop-control-plane-local`이고 Redis는 `code-server-redis`의 DB 1과 `iop:control-plane:local:` key prefix를 사용한다. dev compose 환경은 `IOP_DATABASE_URL`, `IOP_REDIS_URL`, `IOP_REDIS_KEY_PREFIX` 환경변수로 override하고 Postgres 기본 DB명을 `iop-control-plane-dev`, Redis DB를 2와 `iop:control-plane:dev:` key prefix로 둔다. ## 계획된 기능 diff --git a/apps/control-plane/cmd/control-plane/main.go b/apps/control-plane/cmd/control-plane/main.go index fe25f14..f4a2827 100644 --- a/apps/control-plane/cmd/control-plane/main.go +++ b/apps/control-plane/cmd/control-plane/main.go @@ -22,8 +22,9 @@ var cfgFile string type controlPlaneConfig struct { Server struct { - Listen string `yaml:"listen"` - WireListen string `yaml:"wire_listen"` + Listen string `yaml:"listen"` + WireListen string `yaml:"wire_listen"` + EdgeWireListen string `yaml:"edge_wire_listen"` } `yaml:"server"` Database struct { URL string `yaml:"url"` @@ -87,6 +88,7 @@ func defaultConfig() controlPlaneConfig { var cfg controlPlaneConfig cfg.Server.Listen = "0.0.0.0:9080" cfg.Server.WireListen = "0.0.0.0:19080" + cfg.Server.EdgeWireListen = "0.0.0.0:19081" cfg.Database.URL = "postgres://nomadcode:nomadcode@code-server-postgres:5432/iop-control-plane-local?sslmode=disable" cfg.Redis.URL = "redis://code-server-redis:6379/1" cfg.Redis.KeyPrefix = "iop:control-plane:local:" @@ -116,6 +118,9 @@ func applyEnvOverrides(cfg *controlPlaneConfig) { if wireListen := os.Getenv("IOP_WIRE_LISTEN"); wireListen != "" { cfg.Server.WireListen = wireListen } + if edgeWireListen := os.Getenv("IOP_EDGE_WIRE_LISTEN"); edgeWireListen != "" { + cfg.Server.EdgeWireListen = edgeWireListen + } if databaseURL := os.Getenv("IOP_DATABASE_URL"); databaseURL != "" { cfg.Database.URL = databaseURL } @@ -139,10 +144,16 @@ func run(ctx context.Context, cfg controlPlaneConfig, logger *zap.Logger) error }) wireEndpoint := wire.Endpoint{Listen: cfg.Server.WireListen} - logger.Info("control-plane wire endpoint reserved", + logger.Info("control-plane client wire endpoint reserved", zap.String("protocol", wire.Protocol), + zap.String("transport", wire.ClientTransport), zap.String("listen", wireEndpoint.Listen), ) + logger.Info("control-plane edge wire endpoint reserved", + zap.String("protocol", wire.Protocol), + zap.String("transport", wire.EdgeTransport), + zap.String("listen", cfg.Server.EdgeWireListen), + ) if cfg.Database.URL != "" { logger.Info("control-plane database configured", databaseLogFields(cfg.Database.URL)...) } @@ -158,6 +169,15 @@ func run(ctx context.Context, cfg controlPlaneConfig, logger *zap.Logger) error } defer func() { _ = clientServer.Stop() }() + edgeServer, err := wire.NewEdgeServer(cfg.Server.EdgeWireListen, logger) + if err != nil { + return fmt.Errorf("edge wire server: %w", err) + } + if err := edgeServer.Start(ctx); err != nil { + return fmt.Errorf("start edge wire server: %w", err) + } + defer func() { _ = edgeServer.Stop() }() + server := &http.Server{ Addr: cfg.Server.Listen, Handler: mux, diff --git a/apps/control-plane/cmd/control-plane/main_test.go b/apps/control-plane/cmd/control-plane/main_test.go index 6580939..2f7fcd2 100644 --- a/apps/control-plane/cmd/control-plane/main_test.go +++ b/apps/control-plane/cmd/control-plane/main_test.go @@ -64,6 +64,8 @@ func TestLoadConfigRepositoryLocalConfig(t *testing.T) { t.Setenv("IOP_DATABASE_URL", "") t.Setenv("IOP_REDIS_URL", "") t.Setenv("IOP_REDIS_KEY_PREFIX", "") + t.Setenv("IOP_WIRE_LISTEN", "") + t.Setenv("IOP_EDGE_WIRE_LISTEN", "") cfg, err := loadConfig("../../../../configs/control-plane.yaml") if err != nil { t.Fatalf("load config: %v", err) @@ -74,6 +76,12 @@ func TestLoadConfigRepositoryLocalConfig(t *testing.T) { if cfg.Redis.KeyPrefix != "iop:control-plane:local:" { t.Fatalf("redis key prefix: got %q", cfg.Redis.KeyPrefix) } + if cfg.Server.WireListen != "0.0.0.0:19080" { + t.Fatalf("wire_listen: got %q", cfg.Server.WireListen) + } + if cfg.Server.EdgeWireListen != "0.0.0.0:19081" { + t.Fatalf("edge_wire_listen: got %q", cfg.Server.EdgeWireListen) + } } func TestLoadConfigEnvOverrides(t *testing.T) { @@ -186,3 +194,39 @@ server: t.Fatalf("wire_listen: got %q want %q", cfg.Server.WireListen, wantWireListen) } } + +func TestLoadConfigEdgeWireListenDefault(t *testing.T) { + t.Setenv("IOP_EDGE_WIRE_LISTEN", "") + cfg, err := loadConfig("") + if err != nil { + t.Fatalf("load config: %v", err) + } + if cfg.Server.EdgeWireListen != "0.0.0.0:19081" { + t.Fatalf("edge_wire_listen default: got %q want %q", cfg.Server.EdgeWireListen, "0.0.0.0:19081") + } + if cfg.Server.EdgeWireListen == cfg.Server.WireListen { + t.Fatalf("edge_wire_listen must not share the client wire_listen address %q", cfg.Server.WireListen) + } +} + +func TestLoadConfigEdgeWireListenEnvOverrides(t *testing.T) { + path := writeConfig(t, ` +server: + listen: "0.0.0.0:9080" + wire_listen: "0.0.0.0:19080" + edge_wire_listen: "0.0.0.0:19081" +`) + const wantEdgeWireListen = "127.0.0.1:29081" + t.Setenv("IOP_EDGE_WIRE_LISTEN", wantEdgeWireListen) + + cfg, err := loadConfig(path) + if err != nil { + t.Fatalf("load config: %v", err) + } + if cfg.Server.EdgeWireListen != wantEdgeWireListen { + t.Fatalf("edge_wire_listen: got %q want %q", cfg.Server.EdgeWireListen, wantEdgeWireListen) + } + if cfg.Server.WireListen != "0.0.0.0:19080" { + t.Fatalf("wire_listen should stay from YAML: got %q", cfg.Server.WireListen) + } +} diff --git a/apps/control-plane/internal/wire/edge.go b/apps/control-plane/internal/wire/edge.go new file mode 100644 index 0000000..2265db1 --- /dev/null +++ b/apps/control-plane/internal/wire/edge.go @@ -0,0 +1,23 @@ +package wire + +import ( + "google.golang.org/protobuf/proto" + + proto_socket "git.toki-labs.com/toki/proto-socket/go" + iop "iop/proto/gen/iop" +) + +// EdgeParserMap returns the proto-socket parser set for the Control Plane-Edge +// native TCP wire contract. +func EdgeParserMap() proto_socket.ParserMap { + return proto_socket.ParserMap{ + proto_socket.TypeNameOf(&iop.EdgeHelloRequest{}): func(b []byte) (proto.Message, error) { + req := &iop.EdgeHelloRequest{} + return req, proto.Unmarshal(b, req) + }, + proto_socket.TypeNameOf(&iop.EdgeHelloResponse{}): func(b []byte) (proto.Message, error) { + res := &iop.EdgeHelloResponse{} + return res, proto.Unmarshal(b, res) + }, + } +} diff --git a/apps/control-plane/internal/wire/edge_registry.go b/apps/control-plane/internal/wire/edge_registry.go new file mode 100644 index 0000000..cef5347 --- /dev/null +++ b/apps/control-plane/internal/wire/edge_registry.go @@ -0,0 +1,96 @@ +package wire + +import ( + "sync" + "time" + + proto_socket "git.toki-labs.com/toki/proto-socket/go" + iop "iop/proto/gen/iop" +) + +// EdgeConnectionState captures the Control Plane's internal view of a single +// Edge wire connection. It is a registry foundation only; public status query +// and event relay APIs are deferred to later subtasks. +type EdgeConnectionState struct { + EdgeID string + EdgeName string + Version string + Capabilities []string + Protocol string + Connected bool + LastSeen time.Time + DisconnectInfo proto_socket.DisconnectInfo + + // token identifies the specific connection that produced this state. It is an + // internal registry detail (not an external API contract) used to ignore a + // stale connection's late disconnect after the same edge_id reconnected. + token uint64 +} + +// EdgeRegistry stores Edge connection state keyed by edge_id behind a mutex. +type EdgeRegistry struct { + mu sync.Mutex + edges map[string]*EdgeConnectionState + nextToken uint64 +} + +// NewEdgeRegistry returns an empty Edge connection registry. +func NewEdgeRegistry() *EdgeRegistry { + return &EdgeRegistry{edges: make(map[string]*EdgeConnectionState)} +} + +// MarkConnected records or refreshes an Edge as connected from its hello and +// returns the connection token to hand back on disconnect. A reconnection +// supersedes any earlier connection for the same edge_id. +func (r *EdgeRegistry) MarkConnected(req *iop.EdgeHelloRequest, at time.Time) uint64 { + r.mu.Lock() + defer r.mu.Unlock() + r.nextToken++ + token := r.nextToken + r.edges[req.GetEdgeId()] = &EdgeConnectionState{ + EdgeID: req.GetEdgeId(), + EdgeName: req.GetEdgeName(), + Version: req.GetVersion(), + Capabilities: append([]string(nil), req.GetCapabilities()...), + Protocol: Protocol, + Connected: true, + LastSeen: at, + token: token, + } + return token +} + +// MarkDisconnected flips a tracked Edge to disconnected and records why, but +// only when token still matches the current connection. A stale token from a +// superseded connection is ignored so a reconnection is not cleared. +func (r *EdgeRegistry) MarkDisconnected(edgeID string, token uint64, at time.Time, info proto_socket.DisconnectInfo) { + r.mu.Lock() + defer r.mu.Unlock() + state, ok := r.edges[edgeID] + if !ok || state.token != token { + return + } + state.Connected = false + state.LastSeen = at + state.DisconnectInfo = info +} + +// Snapshot returns a copy of the tracked state for an Edge. +func (r *EdgeRegistry) Snapshot(edgeID string) (EdgeConnectionState, bool) { + r.mu.Lock() + defer r.mu.Unlock() + state, ok := r.edges[edgeID] + if !ok { + return EdgeConnectionState{}, false + } + snapshot := *state + snapshot.Capabilities = append([]string(nil), state.Capabilities...) + return snapshot, true +} + +// Len returns the number of tracked Edge connections. +func (r *EdgeRegistry) Len() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.edges) +} diff --git a/apps/control-plane/internal/wire/edge_server.go b/apps/control-plane/internal/wire/edge_server.go new file mode 100644 index 0000000..71ff951 --- /dev/null +++ b/apps/control-plane/internal/wire/edge_server.go @@ -0,0 +1,138 @@ +package wire + +import ( + "context" + "fmt" + "net" + "strconv" + "sync" + "time" + + "go.uber.org/zap" + + proto_socket "git.toki-labs.com/toki/proto-socket/go" + iop "iop/proto/gen/iop" +) + +// EdgeServer hosts the Control Plane-Edge native TCP wire endpoint. It accepts +// Edge connections over proto-socket, handles the hello handshake, and keeps an +// internal connection registry. It does not connect to or schedule Node +// directly; the Control Plane observes and controls the system through Edge. +type EdgeServer struct { + server *proto_socket.TcpServer + registry *EdgeRegistry + logger *zap.Logger + host string + port int +} + +// NewEdgeServer creates an EdgeServer bound to the given TCP listen address. +func NewEdgeServer(listenAddr string, logger *zap.Logger) (*EdgeServer, error) { + host, portStr, err := net.SplitHostPort(listenAddr) + if err != nil { + return nil, fmt.Errorf("invalid edge listen address %q: %w", listenAddr, err) + } + port, err := strconv.Atoi(portStr) + if err != nil { + return nil, fmt.Errorf("invalid port in edge listen address %q: %w", listenAddr, err) + } + + registry := NewEdgeRegistry() + es := &EdgeServer{ + registry: registry, + logger: logger, + host: host, + port: port, + } + + es.server = proto_socket.NewTcpServer(host, port, func(conn net.Conn) *proto_socket.TcpClient { + client := proto_socket.NewTcpClient(conn, EdgeHeartbeatIntervalSec, EdgeHeartbeatWaitSec, EdgeParserMap()) + + // edgeID and token are set once the hello handshake identifies the + // connection so the disconnect listener can flip the right registry entry. + // The token lets a stale connection's late disconnect be ignored after the + // same edge_id reconnected. The hello and disconnect callbacks may run on + // different goroutines, so guard them. + var ( + idMu sync.Mutex + edgeID string + token uint64 + ) + + proto_socket.AddRequestListenerTyped(&client.Communicator, func(req *iop.EdgeHelloRequest) (*iop.EdgeHelloResponse, error) { + now := time.Now() + if req.GetEdgeId() == "" { + logger.Warn("edge hello rejected: missing edge_id") + return &iop.EdgeHelloResponse{ + Accepted: false, + Protocol: Protocol, + ServerTimeUnixNano: now.UnixNano(), + Reason: "edge_id is required", + }, nil + } + + connToken := registry.MarkConnected(req, now) + idMu.Lock() + edgeID = req.GetEdgeId() + token = connToken + idMu.Unlock() + + logger.Info("edge hello accepted", + zap.String("edge_id", req.GetEdgeId()), + zap.String("edge_name", req.GetEdgeName()), + zap.String("version", req.GetVersion()), + ) + return &iop.EdgeHelloResponse{ + Accepted: true, + Protocol: Protocol, + ServerTimeUnixNano: now.UnixNano(), + Message: "Edge enrolled on IOP Control Plane", + }, nil + }) + + client.AddDisconnectListener(func(c *proto_socket.TcpClient) { + idMu.Lock() + id := edgeID + tok := token + idMu.Unlock() + if id == "" { + return + } + info := c.DisconnectInfo() + registry.MarkDisconnected(id, tok, time.Now(), info) + logger.Info("edge disconnected", + zap.String("edge_id", id), + zap.String("reason", info.Reason), + ) + }) + + return client + }) + + return es, nil +} + +// Start begins accepting Edge connections on the configured TCP endpoint. +func (s *EdgeServer) Start(ctx context.Context) error { + s.logger.Info("starting edge wire TCP server", + zap.String("host", s.host), + zap.Int("port", s.port), + zap.String("transport", EdgeTransport), + ) + return s.server.Start(ctx) +} + +// Stop stops the Edge wire TCP server and closes active connections. +func (s *EdgeServer) Stop() error { + s.logger.Info("stopping edge wire TCP server") + return s.server.Stop() +} + +// Registry exposes the internal Edge connection registry. +func (s *EdgeServer) Registry() *EdgeRegistry { return s.registry } + +// Host returns the host the server is listening on. +func (s *EdgeServer) Host() string { return s.host } + +// Port returns the port the server is listening on. +func (s *EdgeServer) Port() int { return s.port } diff --git a/apps/control-plane/internal/wire/edge_server_test.go b/apps/control-plane/internal/wire/edge_server_test.go new file mode 100644 index 0000000..1ab1c73 --- /dev/null +++ b/apps/control-plane/internal/wire/edge_server_test.go @@ -0,0 +1,233 @@ +package wire + +import ( + "context" + "fmt" + "testing" + "time" + + "go.uber.org/zap/zaptest" + + proto_socket "git.toki-labs.com/toki/proto-socket/go" + iop "iop/proto/gen/iop" +) + +func startEdgeServer(t *testing.T) (*EdgeServer, int) { + t.Helper() + logger := zaptest.NewLogger(t) + port := getFreePort(t) + listenAddr := fmt.Sprintf("127.0.0.1:%d", port) + + server, err := NewEdgeServer(listenAddr, logger) + if err != nil { + t.Fatalf("NewEdgeServer failed: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + if err := server.Start(ctx); err != nil { + t.Fatalf("edge server Start failed: %v", err) + } + t.Cleanup(func() { _ = server.Stop() }) + + // Give a tiny moment for the listener to bind. + time.Sleep(50 * time.Millisecond) + return server, port +} + +func dialEdge(t *testing.T, ctx context.Context, port int) *proto_socket.TcpClient { + t.Helper() + client, err := proto_socket.DialTcp( + ctx, + "127.0.0.1", + port, + proto_socket.DefaultHeartbeatIntervalSec, + proto_socket.DefaultHeartbeatWaitSec, + EdgeParserMap(), + ) + if err != nil { + t.Fatalf("failed to DialTcp: %v", err) + } + return client +} + +func TestEdgeServerHelloRegistersEdge(t *testing.T) { + server, port := startEdgeServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client := dialEdge(t, ctx, port) + defer client.Close() + + req := &iop.EdgeHelloRequest{ + EdgeId: "edge-dgx-group", + EdgeName: "DGX Group", + Version: "1.2.3", + Capabilities: []string{"node-registry", "run-dispatch"}, + } + + res, err := proto_socket.SendRequestTyped[*iop.EdgeHelloRequest, *iop.EdgeHelloResponse]( + &client.Communicator, + req, + 2*time.Second, + ) + if err != nil { + t.Fatalf("SendRequestTyped failed: %v", err) + } + + if !res.Accepted { + t.Errorf("expected response.Accepted to be true, got false (reason=%q)", res.Reason) + } + if res.Protocol != Protocol { + t.Errorf("expected response.Protocol to be %q, got %q", Protocol, res.Protocol) + } + if res.ServerTimeUnixNano <= 0 { + t.Errorf("expected server time unix nano to be positive, got %d", res.ServerTimeUnixNano) + } + + state, ok := server.Registry().Snapshot("edge-dgx-group") + if !ok { + t.Fatalf("edge not registered after hello") + } + if !state.Connected { + t.Errorf("expected registry Connected to be true") + } + if state.EdgeName != "DGX Group" { + t.Errorf("edge_name: got %q want %q", state.EdgeName, "DGX Group") + } + if len(state.Capabilities) != 2 { + t.Errorf("capabilities: got %v", state.Capabilities) + } + if state.LastSeen.IsZero() { + t.Errorf("expected LastSeen to be set") + } +} + +func TestEdgeServerRejectsMissingEdgeID(t *testing.T) { + server, port := startEdgeServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client := dialEdge(t, ctx, port) + defer client.Close() + + req := &iop.EdgeHelloRequest{ + EdgeName: "no id edge", + } + + res, err := proto_socket.SendRequestTyped[*iop.EdgeHelloRequest, *iop.EdgeHelloResponse]( + &client.Communicator, + req, + 2*time.Second, + ) + if err != nil { + t.Fatalf("SendRequestTyped failed: %v", err) + } + + if res.Accepted { + t.Errorf("expected response.Accepted to be false for missing edge_id") + } + if res.Reason == "" { + t.Errorf("expected a rejection reason for missing edge_id") + } + if server.Registry().Len() != 0 { + t.Errorf("expected registry to stay empty, got %d entries", server.Registry().Len()) + } +} + +func TestEdgeServerDisconnectMarksEdgeDisconnected(t *testing.T) { + server, port := startEdgeServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + client := dialEdge(t, ctx, port) + + req := &iop.EdgeHelloRequest{ + EdgeId: "edge-dgx-group", + EdgeName: "DGX Group", + } + if _, err := proto_socket.SendRequestTyped[*iop.EdgeHelloRequest, *iop.EdgeHelloResponse]( + &client.Communicator, + req, + 2*time.Second, + ); err != nil { + t.Fatalf("SendRequestTyped failed: %v", err) + } + + if err := client.Close(); err != nil { + t.Fatalf("client Close failed: %v", err) + } + + // The server detects the closed connection asynchronously; poll until the + // disconnect listener flips the registry state. + deadline := time.Now().Add(2 * time.Second) + for { + state, ok := server.Registry().Snapshot("edge-dgx-group") + if ok && !state.Connected { + if state.DisconnectInfo.Reason == "" { + t.Errorf("expected a disconnect reason to be recorded") + } + return + } + if time.Now().After(deadline) { + t.Fatalf("edge was not marked disconnected in time (ok=%v)", ok) + } + time.Sleep(20 * time.Millisecond) + } +} + +func sendEdgeHello(t *testing.T, client *proto_socket.TcpClient, edgeID string) { + t.Helper() + if _, err := proto_socket.SendRequestTyped[*iop.EdgeHelloRequest, *iop.EdgeHelloResponse]( + &client.Communicator, + &iop.EdgeHelloRequest{EdgeId: edgeID, EdgeName: "DGX Group"}, + 2*time.Second, + ); err != nil { + t.Fatalf("SendRequestTyped failed: %v", err) + } +} + +func TestEdgeServerStaleDisconnectDoesNotClearReconnect(t *testing.T) { + server, port := startEdgeServer(t) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const edgeID = "edge-dgx-group" + + // First connection enrolls the edge. + conn1 := dialEdge(t, ctx, port) + sendEdgeHello(t, conn1, edgeID) + + // Second connection with the same edge_id reconnects and becomes current. + conn2 := dialEdge(t, ctx, port) + defer conn2.Close() + sendEdgeHello(t, conn2, edgeID) + + // The stale first connection closing must not clear the live reconnection. + if err := conn1.Close(); err != nil { + t.Fatalf("conn1 Close failed: %v", err) + } + + // Give the server time to process the stale disconnect, then assert the edge + // stays connected under the second connection's token. + time.Sleep(300 * time.Millisecond) + state, ok := server.Registry().Snapshot(edgeID) + if !ok { + t.Fatalf("edge missing from registry") + } + if !state.Connected { + t.Fatalf("stale disconnect cleared the live reconnection; Connected=false") + } +} + +func TestEdgeHeartbeatWaitExceedsInterval(t *testing.T) { + if EdgeHeartbeatWaitSec <= EdgeHeartbeatIntervalSec { + t.Fatalf("EdgeHeartbeatWaitSec (%d) must exceed EdgeHeartbeatIntervalSec (%d)", + EdgeHeartbeatWaitSec, EdgeHeartbeatIntervalSec) + } +} diff --git a/apps/control-plane/internal/wire/edge_test.go b/apps/control-plane/internal/wire/edge_test.go new file mode 100644 index 0000000..bc32d06 --- /dev/null +++ b/apps/control-plane/internal/wire/edge_test.go @@ -0,0 +1,131 @@ +package wire + +import ( + "testing" + "time" + + proto_socket "git.toki-labs.com/toki/proto-socket/go" + iop "iop/proto/gen/iop" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" +) + +func TestEdgeParserMapEdgeHelloRoundTrip(t *testing.T) { + parserMap := EdgeParserMap() + typeName := proto_socket.TypeNameOf(&iop.EdgeHelloRequest{}) + parse, ok := parserMap[typeName] + if !ok { + t.Fatalf("missing parser for %s", typeName) + } + + req := &iop.EdgeHelloRequest{ + EdgeId: "edge-dgx-group", + EdgeName: "DGX Group", + Version: "1.2.3", + Capabilities: []string{"node-registry", "run-dispatch"}, + Metadata: map[string]string{ + "region": "local", + }, + } + + payload, err := proto.Marshal(req) + if err != nil { + t.Fatalf("marshal EdgeHelloRequest: %v", err) + } + + got, err := parse(payload) + if err != nil { + t.Fatalf("parse EdgeHelloRequest: %v", err) + } + + parsed, ok := got.(*iop.EdgeHelloRequest) + if !ok { + t.Fatalf("expected *iop.EdgeHelloRequest, got %T", got) + } + if parsed.EdgeId != req.EdgeId { + t.Errorf("edge_id: got %q want %q", parsed.EdgeId, req.EdgeId) + } + if parsed.EdgeName != req.EdgeName { + t.Errorf("edge_name: got %q want %q", parsed.EdgeName, req.EdgeName) + } + if parsed.Version != req.Version { + t.Errorf("version: got %q want %q", parsed.Version, req.Version) + } + if len(parsed.Capabilities) != len(req.Capabilities) { + t.Fatalf("capabilities length: got %d want %d", len(parsed.Capabilities), len(req.Capabilities)) + } + if parsed.Capabilities[0] != req.Capabilities[0] || parsed.Capabilities[1] != req.Capabilities[1] { + t.Errorf("capabilities: got %v want %v", parsed.Capabilities, req.Capabilities) + } + if parsed.Metadata["region"] != "local" { + t.Errorf("metadata region: got %q want %q", parsed.Metadata["region"], "local") + } +} + +func TestEdgeParserMapEdgeHelloResponseRoundTrip(t *testing.T) { + parserMap := EdgeParserMap() + typeName := proto_socket.TypeNameOf(&iop.EdgeHelloResponse{}) + parse, ok := parserMap[typeName] + if !ok { + t.Fatalf("missing parser for %s", typeName) + } + + res := &iop.EdgeHelloResponse{ + Accepted: true, + Protocol: Protocol, + ServerTimeUnixNano: time.Now().UnixNano(), + Message: "edge enrolled", + } + + payload, err := proto.Marshal(res) + if err != nil { + t.Fatalf("marshal EdgeHelloResponse: %v", err) + } + + got, err := parse(payload) + if err != nil { + t.Fatalf("parse EdgeHelloResponse: %v", err) + } + + parsed, ok := got.(*iop.EdgeHelloResponse) + if !ok { + t.Fatalf("expected *iop.EdgeHelloResponse, got %T", got) + } + if !parsed.Accepted { + t.Error("accepted: got false want true") + } + if parsed.Protocol != Protocol { + t.Errorf("protocol: got %q want %q", parsed.Protocol, Protocol) + } + if parsed.ServerTimeUnixNano <= 0 { + t.Errorf("server_time_unix_nano should be positive, got %d", parsed.ServerTimeUnixNano) + } +} + +func TestEdgeHelloContractDoesNotExposeNodeDirectScheduling(t *testing.T) { + fields := (&iop.EdgeHelloRequest{}).ProtoReflect().Descriptor().Fields() + forbidden := []protoreflect.Name{ + "node_id", + "node_address", + "schedule_request", + "target", + } + for _, name := range forbidden { + if fields.ByName(name) != nil { + t.Fatalf("EdgeHelloRequest must not expose direct Node scheduling field %q", name) + } + } +} + +func TestWireTransportBoundary(t *testing.T) { + if Protocol != "protobuf-socket" { + t.Fatalf("Protocol = %q, want %q", Protocol, "protobuf-socket") + } + if EdgeTransport != "proto-socket-tcp" { + t.Fatalf("EdgeTransport = %q, want %q", EdgeTransport, "proto-socket-tcp") + } + if ClientTransport != "proto-socket-ws" { + t.Fatalf("ClientTransport = %q, want %q", ClientTransport, "proto-socket-ws") + } +} diff --git a/apps/control-plane/internal/wire/wire.go b/apps/control-plane/internal/wire/wire.go index 7c5e51c..1dc1f53 100644 --- a/apps/control-plane/internal/wire/wire.go +++ b/apps/control-plane/internal/wire/wire.go @@ -1,12 +1,32 @@ package wire -// Protocol names the IOP standard communication layer. The current Edge-Node -// transport already uses common-proto-socket; Control Plane endpoints should -// extend that same wire protocol instead of introducing another RPC framework. -const Protocol = "protobuf-socket" +const ( + // Protocol names the IOP standard communication layer. The current Edge-Node + // transport already uses common-proto-socket; Control Plane endpoints should + // extend that same wire protocol instead of introducing another RPC framework. + Protocol = "protobuf-socket" -// Endpoint is the reserved Control Plane wire endpoint configuration, -// realized by ClientServer for Client-Control Plane communication. + // EdgeTransport is the Control Plane-Edge native wire boundary. It uses + // proto-socket over TCP, while HTTP stays reserved for health, readiness, and + // bootstrap-style support endpoints. + EdgeTransport = "proto-socket-tcp" + + // ClientTransport is the browser/app boundary for Control Plane clients. + ClientTransport = "proto-socket-ws" + + // EdgeHeartbeatIntervalSec is how often the Control Plane edge wire drives a + // heartbeat exchange on a connected Edge TCP client. + EdgeHeartbeatIntervalSec = 30 + + // EdgeHeartbeatWaitSec is how long the edge wire waits for a heartbeat + // response before treating the Edge as gone. It intentionally exceeds the + // interval so a slow Edge round trip does not trigger a premature disconnect. + EdgeHeartbeatWaitSec = 45 +) + +// Endpoint carries a Control Plane wire listen address. The current server uses +// it for the Client WebSocket boundary; EdgeTransport records the native TCP +// boundary for the Control Plane-Edge contract. type Endpoint struct { Listen string } diff --git a/configs/control-plane.yaml b/configs/control-plane.yaml index 750e0ba..643c1f7 100644 --- a/configs/control-plane.yaml +++ b/configs/control-plane.yaml @@ -1,6 +1,7 @@ server: listen: "0.0.0.0:9080" wire_listen: "0.0.0.0:19080" + edge_wire_listen: "0.0.0.0:19081" database: url: "postgres://nomadcode:nomadcode@code-server-postgres:5432/iop-control-plane-local?sslmode=disable" diff --git a/docker-compose.yml b/docker-compose.yml index cef0b6b..d8d156c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -44,6 +44,7 @@ services: ports: - "${IOP_CONTROL_PLANE_HTTP_PORT:-9080}:9080" - "${IOP_CONTROL_PLANE_WIRE_PORT:-19080}:19080" + - "${IOP_CONTROL_PLANE_EDGE_WIRE_PORT:-19081}:19081" depends_on: postgres: condition: service_healthy diff --git a/proto/gen/iop/control.pb.go b/proto/gen/iop/control.pb.go index 29c5f32..417237a 100644 --- a/proto/gen/iop/control.pb.go +++ b/proto/gen/iop/control.pb.go @@ -334,6 +334,164 @@ func (x *ClientHelloResponse) GetMessage() string { return "" } +// EdgeHelloRequest is sent by Edge to Control Plane immediately after a +// Control Plane-Edge wire connection is established. It describes the Edge +// itself and must not become a direct Node registration or scheduling contract. +type EdgeHelloRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + EdgeId string `protobuf:"bytes,1,opt,name=edge_id,json=edgeId,proto3" json:"edge_id,omitempty"` + EdgeName string `protobuf:"bytes,2,opt,name=edge_name,json=edgeName,proto3" json:"edge_name,omitempty"` + Version string `protobuf:"bytes,3,opt,name=version,proto3" json:"version,omitempty"` + Capabilities []string `protobuf:"bytes,4,rep,name=capabilities,proto3" json:"capabilities,omitempty"` + Metadata map[string]string `protobuf:"bytes,5,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EdgeHelloRequest) Reset() { + *x = EdgeHelloRequest{} + mi := &file_proto_iop_control_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EdgeHelloRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EdgeHelloRequest) ProtoMessage() {} + +func (x *EdgeHelloRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_iop_control_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EdgeHelloRequest.ProtoReflect.Descriptor instead. +func (*EdgeHelloRequest) Descriptor() ([]byte, []int) { + return file_proto_iop_control_proto_rawDescGZIP(), []int{5} +} + +func (x *EdgeHelloRequest) GetEdgeId() string { + if x != nil { + return x.EdgeId + } + return "" +} + +func (x *EdgeHelloRequest) GetEdgeName() string { + if x != nil { + return x.EdgeName + } + return "" +} + +func (x *EdgeHelloRequest) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *EdgeHelloRequest) GetCapabilities() []string { + if x != nil { + return x.Capabilities + } + return nil +} + +func (x *EdgeHelloRequest) GetMetadata() map[string]string { + if x != nil { + return x.Metadata + } + return nil +} + +// EdgeHelloResponse acknowledges Edge enrollment on the Control Plane-Edge +// wire. Rejection details stay at the Edge boundary rather than exposing Node +// scheduling decisions. +type EdgeHelloResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Accepted bool `protobuf:"varint,1,opt,name=accepted,proto3" json:"accepted,omitempty"` + Protocol string `protobuf:"bytes,2,opt,name=protocol,proto3" json:"protocol,omitempty"` + ServerTimeUnixNano int64 `protobuf:"varint,3,opt,name=server_time_unix_nano,json=serverTimeUnixNano,proto3" json:"server_time_unix_nano,omitempty"` + Message string `protobuf:"bytes,4,opt,name=message,proto3" json:"message,omitempty"` + Reason string `protobuf:"bytes,5,opt,name=reason,proto3" json:"reason,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *EdgeHelloResponse) Reset() { + *x = EdgeHelloResponse{} + mi := &file_proto_iop_control_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *EdgeHelloResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*EdgeHelloResponse) ProtoMessage() {} + +func (x *EdgeHelloResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_iop_control_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use EdgeHelloResponse.ProtoReflect.Descriptor instead. +func (*EdgeHelloResponse) Descriptor() ([]byte, []int) { + return file_proto_iop_control_proto_rawDescGZIP(), []int{6} +} + +func (x *EdgeHelloResponse) GetAccepted() bool { + if x != nil { + return x.Accepted + } + return false +} + +func (x *EdgeHelloResponse) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +func (x *EdgeHelloResponse) GetServerTimeUnixNano() int64 { + if x != nil { + return x.ServerTimeUnixNano + } + return 0 +} + +func (x *EdgeHelloResponse) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +func (x *EdgeHelloResponse) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + var File_proto_iop_control_proto protoreflect.FileDescriptor const file_proto_iop_control_proto_rawDesc = "" + @@ -368,7 +526,22 @@ const file_proto_iop_control_proto_rawDesc = "" + "\x05ready\x18\x01 \x01(\bR\x05ready\x12\x1a\n" + "\bprotocol\x18\x02 \x01(\tR\bprotocol\x121\n" + "\x15server_time_unix_nano\x18\x03 \x01(\x03R\x12serverTimeUnixNano\x12\x18\n" + - "\amessage\x18\x04 \x01(\tR\amessageB\x13Z\x11iop/proto/gen/iopb\x06proto3" + "\amessage\x18\x04 \x01(\tR\amessage\"\x84\x02\n" + + "\x10EdgeHelloRequest\x12\x17\n" + + "\aedge_id\x18\x01 \x01(\tR\x06edgeId\x12\x1b\n" + + "\tedge_name\x18\x02 \x01(\tR\bedgeName\x12\x18\n" + + "\aversion\x18\x03 \x01(\tR\aversion\x12\"\n" + + "\fcapabilities\x18\x04 \x03(\tR\fcapabilities\x12?\n" + + "\bmetadata\x18\x05 \x03(\v2#.iop.EdgeHelloRequest.MetadataEntryR\bmetadata\x1a;\n" + + "\rMetadataEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb0\x01\n" + + "\x11EdgeHelloResponse\x12\x1a\n" + + "\baccepted\x18\x01 \x01(\bR\baccepted\x12\x1a\n" + + "\bprotocol\x18\x02 \x01(\tR\bprotocol\x121\n" + + "\x15server_time_unix_nano\x18\x03 \x01(\x03R\x12serverTimeUnixNano\x12\x18\n" + + "\amessage\x18\x04 \x01(\tR\amessage\x12\x16\n" + + "\x06reason\x18\x05 \x01(\tR\x06reasonB\x13Z\x11iop/proto/gen/iopb\x06proto3" var ( file_proto_iop_control_proto_rawDescOnce sync.Once @@ -382,25 +555,29 @@ func file_proto_iop_control_proto_rawDescGZIP() []byte { return file_proto_iop_control_proto_rawDescData } -var file_proto_iop_control_proto_msgTypes = make([]protoimpl.MessageInfo, 7) +var file_proto_iop_control_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_proto_iop_control_proto_goTypes = []any{ (*PolicyRule)(nil), // 0: iop.PolicyRule (*ScheduleRequest)(nil), // 1: iop.ScheduleRequest (*ScheduleResponse)(nil), // 2: iop.ScheduleResponse (*ClientHelloRequest)(nil), // 3: iop.ClientHelloRequest (*ClientHelloResponse)(nil), // 4: iop.ClientHelloResponse - nil, // 5: iop.PolicyRule.ParamsEntry - nil, // 6: iop.ScheduleRequest.MetadataEntry + (*EdgeHelloRequest)(nil), // 5: iop.EdgeHelloRequest + (*EdgeHelloResponse)(nil), // 6: iop.EdgeHelloResponse + nil, // 7: iop.PolicyRule.ParamsEntry + nil, // 8: iop.ScheduleRequest.MetadataEntry + nil, // 9: iop.EdgeHelloRequest.MetadataEntry } var file_proto_iop_control_proto_depIdxs = []int32{ - 5, // 0: iop.PolicyRule.params:type_name -> iop.PolicyRule.ParamsEntry + 7, // 0: iop.PolicyRule.params:type_name -> iop.PolicyRule.ParamsEntry 0, // 1: iop.ScheduleRequest.policies:type_name -> iop.PolicyRule - 6, // 2: iop.ScheduleRequest.metadata:type_name -> iop.ScheduleRequest.MetadataEntry - 3, // [3:3] is the sub-list for method output_type - 3, // [3:3] is the sub-list for method input_type - 3, // [3:3] is the sub-list for extension type_name - 3, // [3:3] is the sub-list for extension extendee - 0, // [0:3] is the sub-list for field type_name + 8, // 2: iop.ScheduleRequest.metadata:type_name -> iop.ScheduleRequest.MetadataEntry + 9, // 3: iop.EdgeHelloRequest.metadata:type_name -> iop.EdgeHelloRequest.MetadataEntry + 4, // [4:4] is the sub-list for method output_type + 4, // [4:4] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name } func init() { file_proto_iop_control_proto_init() } @@ -414,7 +591,7 @@ func file_proto_iop_control_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_iop_control_proto_rawDesc), len(file_proto_iop_control_proto_rawDesc)), NumEnums: 0, - NumMessages: 7, + NumMessages: 10, NumExtensions: 0, NumServices: 0, }, diff --git a/proto/iop/control.proto b/proto/iop/control.proto index a3aced3..0a4599c 100644 --- a/proto/iop/control.proto +++ b/proto/iop/control.proto @@ -39,3 +39,25 @@ message ClientHelloResponse { int64 server_time_unix_nano = 3; string message = 4; } + +// EdgeHelloRequest is sent by Edge to Control Plane immediately after a +// Control Plane-Edge wire connection is established. It describes the Edge +// itself and must not become a direct Node registration or scheduling contract. +message EdgeHelloRequest { + string edge_id = 1; + string edge_name = 2; + string version = 3; + repeated string capabilities = 4; + map metadata = 5; +} + +// EdgeHelloResponse acknowledges Edge enrollment on the Control Plane-Edge +// wire. Rejection details stay at the Edge boundary rather than exposing Node +// scheduling decisions. +message EdgeHelloResponse { + bool accepted = 1; + string protocol = 2; + int64 server_time_unix_nano = 3; + string message = 4; + string reason = 5; +}