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 index f31978f..a38790a 100644 --- 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 @@ -42,15 +42,15 @@ Control Plane-Edge 연결에서 공유할 protobuf 메시지와 transport 경계 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 상태가 갱신된다. +- [x] [edge-outbound] Edge는 설정된 Control Plane wire endpoint로 outbound 연결을 시작할 수 있다. 검증: 로컬 Control Plane과 Edge를 함께 띄웠을 때 hello가 성공한다. +- [x] [heartbeat] Control Plane과 Edge는 heartbeat 또는 동등한 keepalive로 연결 생존 상태를 판단할 수 있다. +- [x] [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로 조회할 수 있다. +- [x] [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되는 최소 경로가 있다. @@ -59,13 +59,13 @@ Control Plane은 연결된 Edge를 제품 운영 표면에서 소비할 수 있 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에 정리된다. +- [x] [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가 아직 남아 있다. +- 완료 근거: `[wire-contract]` Epic은 proto 계약과 parser roundtrip 기준으로 충족되었고, Edge connector outbound/heartbeat/disconnect, Control Plane registry 조회 표면, local smoke 기준 문서화는 완료 근거가 확인되었다. status/event relay와 service-boundary 기능 Task가 아직 남아 있다. - 리뷰 필요: - [ ] 사용자가 완료 결과를 확인했다 - [ ] archive 이동을 승인했다 @@ -87,6 +87,10 @@ Control Plane wire handler가 Edge 내부 구현을 복제하지 않고 Edge-own - 표준선(선택): 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`가 통과했다. +- 진행 근거(2026-05-30): `agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/02+01_edge_connector/complete.log` 기준 Edge `control_plane` 설정/default/template, outbound hello, heartbeat, disconnect, reconnect, runtime wiring이 PASS 완료되었다. `go test -count=1 ./apps/edge/internal/controlplane`, `go test -race -count=1 ./apps/edge/internal/controlplane` 계열 검증이 통과했다. +- 진행 근거(2026-05-30): `agent-task/archive/2026/05/m-control-plane-edge-wire-baseline/03+01,02_local_smoke/complete.log` 기준 `make test-control-plane-edge-wire`가 local Control Plane과 Edge를 함께 띄워 `edge hello accepted`, `connected to control plane`, `edge disconnected`를 확인했다. +- 진행 근거(2026-05-30): Control Plane HTTP `/edges`와 `/edges/{edge_id}`에서 Edge registry snapshot을 조회하도록 했고, `go test -count=1 ./apps/control-plane/...`, registry/HTTP focused tests, `make test-control-plane-edge-wire`가 통과했다. +- 진행 근거(2026-05-30): `agent-test/local/control-plane-smoke.md`에 Control Plane-Edge TCP wire baseline의 `make test-control-plane-edge-wire` 실행 기준과 hello/connect/disconnect marker 판정 기준을 정리했다. - 선행 작업: Edge Command와 Control Plane 운영 경계 - 후속 작업: Control Plane과 Client, 정책, 이력, 감사, Multi-Edge 운영 - 확인 필요: 없음 diff --git a/agent-task/m-control-plane-edge-wire-baseline/04_status_snapshot/CODE_REVIEW-cloud-G08.md b/agent-task/m-control-plane-edge-wire-baseline/04_status_snapshot/CODE_REVIEW-cloud-G08.md new file mode 100644 index 0000000..d3034bc --- /dev/null +++ b/agent-task/m-control-plane-edge-wire-baseline/04_status_snapshot/CODE_REVIEW-cloud-G08.md @@ -0,0 +1,187 @@ + + +# Code Review Reference - STATUS + +> **[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/04_status_snapshot, plan=0, tag=STATUS + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md` +- Task ids: + - `edge-status`: Control Plane은 Edge에 상태 snapshot을 요청하고 Edge-owned 상태를 응답받을 수 있다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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/04_status_snapshot/`로 이동한다. 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` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [STATUS-1] Proto Status Contract | [ ] | +| [STATUS-2] Control Plane Status Request Path | [ ] | +| [STATUS-3] Edge Service-Backed Status Response | [ ] | +| [STATUS-4] Focused Regression And Smoke | [ ] | + +## 구현 체크리스트 + +- [ ] `proto/iop/control.proto`에 Control Plane-Edge status request/response와 node snapshot message를 추가하고 `make proto`로 Go 생성물을 갱신한다. +- [ ] Control Plane `wire.EdgeServer`가 connected Edge client에 status request를 보내고 response를 받을 수 있도록 client tracking과 parser/test를 추가한다. +- [ ] Edge `controlplane.Connector`가 Control Plane status request를 수신하면 `apps/edge/internal/service`의 node snapshot 표면으로 응답하도록 option과 bootstrap wiring을 추가한다. +- [ ] Status proto roundtrip, Control Plane request/response, Edge service-backed response, bootstrap wiring 테스트를 추가하고 대상 Go 테스트를 통과시킨다. +- [ ] 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/04_status_snapshot/`를 `agent-task/archive/YYYY/MM/m-control-plane-edge-wire-baseline/04_status_snapshot/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-control-plane-edge-wire-baseline`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `EdgeStatusRequest`/`EdgeStatusResponse`가 Control Plane-Edge 전용 계약이며 Node direct scheduling 필드를 추가하지 않았는지 확인한다. +- Control Plane이 status 응답을 받기 위해 Edge active client만 사용하고 Node registry/transport를 직접 보지 않는지 확인한다. +- Edge status response가 `apps/edge/internal/service.ListNodeSnapshots()` 또는 그에 준하는 surface-neutral provider를 통과하는지 확인한다. +- generated proto 파일은 `make proto` 결과인지, 사람이 직접 편집한 흔적이 없는지 확인한다. +- review stub의 검증 출력이 실제 명령 stdout/stderr와 일치하는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### STATUS-1 중간 검증 + +```text +$ make proto +(output) + +$ go test -count=1 ./apps/control-plane/internal/wire ./proto/gen/... +(output) +``` + +### STATUS-2 중간 검증 + +```text +$ go test -count=1 ./apps/control-plane/internal/wire +(output) +``` + +### STATUS-3 중간 검증 + +```text +$ go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/service +(output) +``` + +### STATUS-4 중간 검증 + +```text +$ go test -count=1 ./apps/control-plane/... ./apps/edge/... ./packages/... ./proto/gen/... +(output) + +$ make test-control-plane-edge-wire +(output) +``` + +### 최종 검증 + +```text +$ make proto +(output) + +$ go test -count=1 ./apps/control-plane/internal/wire +(output) + +$ go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/service +(output) + +$ go test -count=1 ./apps/control-plane/... ./apps/edge/... ./packages/... ./proto/gen/... +(output) + +$ go test ./... +(output) + +$ 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. + +## 섹션 소유권 + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요, 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 | +| Roadmap Targets | plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음 | +| 구현 항목별 완료 여부 | 구현 에이전트 | `[ ]` -> `[x]` 체크 | +| 구현 체크리스트 | 구현 에이전트 | `[ ]` -> `[x]` 체크, 마지막 항목 필수 | +| 코드리뷰 전용 체크리스트 | 리뷰 에이전트 | 구현 에이전트가 수정하지 않음 | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트 | 실제 내용으로 교체 | +| 사용자 리뷰 요청 | 구현 에이전트 | 기본 `상태: 없음` 유지 또는 차단 근거 기록 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 리뷰 초점 | +| 검증 결과 | 구현 에이전트 | 실제 stdout/stderr 기록 | +| 코드리뷰 결과 | 리뷰 에이전트 | 스텁에 포함하지 않음 | diff --git a/agent-task/m-control-plane-edge-wire-baseline/04_status_snapshot/PLAN-cloud-G08.md b/agent-task/m-control-plane-edge-wire-baseline/04_status_snapshot/PLAN-cloud-G08.md new file mode 100644 index 0000000..a39efd3 --- /dev/null +++ b/agent-task/m-control-plane-edge-wire-baseline/04_status_snapshot/PLAN-cloud-G08.md @@ -0,0 +1,380 @@ + + +# Control Plane-Edge Status Snapshot Plan + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G08.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현입니다. 구현 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 review stub에 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고하세요. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review 스킬 책임입니다. + +구현 중 사용자만 결정할 수 있는 범위 변경, 사용자 소유 외부 환경/secret, 또는 현재 계획과 충돌하는 요구가 발견되면 `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션에 정확한 결정 항목과 근거를 기록하고 중단하세요. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해결할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니라 일반 후속 작업입니다. + +## 배경 + +현 마일스톤의 3번째 Epic에는 Control Plane이 Edge-owned 상태 snapshot을 요청하고 응답받는 기능이 남아 있습니다. 현재 Control Plane-Edge wire는 hello/enrollment와 registry 조회까지는 갖췄지만, Edge 쪽 node registry 상태를 Control Plane이 wire request/response로 읽는 계약은 없습니다. 이 작업은 status snapshot의 최소 proto 계약과 양방향 handler를 추가하되, Control Plane이 Node를 직접 보거나 스케줄링하지 않는 경계를 유지합니다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사유가 생기면 active `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션을 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 채웁니다. code-review 스킬이 그 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정합니다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md` +- Task ids: + - `edge-status`: Control Plane은 Edge에 상태 snapshot을 요청하고 Edge-owned 상태를 응답받을 수 있다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `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/rules.md` +- `agent-ops/rules/private/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/rules/project/domain/control-plane/rules.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/project/domain/platform-common/rules.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-ops/skills/common/router.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/update-test/SKILL.md` +- `agent-ops/skills/common/update-roadmap/SKILL.md` +- `agent-ops/skills/common/code-review/SKILL.md` +- `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` +- `agent-ops/skills/project/e2e-smoke/SKILL.md` +- `agent-test/local/rules.md` +- `agent-test/local/control-plane-smoke.md` +- `agent-test/local/edge-smoke.md` +- `agent-test/local/platform-common-smoke.md` +- `agent-test/local/testing-smoke.md` +- `proto/iop/control.proto` +- `proto/iop/runtime.proto` +- `proto/iop/node.proto` +- `apps/control-plane/internal/wire/edge.go` +- `apps/control-plane/internal/wire/wire.go` +- `apps/control-plane/internal/wire/edge_registry.go` +- `apps/control-plane/internal/wire/edge_server.go` +- `apps/control-plane/internal/wire/edge_test.go` +- `apps/control-plane/internal/wire/edge_server_test.go` +- `apps/control-plane/internal/wire/client.go` +- `apps/control-plane/internal/wire/client_test.go` +- `apps/control-plane/cmd/control-plane/main.go` +- `apps/control-plane/cmd/control-plane/main_test.go` +- `apps/edge/internal/controlplane/connector.go` +- `apps/edge/internal/controlplane/connector_test.go` +- `apps/edge/internal/controlplane/heartbeat_test.go` +- `apps/edge/internal/controlplane/helpers_test.go` +- `apps/edge/internal/service/service.go` +- `apps/edge/internal/service/service_test.go` +- `apps/edge/internal/events/bus.go` +- `apps/edge/internal/events/bus_test.go` +- `apps/edge/internal/bootstrap/runtime.go` +- `apps/edge/internal/bootstrap/runtime_test.go` +- `apps/edge/internal/node/registry.go` +- `apps/edge/internal/node/registry_test.go` +- `scripts/e2e-control-plane-edge-wire.sh` +- `Makefile` +- `go.mod` + +### 테스트 환경 규칙 + +- `test_env=local`로 판단했고 `agent-test/local/rules.md`를 읽었습니다. +- 매칭 profile은 `control-plane-smoke`, `edge-smoke`, `platform-common-smoke`, `testing-smoke`입니다. +- 적용 명령은 `make proto`, `go test ./apps/control-plane/...`, `go test ./apps/edge/...`, `go test ./packages/... ./proto/gen/...`, `go test ./...`, `make test-control-plane-edge-wire`입니다. +- `control-plane-smoke`는 이번 작업 직전 `make test-control-plane-edge-wire` 기준을 명시하도록 갱신되었고 다시 읽었습니다. +- profile에 `<확인 필요>` 값은 없었습니다. Docker compose health/manual full-cycle 항목은 실제 환경이 필요하므로 이 계획의 필수 자동 검증은 Go 테스트와 local wire smoke에 둡니다. + +### 테스트 커버리지 공백 + +- Status proto roundtrip: 현재 `EdgeHelloRequest`/`EdgeHelloResponse`만 parser roundtrip 테스트가 있고 status request/response 테스트가 없습니다. +- Control Plane status 요청: 현재 `EdgeServer`는 hello를 받고 registry에 연결 상태만 저장하며, 연결된 Edge에 typed request를 보내는 테스트가 없습니다. +- Edge status 응답: 현재 `Connector`는 hello만 보내고 Control Plane request listener가 없으며, `service.ListNodeSnapshots()`로 응답하는 테스트가 없습니다. +- Boundary 검증: 현재 service DTO (`NodeSnapshot`) 테스트는 있지만 Control Plane connector가 이 표면을 사용한다는 테스트가 없습니다. + +### 심볼 참조 + +- renamed/removed symbol: none. 새 proto message와 새 handler/API를 추가하는 작업입니다. + +### 분할 판단 + +- split decision policy를 plan 파일 선택 전에 평가했습니다. +- 공유 task group은 `m-control-plane-edge-wire-baseline`입니다. +- `04_status_snapshot`: proto/status request-response foundation입니다. 다른 작업이 이 wire/parser/connector foundation에 의존합니다. +- `05+04_event_relay_boundary`: node lifecycle event relay와 service/events boundary 검증입니다. `04_status_snapshot`의 parser/connector 구조가 완료된 뒤 진행합니다. +- protocol/schema, Control Plane wire, Edge connector, service boundary가 동시에 걸리므로 단일 plan으로 묶지 않습니다. + +### 범위 결정 근거 + +- Control Plane이 Node에 직접 연결하거나 직접 스케줄링하는 계약은 만들지 않습니다. +- Edge 내부 registry map을 Control Plane에 복제하지 않고, Edge가 응답하는 snapshot DTO만 Control Plane이 관찰합니다. +- Flutter client, durable DB 저장, 권한/정책/감사, run dispatch/command 실행 제어는 제외합니다. +- `scripts/e2e-control-plane-edge-wire.sh`는 이번 status request/response의 필수 구현 파일이 아니며, 기존 local smoke는 회귀 검증으로만 사용합니다. + +### 빌드 등급 + +- build lane: `cloud-G08`, review lane: `cloud-G08`. +- 근거: protobuf 계약과 양쪽 앱의 proto-socket request/response handler를 바꾸는 cross-domain protocol 작업이며, 테스트는 가능하지만 API/경계 회귀 위험이 있습니다. + +## 구현 체크리스트 + +- [ ] `proto/iop/control.proto`에 Control Plane-Edge status request/response와 node snapshot message를 추가하고 `make proto`로 Go 생성물을 갱신한다. +- [ ] Control Plane `wire.EdgeServer`가 connected Edge client에 status request를 보내고 response를 받을 수 있도록 client tracking과 parser/test를 추가한다. +- [ ] Edge `controlplane.Connector`가 Control Plane status request를 수신하면 `apps/edge/internal/service`의 node snapshot 표면으로 응답하도록 option과 bootstrap wiring을 추가한다. +- [ ] Status proto roundtrip, Control Plane request/response, Edge service-backed response, bootstrap wiring 테스트를 추가하고 대상 Go 테스트를 통과시킨다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## STATUS-1 Proto Status Contract + +### 문제 + +`proto/iop/control.proto:57`-`63`에는 Edge enrollment 응답까지만 있고, Control Plane이 Edge-owned 상태 snapshot을 요청하는 message가 없습니다. 기존 `proto/iop/node.proto:7`-`17`의 `NodeInfo`는 legacy placeholder라서 새 Control Plane-Edge status 계약에 그대로 쓰면 Node 직접 관찰처럼 보일 수 있습니다. + +현재 코드: + +```proto +// proto/iop/control.proto:57 +message EdgeHelloResponse { + bool accepted = 1; + string protocol = 2; + int64 server_time_unix_nano = 3; + string message = 4; + string reason = 5; +} +``` + +### 해결 방법 + +`control.proto`에 Control Plane-Edge 전용 status message를 추가합니다. node snapshot은 Edge service가 제공하는 surface-neutral DTO만 담고, address/token/scheduling 필드는 넣지 않습니다. + +예상 형태: + +```proto +message EdgeStatusRequest { + string request_id = 1; +} + +message EdgeNodeSnapshot { + string node_id = 1; + string alias = 2; + string label = 3; + bool connected = 4; +} + +message EdgeStatusResponse { + string request_id = 1; + string edge_id = 2; + string edge_name = 3; + int64 observed_time_unix_nano = 4; + repeated EdgeNodeSnapshot nodes = 5; + map metadata = 6; + string error = 7; +} +``` + +### 수정 파일 및 체크리스트 + +- [ ] `proto/iop/control.proto`: 새 message 추가, field number 안정화. +- [ ] `proto/gen/iop/control.pb.go`: `make proto`로만 갱신. +- [ ] `apps/control-plane/internal/wire/edge.go`: status request/response parser 추가. +- [ ] `apps/edge/internal/controlplane/connector.go`: Edge 쪽 parser에 status request/response 추가. + +### 테스트 작성 + +- `apps/control-plane/internal/wire/edge_test.go`에 `TestEdgeParserMapStatusRoundTrip`을 추가합니다. +- assertion: `EdgeStatusRequest`, `EdgeStatusResponse`가 `EdgeParserMap()`에서 marshal/parse roundtrip되고, forbidden direct scheduling field가 status request에도 없음을 확인합니다. + +### 중간 검증 + +```bash +make proto +go test -count=1 ./apps/control-plane/internal/wire ./proto/gen/... +``` + +기대 결과: proto 생성이 성공하고 status parser/contract 테스트가 통과합니다. + +## STATUS-2 Control Plane Status Request Path + +### 문제 + +`apps/control-plane/internal/wire/edge_server.go:62`-`91`은 hello request listener만 등록하고, 연결된 Edge client를 status 요청에 사용할 수 있게 보관하지 않습니다. `apps/control-plane/internal/wire/edge_registry.go:12`-`24`도 외부 상태 snapshot만 보관하고 active client 접근 표면이 없습니다. + +현재 코드: + +```go +// apps/control-plane/internal/wire/edge_server.go:62 +proto_socket.AddRequestListenerTyped(&client.Communicator, func(req *iop.EdgeHelloRequest) (*iop.EdgeHelloResponse, error) { + now := time.Now() + if req.GetEdgeId() == "" { + // ... + } + connToken := registry.MarkConnected(req, now) + // ... + return &iop.EdgeHelloResponse{Accepted: true, Protocol: Protocol}, nil +}) +``` + +### 해결 방법 + +`EdgeServer`가 hello acceptance 이후 active `TcpClient`를 edge id/token 기준으로 추적하고, `RequestStatus(ctx, edgeID string, timeout time.Duration) (*iop.EdgeStatusResponse, error)`를 제공합니다. disconnect가 current token일 때만 registry와 active client map을 함께 정리합니다. + +예상 형태: + +```go +func (s *EdgeServer) RequestStatus(edgeID string, timeout time.Duration) (*iop.EdgeStatusResponse, error) { + client, ok := s.activeClient(edgeID) + if !ok { + return nil, fmt.Errorf("edge %q is not connected", edgeID) + } + return proto_socket.SendRequestTyped[*iop.EdgeStatusRequest, *iop.EdgeStatusResponse]( + &client.Communicator, + &iop.EdgeStatusRequest{RequestId: fmt.Sprintf("edge-status-%d", time.Now().UnixNano())}, + timeout, + ) +} +``` + +### 수정 파일 및 체크리스트 + +- [ ] `apps/control-plane/internal/wire/edge_server.go`: active client map, status request method, stale disconnect cleanup 추가. +- [ ] `apps/control-plane/internal/wire/edge_registry.go`: disconnect 갱신 성공 여부를 반환하거나 EdgeServer가 stale cleanup을 판정할 수 있는 helper 추가. +- [ ] `apps/control-plane/internal/wire/edge_server_test.go`: connected Edge에 status request를 보내는 통합 테스트 추가. + +### 테스트 작성 + +- `TestEdgeServerRequestsStatusFromConnectedEdge`를 추가합니다. +- test Edge client에 `EdgeStatusRequest` listener를 등록하고 hello 후 `server.RequestStatus("edge-dgx-group", 2*time.Second)`가 node snapshot response를 받는지 확인합니다. +- stale disconnect 기존 테스트가 active client cleanup 회귀를 막도록 유지합니다. + +### 중간 검증 + +```bash +go test -count=1 ./apps/control-plane/internal/wire +``` + +기대 결과: hello/disconnect 기존 테스트와 status request 통합 테스트가 모두 통과합니다. + +## STATUS-3 Edge Service-Backed Status Response + +### 문제 + +`apps/edge/internal/controlplane/connector.go:186`-`193`은 Edge hello만 전송하고, `cpParserMap()`은 `EdgeHelloResponse`만 파싱합니다. `apps/edge/internal/bootstrap/runtime.go:64`-`68`도 connector를 만들 때 `Service`를 전달하지 않아 Control Plane request를 Edge-owned status 표면으로 응답할 방법이 없습니다. + +현재 코드: + +```go +// apps/edge/internal/controlplane/connector.go:186 +resp, err := toki.SendRequestTyped[*iop.EdgeHelloRequest, *iop.EdgeHelloResponse]( + &cl.Communicator, + &iop.EdgeHelloRequest{ + EdgeId: c.edgeInfo.ID, + EdgeName: c.edgeInfo.Name, + Version: c.version, + Capabilities: []string{"run", "node-registry"}, + }, + helloTimeout, +) +``` + +### 해결 방법 + +Connector에 optional dependency를 추가하되 기존 tests/call sites가 크게 깨지지 않도록 functional option을 사용합니다. Bootstrap에서는 `WithStatusProvider(svc)`를 전달하고, status listener는 `Service.ListNodeSnapshots()` 결과만 proto response로 변환합니다. + +예상 형태: + +```go +type StatusProvider interface { + ListNodeSnapshots() []edgeservice.NodeSnapshot +} + +func WithStatusProvider(provider StatusProvider) Option { + return func(c *Connector) { c.statusProvider = provider } +} +``` + +### 수정 파일 및 체크리스트 + +- [ ] `apps/edge/internal/controlplane/connector.go`: option, status provider, parser, request listener 추가. +- [ ] `apps/edge/internal/bootstrap/runtime.go`: `NewConnector(..., WithStatusProvider(svc))` wiring. +- [ ] `apps/edge/internal/controlplane/connector_test.go`: fake Control Plane이 status request를 보내고 service-backed node snapshot을 받는 테스트 추가. +- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: runtime이 status provider를 연결한다는 관찰 가능한 테스트 추가. + +### 테스트 작성 + +- `TestConnectorRespondsToStatusRequestFromService`를 추가합니다. +- `edgenode.Registry`에 fake node를 등록한 `edgeservice.Service`를 status provider로 전달하고, fake CP server가 hello 후 `EdgeStatusRequest`를 보내 `EdgeStatusResponse.Nodes`를 확인합니다. +- provider가 없는 경우 error response 또는 empty response가 명확한지 테스트합니다. + +### 중간 검증 + +```bash +go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/service +``` + +기대 결과: Edge connector, bootstrap wiring, service DTO tests가 통과합니다. + +## STATUS-4 Focused Regression And Smoke + +### 문제 + +이 작업은 protobuf 계약, Control Plane wire server, Edge connector, bootstrap wiring을 함께 건드립니다. `agent-test/local/control-plane-smoke.md:52`-`55`는 Control Plane-Edge TCP wire 변경 시 `make test-control-plane-edge-wire` 실행을 요구합니다. + +### 해결 방법 + +대상 package test를 먼저 통과시킨 뒤 전체 Go 회귀와 local wire smoke를 실행합니다. `make test-control-plane-edge-wire`는 status request 자체를 검증하지는 않지만, hello/connect/disconnect lifecycle이 깨지지 않았는지 확인합니다. + +### 수정 파일 및 체크리스트 + +- [ ] `Makefile`/`scripts/e2e-control-plane-edge-wire.sh`는 status 구현을 위해 수정하지 않습니다. +- [ ] package focused tests를 실행합니다. +- [ ] 전체 Go test와 local wire smoke를 실행합니다. + +### 테스트 작성 + +- 새 테스트는 STATUS-1~3 항목의 Go 테스트에 둡니다. +- smoke script는 이번 작업에서 수정하지 않습니다. + +### 중간 검증 + +```bash +go test -count=1 ./apps/control-plane/... ./apps/edge/... ./packages/... ./proto/gen/... +make test-control-plane-edge-wire +``` + +기대 결과: package test와 existing Control Plane-Edge smoke가 통과합니다. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `proto/iop/control.proto` | STATUS-1 | +| `proto/gen/iop/control.pb.go` | STATUS-1 | +| `apps/control-plane/internal/wire/edge.go` | STATUS-1 | +| `apps/control-plane/internal/wire/edge_test.go` | STATUS-1 | +| `apps/control-plane/internal/wire/edge_server.go` | STATUS-2 | +| `apps/control-plane/internal/wire/edge_registry.go` | STATUS-2 | +| `apps/control-plane/internal/wire/edge_server_test.go` | STATUS-2 | +| `apps/edge/internal/controlplane/connector.go` | STATUS-3 | +| `apps/edge/internal/controlplane/connector_test.go` | STATUS-3 | +| `apps/edge/internal/bootstrap/runtime.go` | STATUS-3 | +| `apps/edge/internal/bootstrap/runtime_test.go` | STATUS-3 | + +## 의존 관계 및 구현 순서 + +이 subtask는 runtime dependency가 없는 `04_status_snapshot`입니다. 먼저 proto 계약과 parser를 추가한 뒤 Control Plane request path, Edge response path, regression/smoke 순서로 진행합니다. + +## 최종 검증 + +```bash +make proto +go test -count=1 ./apps/control-plane/internal/wire +go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/service +go test -count=1 ./apps/control-plane/... ./apps/edge/... ./packages/... ./proto/gen/... +go test ./... +make test-control-plane-edge-wire +``` + +기대 결과: 모든 명령이 성공하고, `make test-control-plane-edge-wire` 출력에 `CP: hello accepted`, `Edge: connected to control plane`, `CP: edge disconnected`, `Control Plane-Edge wire smoke PASSED.`가 포함됩니다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-control-plane-edge-wire-baseline/05+04_event_relay_boundary/CODE_REVIEW-cloud-G08.md b/agent-task/m-control-plane-edge-wire-baseline/05+04_event_relay_boundary/CODE_REVIEW-cloud-G08.md new file mode 100644 index 0000000..4b3c63f --- /dev/null +++ b/agent-task/m-control-plane-edge-wire-baseline/05+04_event_relay_boundary/CODE_REVIEW-cloud-G08.md @@ -0,0 +1,204 @@ + + +# Code Review Reference - EVENTS + +> **[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/05+04_event_relay_boundary, plan=0, tag=EVENTS + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md` +- Task ids: + - `event-relay`: Edge의 node lifecycle event가 Control Plane까지 relay되는 최소 경로가 있다. + - `service-boundary`: Edge wire handler는 node registry, run dispatch, command request 로직을 복제하지 않고 `apps/edge/internal/service`와 `events.Bus` 같은 표면 중립 경계를 사용한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[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/05+04_event_relay_boundary/`로 이동한다. 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` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [EVENTS-1] Dependency Gate And Parser | [ ] | +| [EVENTS-2] Control Plane Receive And Recent Event Store | [ ] | +| [EVENTS-3] Edge Events.Bus Relay | [ ] | +| [EVENTS-4] Observation Surface And Boundary Guard | [ ] | +| [EVENTS-5] Regression And Smoke | [ ] | + +## 구현 체크리스트 + +- [ ] `04_status_snapshot` subtask가 PASS 완료된 뒤에만 시작한다. runtime이 이 subtask를 시작했다면 의존성이 충족된 것으로 보고, 수동 실행이면 predecessor PASS evidence 또는 runtime 완료 이벤트 경로를 review stub에 기록한다. +- [ ] Control Plane `EdgeParserMap`/`EdgeServer`가 `EdgeNodeEvent`를 수신해 edge id와 함께 bounded recent event store에 기록하도록 구현한다. +- [ ] Edge `controlplane.Connector`가 `events.Bus.SubscribeAllNodes()`를 통해 node lifecycle event를 Control Plane으로 relay하도록 option과 bootstrap wiring을 추가한다. +- [ ] Control Plane에서 relayed node lifecycle event를 조회할 최소 observation surface와 tests를 추가하되, Edge internal registry/run/command 로직을 복제하지 않는다. +- [ ] Event relay, recent event snapshot, service/events boundary 회귀 테스트를 추가하고 대상 Go 테스트 및 기존 local wire smoke를 통과시킨다. +- [ ] 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/05+04_event_relay_boundary/`를 `agent-task/archive/YYYY/MM/m-control-plane-edge-wire-baseline/05+04_event_relay_boundary/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-control-plane-edge-wire-baseline`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `04_status_snapshot`의 predecessor PASS evidence 또는 runtime 완료 이벤트 경로가 없는 상태에서 코드 구현이 진행되지 않았는지 확인한다. +- Control Plane receive path가 connection의 `edgeID` context에 event를 묶고, hello 전 event를 무시하는지 확인한다. +- Edge connector relay가 `events.Bus.SubscribeAllNodes()`를 사용하고 node registry/run dispatch/command request 로직을 직접 복제하지 않는지 확인한다. +- relayed event copy/enrichment가 원본 bus event를 mutate하지 않는지 확인한다. +- `/edges/{edge_id}/events`는 Control Plane에 도착한 event 조회만 제공하며 Edge internals를 직접 재구성하지 않는지 확인한다. +- `-race` 검증과 review stub의 실제 stdout/stderr가 신뢰 가능한지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. + +### EVENTS-1 중간 검증 + +```text +$ go test -count=1 ./apps/control-plane/internal/wire +(output) +``` + +### EVENTS-2 중간 검증 + +```text +$ go test -count=1 ./apps/control-plane/internal/wire +(output) +``` + +### EVENTS-3 중간 검증 + +```text +$ go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/events +(output) +``` + +### EVENTS-4 중간 검증 + +```text +$ go test -count=1 ./apps/control-plane/cmd/control-plane ./apps/control-plane/internal/wire +(output) + +$ rg --sort path -n "apps/edge/internal/node|NodeCommandRequest|RunRequest" apps/edge/internal/controlplane +(output) +``` + +### EVENTS-5 중간 검증 + +```text +$ go test -race -count=1 ./apps/edge/internal/controlplane ./apps/control-plane/internal/wire +(output) + +$ go test ./... +(output) + +$ make test-control-plane-edge-wire +(output) +``` + +### 최종 검증 + +```text +$ go test -count=1 ./apps/control-plane/internal/wire +(output) + +$ go test -count=1 ./apps/control-plane/cmd/control-plane +(output) + +$ go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/events +(output) + +$ go test -race -count=1 ./apps/edge/internal/controlplane ./apps/control-plane/internal/wire +(output) + +$ go test ./... +(output) + +$ make test-control-plane-edge-wire +(output) + +$ rg --sort path -n "apps/edge/internal/node|NodeCommandRequest|RunRequest" apps/edge/internal/controlplane +(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. + +## 섹션 소유권 + +| 섹션 | 소유자 | 설명 | +|------|--------|------| +| 헤더 주석, 개요, 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 | +| Roadmap Targets | plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 수정하지 않음 | +| 구현 항목별 완료 여부 | 구현 에이전트 | `[ ]` -> `[x]` 체크 | +| 구현 체크리스트 | 구현 에이전트 | `[ ]` -> `[x]` 체크, 마지막 항목 필수 | +| 코드리뷰 전용 체크리스트 | 리뷰 에이전트 | 구현 에이전트가 수정하지 않음 | +| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트 | 실제 내용으로 교체 | +| 사용자 리뷰 요청 | 구현 에이전트 | 기본 `상태: 없음` 유지 또는 차단 근거 기록 | +| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 리뷰 초점 | +| 검증 결과 | 구현 에이전트 | 실제 stdout/stderr 기록 | +| 코드리뷰 결과 | 리뷰 에이전트 | 스텁에 포함하지 않음 | diff --git a/agent-task/m-control-plane-edge-wire-baseline/05+04_event_relay_boundary/PLAN-cloud-G08.md b/agent-task/m-control-plane-edge-wire-baseline/05+04_event_relay_boundary/PLAN-cloud-G08.md new file mode 100644 index 0000000..a836d11 --- /dev/null +++ b/agent-task/m-control-plane-edge-wire-baseline/05+04_event_relay_boundary/PLAN-cloud-G08.md @@ -0,0 +1,404 @@ + + +# Control Plane-Edge Event Relay And Boundary Plan + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G08.md`의 구현 에이전트 소유 섹션을 채우는 것까지가 구현입니다. 구현 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 review stub에 기록한 뒤 active 파일을 그대로 둔 채 리뷰 준비를 보고하세요. 최종 판정, 로그 rename, `complete.log`, archive 이동은 code-review 스킬 책임입니다. + +구현 중 사용자만 결정할 수 있는 범위 변경, 사용자 소유 외부 환경/secret, 또는 현재 계획과 충돌하는 요구가 발견되면 `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션에 정확한 결정 항목과 근거를 기록하고 중단하세요. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해결할 수 있는 검증 증거 공백은 사용자 리뷰 요청이 아니라 일반 후속 작업입니다. + +## 배경 + +현 마일스톤의 3-4번째 Epic에는 Edge의 node lifecycle event가 Control Plane까지 도달하고, Edge wire handler가 Edge 내부 구현을 복제하지 않는 boundary가 남아 있습니다. Status snapshot 작업이 끝나면 Control Plane-Edge connector는 양방향 message를 다룰 수 있으므로, 이 작업은 그 구조 위에 `events.Bus` 기반 live relay와 Control Plane의 최소 recent-event 관찰 표면을 붙입니다. + +## 사용자 리뷰 요청 흐름 + +구현 중 차단 사유가 생기면 active `CODE_REVIEW-cloud-G08.md`의 `사용자 리뷰 요청` 섹션을 `agent-ops/skills/common/_templates/implementation-user-review-request-section.md` 형식으로 채웁니다. code-review 스킬이 그 요청을 검증하고 실제 `USER_REVIEW.md` 작성 여부를 결정합니다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/control-plane-portal-ops/milestones/control-plane-edge-wire-baseline.md` +- Task ids: + - `event-relay`: Edge의 node lifecycle event가 Control Plane까지 relay되는 최소 경로가 있다. + - `service-boundary`: Edge wire handler는 node registry, run dispatch, command request 로직을 복제하지 않고 `apps/edge/internal/service`와 `events.Bus` 같은 표면 중립 경계를 사용한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `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/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/rules/project/domain/control-plane/rules.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/project/domain/platform-common/rules.md` +- `agent-ops/rules/project/domain/testing/rules.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-ops/skills/common/code-review/SKILL.md` +- `agent-ops/skills/project/e2e-smoke/SKILL.md` +- `agent-test/local/rules.md` +- `agent-test/local/control-plane-smoke.md` +- `agent-test/local/edge-smoke.md` +- `agent-test/local/platform-common-smoke.md` +- `agent-test/local/testing-smoke.md` +- `proto/iop/control.proto` +- `proto/iop/runtime.proto` +- `proto/iop/node.proto` +- `apps/control-plane/internal/wire/edge.go` +- `apps/control-plane/internal/wire/edge_registry.go` +- `apps/control-plane/internal/wire/edge_server.go` +- `apps/control-plane/internal/wire/edge_test.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/edge/internal/controlplane/connector.go` +- `apps/edge/internal/controlplane/connector_test.go` +- `apps/edge/internal/service/service.go` +- `apps/edge/internal/service/service_test.go` +- `apps/edge/internal/events/bus.go` +- `apps/edge/internal/events/bus_test.go` +- `apps/edge/internal/bootstrap/runtime.go` +- `apps/edge/internal/bootstrap/runtime_test.go` +- `apps/edge/internal/node/registry.go` +- `apps/edge/internal/node/registry_test.go` +- `scripts/e2e-control-plane-edge-wire.sh` +- `Makefile` +- `go.mod` + +### 테스트 환경 규칙 + +- `test_env=local`로 판단했고 `agent-test/local/rules.md`를 읽었습니다. +- 매칭 profile은 `control-plane-smoke`, `edge-smoke`, `platform-common-smoke`, `testing-smoke`입니다. +- 적용 명령은 `go test ./apps/control-plane/...`, `go test ./apps/edge/...`, `go test ./...`, `make test-control-plane-edge-wire`입니다. +- 이 작업은 기존 `runtime.proto`의 `EdgeNodeEvent`를 사용하므로 새 protobuf 원본 변경이 없다면 `make proto`는 필수가 아닙니다. proto를 바꾸는 선택을 했다면 `platform-common-smoke`에 따라 `make proto`를 추가 실행합니다. +- `make test-control-plane-edge-wire`는 hello/connect/disconnect smoke이며 event relay 자체를 검증하지 않습니다. event relay 검증은 focused Go integration test에 둡니다. + +### 테스트 커버리지 공백 + +- Edge event relay: `events.Bus.SubscribeAllNodes()`와 connector를 연결하는 테스트가 없습니다. +- Control Plane receive path: `EdgeServer`가 `EdgeNodeEvent`를 수신해 edge id와 함께 저장하는 테스트가 없습니다. +- Recent event observation: Control Plane HTTP `/edges`는 registry state만 보여주며 relayed node lifecycle event를 조회하는 표면이 없습니다. +- Boundary: connector가 `events.Bus`와 `Service` 같은 표면 중립 dependency만 쓰고 node registry/client internals를 직접 복제하지 않는 회귀 테스트가 없습니다. + +### 심볼 참조 + +- renamed/removed symbol: none. 새 event relay method와 registry event snapshot API를 추가하는 작업입니다. + +### 분할 판단 + +- split decision policy를 plan 파일 선택 전에 평가했습니다. +- 공유 task group은 `m-control-plane-edge-wire-baseline`입니다. +- `04_status_snapshot`은 status request/response foundation이며 이 작업의 선행 조건입니다. +- `05+04_event_relay_boundary`는 `04_status_snapshot` 완료 후 같은 connector/parser 구조에 event relay를 추가합니다. +- event fanout, Control Plane receive registry, HTTP observation, boundary 검증은 status foundation과 다른 위험 profile이므로 별도 subtask로 분리합니다. + +### 범위 결정 근거 + +- Control Plane DB 저장, durable event history, ack/retry delivery, backpressure policy, multi-edge federation은 제외합니다. +- Run event stream relay는 제외하고 node lifecycle/control event인 `EdgeNodeEvent`만 다룹니다. +- Edge command/run dispatch/status command 로직은 새로 복제하지 않고, 필요한 상태는 `events.Bus`와 status provider가 제공한 DTO에 한정합니다. +- 기본 local smoke script는 유지합니다. event relay success는 deterministic Go test로 검증합니다. + +### 빌드 등급 + +- build lane: `cloud-G08`, review lane: `cloud-G08`. +- 근거: event fanout/concurrency와 Control Plane-Edge protocol handler를 함께 바꾸는 cross-domain 작업이며, status subtask에 의존하는 boundary 검증이 필요합니다. + +## 구현 체크리스트 + +- [ ] `04_status_snapshot` subtask가 PASS 완료된 뒤에만 시작한다. runtime이 이 subtask를 시작했다면 의존성이 충족된 것으로 보고, 수동 실행이면 predecessor PASS evidence 또는 runtime 완료 이벤트 경로를 review stub에 기록한다. +- [ ] Control Plane `EdgeParserMap`/`EdgeServer`가 `EdgeNodeEvent`를 수신해 edge id와 함께 bounded recent event store에 기록하도록 구현한다. +- [ ] Edge `controlplane.Connector`가 `events.Bus.SubscribeAllNodes()`를 통해 node lifecycle event를 Control Plane으로 relay하도록 option과 bootstrap wiring을 추가한다. +- [ ] Control Plane에서 relayed node lifecycle event를 조회할 최소 observation surface와 tests를 추가하되, Edge internal registry/run/command 로직을 복제하지 않는다. +- [ ] Event relay, recent event snapshot, service/events boundary 회귀 테스트를 추가하고 대상 Go 테스트 및 기존 local wire smoke를 통과시킨다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## EVENTS-1 Dependency Gate And Parser + +### 문제 + +이 subtask는 `05+04_event_relay_boundary` 이름으로 `04_status_snapshot`에 의존합니다. 또한 `apps/control-plane/internal/wire/edge.go:13`-`21`의 `EdgeParserMap()`은 현재 hello message만 파싱하므로 Edge가 `runtime.proto`의 `EdgeNodeEvent`를 보내도 Control Plane이 받을 수 없습니다. + +현재 코드: + +```go +// apps/control-plane/internal/wire/edge.go:13 +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) + }, +} +``` + +### 해결 방법 + +구현 시작 시 runtime dependency가 충족되었는지 확인합니다. runtime이 이 subtask를 시작했다면 `04_status_snapshot`이 PASS된 것으로 보고, 수동 실행이면 predecessor PASS evidence 또는 runtime 완료 이벤트 경로를 review stub에 기록합니다. 완료 전이면 code changes를 하지 말고 dependency 대기 상태를 기록합니다. 완료되어 있으면 `EdgeParserMap()`에 `iop.EdgeNodeEvent` parser를 추가하고 roundtrip test를 작성합니다. + +### 수정 파일 및 체크리스트 + +- [ ] `04_status_snapshot` predecessor PASS evidence 또는 runtime 완료 이벤트 경로: 선행 완료 확인. +- [ ] `apps/control-plane/internal/wire/edge.go`: `EdgeNodeEvent` parser 추가. +- [ ] `apps/control-plane/internal/wire/edge_test.go`: parser roundtrip 추가. + +### 테스트 작성 + +- `TestEdgeParserMapNodeEventRoundTrip`를 추가합니다. +- assertion: `event_id`, `type`, `node_id`, `alias`, `metadata`, `timestamp`가 roundtrip됩니다. + +### 중간 검증 + +```bash +go test -count=1 ./apps/control-plane/internal/wire +``` + +기대 결과: review stub에 선행 PASS evidence가 기록되고 parser tests가 통과합니다. + +## EVENTS-2 Control Plane Receive And Recent Event Store + +### 문제 + +`apps/control-plane/internal/wire/edge_server.go:62`-`107`은 hello와 disconnect만 처리하고, 연결 context의 `edgeID`에 묶인 `EdgeNodeEvent` listener가 없습니다. `apps/control-plane/internal/wire/edge_registry.go:12`-`24`의 state에는 lifecycle event 보관 표면도 없습니다. + +현재 코드: + +```go +// apps/control-plane/internal/wire/edge_server.go:93 +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) +}) +``` + +### 해결 방법 + +hello로 식별된 connection context에서 `EdgeNodeEvent` listener를 등록하고, registry에 bounded recent events를 edge id와 함께 기록합니다. timestamp가 비어 있으면 Control Plane receive time을 채우고, event 자체는 copy/clone해서 caller mutation이 registry를 오염시키지 않게 합니다. + +예상 형태: + +```go +proto_socket.AddListenerTyped[*iop.EdgeNodeEvent](&client.Communicator, func(event *iop.EdgeNodeEvent) { + idMu.Lock() + id := edgeID + idMu.Unlock() + if id == "" { + return + } + registry.RecordNodeEvent(id, event, time.Now()) +}) +``` + +### 수정 파일 및 체크리스트 + +- [ ] `apps/control-plane/internal/wire/edge_registry.go`: `EdgeNodeEventRecord`, `RecordNodeEvent`, `NodeEvents(edgeID, nodeID string)` 추가. +- [ ] `apps/control-plane/internal/wire/edge_server.go`: `EdgeNodeEvent` listener 추가. +- [ ] `apps/control-plane/internal/wire/edge_server_test.go`: relayed event 수신/저장 테스트 추가. + +### 테스트 작성 + +- `TestEdgeServerRecordsNodeLifecycleEventFromEdge`를 추가합니다. +- hello 전 event는 무시하고, hello 후 `node.connected` event는 `edgeID` 아래에 저장되는지 확인합니다. +- returned event slice를 mutate해도 registry 내부가 바뀌지 않는지 확인합니다. + +### 중간 검증 + +```bash +go test -count=1 ./apps/control-plane/internal/wire +``` + +기대 결과: event receive/store tests와 기존 hello/disconnect tests가 통과합니다. + +## EVENTS-3 Edge Events.Bus Relay + +### 문제 + +`apps/edge/internal/events/bus.go:123`-`138`은 all-node subscriber를 제공하고, `apps/edge/internal/bootstrap/runtime.go:85`-`87`은 transport node events를 bus에 publish합니다. 하지만 `apps/edge/internal/controlplane/connector.go:223`-`230`은 Control Plane connection이 열린 동안 disconnect를 기다릴 뿐, bus events를 Control Plane으로 보내지 않습니다. + +현재 코드: + +```go +// apps/edge/internal/bootstrap/runtime.go:85 +func (r *Runtime) wireHandlers() { + r.Server.SetRunEventHandler(r.EventBus.PublishRun) + r.Server.SetNodeEventHandler(r.EventBus.PublishNode) +} +``` + +### 해결 방법 + +Connector에 `WithNodeEventBus(bus *edgeevents.Bus)` option을 추가하고, accepted Control Plane connection lifetime 동안 `SubscribeAllNodes()`를 사용해 live node lifecycle events를 `TcpClient.Send(event)`로 relay합니다. Event metadata에 `edge_id`가 없으면 copy에만 채우고 원본 bus event는 mutate하지 않습니다. + +예상 형태: + +```go +func (c *Connector) startNodeEventRelay(ctx context.Context, cl *toki.TcpClient) func() { + if c.nodeEvents == nil { + return func() {} + } + events, unsubscribe := c.nodeEvents.SubscribeAllNodes(64) + go func() { + defer unsubscribe() + for { + select { + case <-ctx.Done(): + return + case event, ok := <-events: + if !ok { + return + } + _ = cl.Send(enrichEdgeMetadata(event, c.edgeInfo.ID)) + } + } + }() + return unsubscribe +} +``` + +### 수정 파일 및 체크리스트 + +- [ ] `apps/edge/internal/controlplane/connector.go`: node event bus option, relay loop, event copy/enrichment helper 추가. +- [ ] `apps/edge/internal/bootstrap/runtime.go`: `WithNodeEventBus(bus)` wiring. +- [ ] `apps/edge/internal/controlplane/connector_test.go`: fake Control Plane이 `EdgeNodeEvent`를 받는 테스트 추가. +- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: runtime wiring 회귀 테스트 추가. + +### 테스트 작성 + +- `TestConnectorRelaysNodeLifecycleEventsToControlPlane`를 추가합니다. +- fake CP server가 hello 후 `EdgeNodeEvent` listener를 등록하고, Edge `events.Bus.PublishNode()` 호출 후 event를 받는지 확인합니다. +- event copy에 edge metadata가 들어가고 원본 event metadata는 mutation되지 않는지 확인합니다. + +### 중간 검증 + +```bash +go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/events +``` + +기대 결과: connector relay, bootstrap wiring, events bus tests가 통과합니다. + +## EVENTS-4 Observation Surface And Boundary Guard + +### 문제 + +`apps/control-plane/cmd/control-plane/main.go:223`-`258`의 HTTP handlers는 `/edges`와 `/edges/{edge_id}` registry state만 반환합니다. Relayed lifecycle event를 Control Plane 운영 표면에서 확인할 최소 조회 경로가 없으면 relay가 제품 운영 surface로 이어졌는지 검증하기 어렵습니다. + +현재 코드: + +```go +// apps/control-plane/cmd/control-plane/main.go:241 +mux.HandleFunc("/edges/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + edgeID := strings.TrimPrefix(r.URL.Path, "/edges/") + // ... +}) +``` + +### 해결 방법 + +`GET /edges/{edge_id}/events`를 최소 observation surface로 추가하고, registry의 bounded recent node events를 JSON으로 반환합니다. 이 endpoint는 Control Plane에 도착한 event를 보여줄 뿐, Edge node registry나 command/run dispatch 로직을 재구현하지 않습니다. + +### 수정 파일 및 체크리스트 + +- [ ] `apps/control-plane/cmd/control-plane/main.go`: event view DTO와 `/edges/{edge_id}/events` handler 추가. +- [ ] `apps/control-plane/cmd/control-plane/main_test.go`: list/get/events handler tests 추가. +- [ ] `apps/edge/internal/controlplane/connector.go`: node registry/run dispatch/command request 직접 import가 없는지 유지. + +### 테스트 작성 + +- `TestEdgeRegistryHTTPHandlersListNodeEvents`를 추가합니다. +- registry에 edge와 node event를 seed하고 `/edges/edge-a/events`가 event id/type/node/alias/reason/timestamp/metadata를 반환하는지 확인합니다. +- `/edges/edge-a/events/extra`나 unknown edge는 404를 반환합니다. + +### 중간 검증 + +```bash +go test -count=1 ./apps/control-plane/cmd/control-plane ./apps/control-plane/internal/wire +rg --sort path -n "apps/edge/internal/node|NodeCommandRequest|RunRequest" apps/edge/internal/controlplane +``` + +기대 결과: HTTP tests가 통과하고, `apps/edge/internal/controlplane`에는 node internals 또는 command/run dispatch 복제 import가 없습니다. 단, proto type 이름이 test fixture에 필요한 경우는 계획 대비 변경 사항에 이유를 기록합니다. + +## EVENTS-5 Regression And Smoke + +### 문제 + +Event relay는 goroutine, disconnect lifetime, bounded buffers가 걸려 있어 unit 통과만으로 lifecycle 회귀를 놓칠 수 있습니다. `agent-test/local/control-plane-smoke.md:55`는 Control Plane-Edge TCP wire 변경 시 existing local smoke 실행을 요구합니다. + +### 해결 방법 + +focused tests에 `-race`를 포함해 relay loop/data race를 확인하고, 전체 Go 회귀 및 existing local Control Plane-Edge smoke를 실행합니다. 기존 smoke가 event 자체를 확인하지 않는다는 한계는 review stub에 남기고, event success는 focused integration tests 출력으로 증명합니다. + +### 수정 파일 및 체크리스트 + +- [ ] focused package tests를 실행합니다. +- [ ] race test를 relay 관련 package에 실행합니다. +- [ ] 전체 Go 회귀와 existing local wire smoke를 실행합니다. + +### 테스트 작성 + +- 새 테스트는 EVENTS-1~4 항목에 둡니다. +- 기존 smoke script 수정은 이 plan 범위 밖입니다. + +### 중간 검증 + +```bash +go test -race -count=1 ./apps/edge/internal/controlplane ./apps/control-plane/internal/wire +go test ./... +make test-control-plane-edge-wire +``` + +기대 결과: race/focused/full tests와 existing local wire smoke가 통과합니다. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/control-plane/internal/wire/edge.go` | EVENTS-1 | +| `apps/control-plane/internal/wire/edge_test.go` | EVENTS-1 | +| `apps/control-plane/internal/wire/edge_registry.go` | EVENTS-2 | +| `apps/control-plane/internal/wire/edge_server.go` | EVENTS-2 | +| `apps/control-plane/internal/wire/edge_server_test.go` | EVENTS-2 | +| `apps/edge/internal/controlplane/connector.go` | EVENTS-3, EVENTS-4 | +| `apps/edge/internal/controlplane/connector_test.go` | EVENTS-3 | +| `apps/edge/internal/bootstrap/runtime.go` | EVENTS-3 | +| `apps/edge/internal/bootstrap/runtime_test.go` | EVENTS-3 | +| `apps/control-plane/cmd/control-plane/main.go` | EVENTS-4 | +| `apps/control-plane/cmd/control-plane/main_test.go` | EVENTS-4 | + +## 의존 관계 및 구현 순서 + +이 subtask directory는 `05+04_event_relay_boundary`이므로 runtime dependency source of truth는 `04_status_snapshot`입니다. runtime이 이 subtask를 시작했다면 선행 PASS가 충족된 것으로 보고, 수동 실행이면 predecessor PASS evidence 또는 runtime 완료 이벤트 경로를 먼저 기록합니다. 완료 후 EVENTS-1 -> EVENTS-2 -> EVENTS-3 -> EVENTS-4 -> EVENTS-5 순서로 진행합니다. + +## 최종 검증 + +```bash +go test -count=1 ./apps/control-plane/internal/wire +go test -count=1 ./apps/control-plane/cmd/control-plane +go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/events +go test -race -count=1 ./apps/edge/internal/controlplane ./apps/control-plane/internal/wire +go test ./... +make test-control-plane-edge-wire +rg --sort path -n "apps/edge/internal/node|NodeCommandRequest|RunRequest" apps/edge/internal/controlplane +``` + +기대 결과: 선행 PASS evidence가 review stub에 기록되고 모든 Go/race/smoke 명령이 성공합니다. 마지막 `rg`는 controlplane package가 Edge node internals 또는 run/command dispatch 로직을 직접 복제하지 않았다는 boundary check로 사용하며, 의도된 proto type 언급이 있으면 review stub에 근거를 기록합니다. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/control-plane/cmd/control-plane/main.go b/apps/control-plane/cmd/control-plane/main.go index f4a2827..741a9ec 100644 --- a/apps/control-plane/cmd/control-plane/main.go +++ b/apps/control-plane/cmd/control-plane/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "net/http" "net/url" @@ -177,6 +178,7 @@ func run(ctx context.Context, cfg controlPlaneConfig, logger *zap.Logger) error return fmt.Errorf("start edge wire server: %w", err) } defer func() { _ = edgeServer.Stop() }() + registerEdgeRegistryHandlers(mux, edgeServer.Registry()) server := &http.Server{ Addr: cfg.Server.Listen, @@ -204,6 +206,76 @@ func run(ctx context.Context, cfg controlPlaneConfig, logger *zap.Logger) error } } +type edgeRegistryResponse struct { + Edges []edgeRegistryView `json:"edges"` +} + +type edgeRegistryView struct { + EdgeID string `json:"edge_id"` + EdgeName string `json:"edge_name"` + Version string `json:"version"` + Capabilities []string `json:"capabilities"` + Protocol string `json:"protocol"` + Connected bool `json:"connected"` + LastSeen time.Time `json:"last_seen"` +} + +func registerEdgeRegistryHandlers(mux *http.ServeMux, registry *wire.EdgeRegistry) { + mux.HandleFunc("/edges", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/edges" { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + states := registry.Snapshots() + views := make([]edgeRegistryView, 0, len(states)) + for _, state := range states { + views = append(views, edgeRegistryViewFromState(state)) + } + writeJSON(w, http.StatusOK, edgeRegistryResponse{Edges: views}) + }) + mux.HandleFunc("/edges/", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + edgeID := strings.TrimPrefix(r.URL.Path, "/edges/") + if edgeID == "" || strings.Contains(edgeID, "/") { + http.NotFound(w, r) + return + } + state, ok := registry.Snapshot(edgeID) + if !ok { + http.NotFound(w, r) + return + } + writeJSON(w, http.StatusOK, edgeRegistryViewFromState(state)) + }) +} + +func edgeRegistryViewFromState(state wire.EdgeConnectionState) edgeRegistryView { + return edgeRegistryView{ + EdgeID: state.EdgeID, + EdgeName: state.EdgeName, + Version: state.Version, + Capabilities: append([]string(nil), state.Capabilities...), + Protocol: state.Protocol, + Connected: state.Connected, + LastSeen: state.LastSeen, + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + func databaseLogFields(rawURL string) []zap.Field { return serviceURLLogFields(rawURL) } diff --git a/apps/control-plane/cmd/control-plane/main_test.go b/apps/control-plane/cmd/control-plane/main_test.go index 2f7fcd2..bb0f2ff 100644 --- a/apps/control-plane/cmd/control-plane/main_test.go +++ b/apps/control-plane/cmd/control-plane/main_test.go @@ -1,11 +1,18 @@ package main import ( + "encoding/json" "fmt" + "net/http" + "net/http/httptest" "os" "path/filepath" "strings" "testing" + "time" + + "iop/apps/control-plane/internal/wire" + iop "iop/proto/gen/iop" ) func TestLoadConfigDatabaseDefaultsToLocal(t *testing.T) { @@ -230,3 +237,81 @@ server: t.Fatalf("wire_listen should stay from YAML: got %q", cfg.Server.WireListen) } } + +func TestEdgeRegistryHTTPHandlersListAndGetEdge(t *testing.T) { + registry := wire.NewEdgeRegistry() + at := time.Unix(1780142200, 123).UTC() + registry.MarkConnected(&iop.EdgeHelloRequest{ + EdgeId: "edge-b", + EdgeName: "Edge B", + Version: "1.2.3", + Capabilities: []string{"run", "node-registry"}, + }, at) + registry.MarkConnected(&iop.EdgeHelloRequest{ + EdgeId: "edge-a", + EdgeName: "Edge A", + Version: "1.0.0", + }, at.Add(time.Second)) + + mux := http.NewServeMux() + registerEdgeRegistryHandlers(mux, registry) + + listResp := httptest.NewRecorder() + mux.ServeHTTP(listResp, httptest.NewRequest(http.MethodGet, "/edges", nil)) + if listResp.Code != http.StatusOK { + t.Fatalf("GET /edges status=%d body=%s", listResp.Code, listResp.Body.String()) + } + var list edgeRegistryResponse + if err := json.Unmarshal(listResp.Body.Bytes(), &list); err != nil { + t.Fatalf("decode list response: %v", err) + } + if len(list.Edges) != 2 { + t.Fatalf("expected 2 edges, got %d", len(list.Edges)) + } + if list.Edges[0].EdgeID != "edge-a" || list.Edges[1].EdgeID != "edge-b" { + t.Fatalf("expected stable edge_id ordering, got %+v", list.Edges) + } + if list.Edges[1].Protocol != wire.Protocol { + t.Fatalf("protocol: got %q want %q", list.Edges[1].Protocol, wire.Protocol) + } + if !list.Edges[1].Connected { + t.Fatal("expected edge-b to be connected") + } + + getResp := httptest.NewRecorder() + mux.ServeHTTP(getResp, httptest.NewRequest(http.MethodGet, "/edges/edge-b", nil)) + if getResp.Code != http.StatusOK { + t.Fatalf("GET /edges/edge-b status=%d body=%s", getResp.Code, getResp.Body.String()) + } + var got edgeRegistryView + if err := json.Unmarshal(getResp.Body.Bytes(), &got); err != nil { + t.Fatalf("decode get response: %v", err) + } + if got.EdgeName != "Edge B" || got.Version != "1.2.3" { + t.Fatalf("unexpected edge view: %+v", got) + } + if len(got.Capabilities) != 2 || got.Capabilities[0] != "run" || got.Capabilities[1] != "node-registry" { + t.Fatalf("capabilities: got %+v", got.Capabilities) + } + if !got.LastSeen.Equal(at) { + t.Fatalf("last_seen: got %s want %s", got.LastSeen, at) + } +} + +func TestEdgeRegistryHTTPHandlersRejectUnsupportedCases(t *testing.T) { + registry := wire.NewEdgeRegistry() + mux := http.NewServeMux() + registerEdgeRegistryHandlers(mux, registry) + + postResp := httptest.NewRecorder() + mux.ServeHTTP(postResp, httptest.NewRequest(http.MethodPost, "/edges", nil)) + if postResp.Code != http.StatusMethodNotAllowed { + t.Fatalf("POST /edges status=%d body=%s", postResp.Code, postResp.Body.String()) + } + + missingResp := httptest.NewRecorder() + mux.ServeHTTP(missingResp, httptest.NewRequest(http.MethodGet, "/edges/missing-edge", nil)) + if missingResp.Code != http.StatusNotFound { + t.Fatalf("GET /edges/missing-edge status=%d body=%s", missingResp.Code, missingResp.Body.String()) + } +} diff --git a/apps/control-plane/internal/wire/edge_registry.go b/apps/control-plane/internal/wire/edge_registry.go index cef5347..6d47bde 100644 --- a/apps/control-plane/internal/wire/edge_registry.go +++ b/apps/control-plane/internal/wire/edge_registry.go @@ -1,6 +1,7 @@ package wire import ( + "sort" "sync" "time" @@ -88,6 +89,25 @@ func (r *EdgeRegistry) Snapshot(edgeID string) (EdgeConnectionState, bool) { return snapshot, true } +// Snapshots returns a stable, copied view of all tracked Edge states. +func (r *EdgeRegistry) Snapshots() []EdgeConnectionState { + r.mu.Lock() + defer r.mu.Unlock() + ids := make([]string, 0, len(r.edges)) + for id := range r.edges { + ids = append(ids, id) + } + sort.Strings(ids) + snapshots := make([]EdgeConnectionState, 0, len(ids)) + for _, id := range ids { + state := r.edges[id] + snapshot := *state + snapshot.Capabilities = append([]string(nil), state.Capabilities...) + snapshots = append(snapshots, snapshot) + } + return snapshots +} + // Len returns the number of tracked Edge connections. func (r *EdgeRegistry) Len() int { r.mu.Lock() diff --git a/apps/control-plane/internal/wire/edge_server_test.go b/apps/control-plane/internal/wire/edge_server_test.go index 1ab1c73..1e40c77 100644 --- a/apps/control-plane/internal/wire/edge_server_test.go +++ b/apps/control-plane/internal/wire/edge_server_test.go @@ -225,6 +225,38 @@ func TestEdgeServerStaleDisconnectDoesNotClearReconnect(t *testing.T) { } } +func TestEdgeRegistrySnapshotsAreSortedAndCopied(t *testing.T) { + registry := NewEdgeRegistry() + at := time.Unix(1780142300, 0).UTC() + registry.MarkConnected(&iop.EdgeHelloRequest{ + EdgeId: "edge-b", + EdgeName: "Edge B", + Capabilities: []string{"run"}, + }, at) + registry.MarkConnected(&iop.EdgeHelloRequest{ + EdgeId: "edge-a", + EdgeName: "Edge A", + Capabilities: []string{"status"}, + }, at.Add(time.Second)) + + snapshots := registry.Snapshots() + if len(snapshots) != 2 { + t.Fatalf("expected 2 snapshots, got %d", len(snapshots)) + } + if snapshots[0].EdgeID != "edge-a" || snapshots[1].EdgeID != "edge-b" { + t.Fatalf("expected snapshots sorted by edge_id, got %+v", snapshots) + } + + snapshots[0].Capabilities[0] = "mutated" + again, ok := registry.Snapshot("edge-a") + if !ok { + t.Fatal("edge-a missing from registry") + } + if again.Capabilities[0] != "status" { + t.Fatalf("snapshot capabilities alias registry storage: %+v", again.Capabilities) + } +} + func TestEdgeHeartbeatWaitExceedsInterval(t *testing.T) { if EdgeHeartbeatWaitSec <= EdgeHeartbeatIntervalSec { t.Fatalf("EdgeHeartbeatWaitSec (%d) must exceed EdgeHeartbeatIntervalSec (%d)",