feat: control-plane edge wire baseline implementation

- Add edge server registry and connection management
- Implement edge wire protocol with gRPC streaming
- Update proto definitions for control plane communication
- Add edge bootstrap runtime and connector
- Add code review and plan documentation for G08
This commit is contained in:
toki 2026-05-30 21:44:31 +09:00
parent 31421b2a8e
commit 348578e5dd
15 changed files with 1478 additions and 410 deletions

View file

@ -1,6 +1,6 @@
<!-- task=m-control-plane-edge-wire-baseline/04_status_snapshot plan=0 tag=STATUS -->
<!-- task=m-control-plane-edge-wire-baseline/04_status_snapshot plan=1 tag=REVIEW_STATUS -->
# Code Review Reference - STATUS
# Code Review Reference - REVIEW_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.
@ -13,7 +13,7 @@
## 개요
date=2026-05-30
task=m-control-plane-edge-wire-baseline/04_status_snapshot, plan=0, tag=STATUS
task=m-control-plane-edge-wire-baseline/04_status_snapshot, plan=1, tag=REVIEW_STATUS
## Roadmap Targets
@ -41,17 +41,13 @@ task=m-control-plane-edge-wire-baseline/04_status_snapshot, plan=0, tag=STATUS
| 항목 | 완료 여부 |
|------|---------|
| [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 | [ ] |
| [REVIEW_STATUS-1] Proto Generated Artifact Idempotence | [ ] |
## 구현 체크리스트
- [ ] `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 테스트를 통과시킨다.
- [ ] 현재 repo 툴체인으로 `make proto`를 실행해 `proto/gen/iop/control.pb.go`가 idempotent한 generated output이 되도록 맞춘다.
- [ ] repo 밖 temp dir에 Makefile과 동일한 protoc 명령을 실행해 `proto/gen/iop/control.pb.go`와 temp generated file의 diff가 없는지 확인하고, `node.pb.go`/`runtime.pb.go`/`job.pb.go`에 불필요한 diff가 남지 않았음을 기록한다.
- [ ] focused Go test와 Control Plane-Edge wire smoke를 재실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
@ -93,11 +89,10 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
## 리뷰어를 위한 체크포인트
- `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와 일치하는지 확인한다.
- `proto/gen/iop/control.pb.go`가 현재 repo `make proto` 결과와 idempotent한지 확인한다.
- temp dir generation 비교가 출력 없이 exit 0인지 확인한다.
- `node.pb.go`/`runtime.pb.go`/`job.pb.go`에 status 계약과 무관한 generated churn이 남지 않았는지 확인한다.
- focused Go test와 `make test-control-plane-edge-wire` 출력이 실제 명령 결과인지 확인한다.
## 검증 결과
@ -109,56 +104,29 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
### STATUS-1 중간 검증
### REVIEW_STATUS-1 중간 검증
```text
$ protoc --version
(output)
$ protoc-gen-go --version
(output)
$ 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/...
$ 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 && diff -u proto/gen/iop/control.pb.go "$tmpdir/proto/gen/iop/control.pb.go"; status=$?; rm -rf "$tmpdir"; exit $status
(output)
$ make test-control-plane-edge-wire
$ git diff --name-only -- proto/gen/iop/node.pb.go proto/gen/iop/runtime.pb.go proto/gen/iop/job.pb.go
(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 ./...
$ go test -count=1 ./apps/control-plane/internal/wire ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./proto/gen/...
(output)
$ make test-control-plane-edge-wire

View file

@ -1,6 +1,6 @@
<!-- task=m-control-plane-edge-wire-baseline/04_status_snapshot plan=0 tag=STATUS -->
<!-- task=m-control-plane-edge-wire-baseline/04_status_snapshot plan=1 tag=REVIEW_STATUS -->
# Control Plane-Edge Status Snapshot Plan
# Control Plane-Edge Status Snapshot Review Follow-up Plan
## 이 파일을 읽는 구현 에이전트에게
@ -8,14 +8,6 @@
구현 중 사용자만 결정할 수 있는 범위 변경, 사용자 소유 외부 환경/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`
@ -23,358 +15,66 @@
- `edge-status`: Control Plane은 Edge에 상태 snapshot을 요청하고 Edge-owned 상태를 응답받을 수 있다.
- Completion mode: check-on-pass
## 분석 결과
## 이전 리뷰 결과
### 읽은 파일
- Archived plan: `agent-task/m-control-plane-edge-wire-baseline/04_status_snapshot/plan_cloud_G08_0.log`
- Archived review: `agent-task/m-control-plane-edge-wire-baseline/04_status_snapshot/code_review_cloud_G08_0.log`
- Verdict: FAIL
- Required issue: `proto/gen/iop/control.pb.go`가 현재 repo `make proto` 출력과 idempotent하지 않습니다. 현재 `protoc --version``libprotoc 29.3`인데 generated header는 `protoc v3.21.12`로 남아 있습니다.
- `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/경계 회귀 위험이 있습니다.
- 이번 follow-up은 generated proto artifact와 검증 기록 신뢰도만 회복합니다.
- `proto/iop/control.proto`, Control Plane request path, Edge status provider 로직의 의미 변경은 하지 않습니다.
- `node.pb.go`, `runtime.pb.go`, `job.pb.go`는 이번 status 계약 변경 대상이 아니므로 `make proto` 후 의미 없는 header churn이 생기면 남기지 않습니다.
## 구현 체크리스트
- [ ] `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 테스트를 통과시킨다.
- [ ] 현재 repo 툴체인으로 `make proto`를 실행해 `proto/gen/iop/control.pb.go`가 idempotent한 generated output이 되도록 맞춘다.
- [ ] repo 밖 temp dir에 Makefile과 동일한 protoc 명령을 실행해 `proto/gen/iop/control.pb.go`와 temp generated file의 diff가 없는지 확인하고, `node.pb.go`/`runtime.pb.go`/`job.pb.go`에 불필요한 diff가 남지 않았음을 기록한다.
- [ ] focused Go test와 Control Plane-Edge wire smoke를 재실행하고 실제 stdout/stderr를 review stub에 기록한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## STATUS-1 Proto Status Contract
## REVIEW_STATUS-1 Proto Generated Artifact Idempotence
### 문제
`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;
}
```
`code_review_cloud_G08_0.log`의 FAIL 판정에 따르면 `proto/gen/iop/control.pb.go:4`는 현재 repo에서 사용하는 `protoc v5.29.3` 출력과 다릅니다. 이 상태에서는 `make proto`를 다시 실행할 때 generated file이 또 바뀌므로, `make proto` 검증 기록을 신뢰할 수 없습니다.
### 해결 방법
`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<string, string> metadata = 6;
string error = 7;
}
```
현재 repo PATH의 `protoc`/`protoc-gen-go`를 확인한 뒤 `make proto`를 실행합니다. 생성 결과에서 status 계약 대상인 `control.pb.go`만 필요한 diff를 유지하고, `node.pb.go`/`runtime.pb.go`/`job.pb.go`에 불필요한 header-only diff가 남으면 되돌리지 말고 먼저 `git diff --name-only``git diff`로 원인을 확인한 뒤 status 계약과 무관한 churn을 제거합니다.
### 수정 파일 및 체크리스트
- [ ] `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 추가.
- [ ] `proto/gen/iop/control.pb.go`: 현재 repo `make proto` 출력과 idempotent하게 갱신.
- [ ] `CODE_REVIEW-cloud-G08.md`: 실제 `protoc --version`, `protoc-gen-go --version`, `make proto`, temp generation diff, 테스트/smoke 출력 기록.
### 테스트 작성
### 테스트 결정
- `apps/control-plane/internal/wire/edge_test.go``TestEdgeParserMapStatusRoundTrip`을 추가합니다.
- assertion: `EdgeStatusRequest`, `EdgeStatusResponse``EdgeParserMap()`에서 marshal/parse roundtrip되고, forbidden direct scheduling field가 status request에도 없음을 확인합니다.
- generated artifact 신뢰도 회복이 핵심이므로 temp dir generation 비교를 필수 중간 검증으로 둡니다.
- protobuf 계약과 양쪽 wire 경계가 포함되므로 focused Go test와 `make test-control-plane-edge-wire`를 다시 실행합니다.
- 전체 `go test ./...`는 이번 follow-up이 generated header/idempotence 수정에 한정되므로 필수는 아닙니다. 실행하면 결과를 추가 기록해도 됩니다.
### 중간 검증
```bash
protoc --version
protoc-gen-go --version
make proto
go test -count=1 ./apps/control-plane/internal/wire ./proto/gen/...
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 && diff -u proto/gen/iop/control.pb.go "$tmpdir/proto/gen/iop/control.pb.go"; status=$?; rm -rf "$tmpdir"; exit $status
git diff --name-only -- proto/gen/iop/node.pb.go proto/gen/iop/runtime.pb.go proto/gen/iop/job.pb.go
```
기대 결과: 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 순서로 진행합니다.
기대 결과: temp generation diff는 출력 없이 exit 0이고, `node.pb.go`/`runtime.pb.go`/`job.pb.go` diff 확인 명령은 출력이 없습니다.
## 최종 검증
```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 ./...
go test -count=1 ./apps/control-plane/internal/wire ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./proto/gen/...
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.`가 포함됩니다.
기대 결과: 모든 명령이 성공하고, smoke 출력에 `CP: hello accepted`, `Edge: connected to control plane`, `CP: edge disconnected`, `Control Plane-Edge wire smoke PASSED.`가 포함됩니다.
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -0,0 +1,279 @@
<!-- task=m-control-plane-edge-wire-baseline/04_status_snapshot plan=0 tag=STATUS -->
# 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 | [x] |
| [STATUS-2] Control Plane Status Request Path | [x] |
| [STATUS-3] Edge Service-Backed Status Response | [x] |
| [STATUS-4] Focused Regression And Smoke | [x] |
## 구현 체크리스트
- [x] `proto/iop/control.proto`에 Control Plane-Edge status request/response와 node snapshot message를 추가하고 `make proto`로 Go 생성물을 갱신한다.
- [x] Control Plane `wire.EdgeServer`가 connected Edge client에 status request를 보내고 response를 받을 수 있도록 client tracking과 parser/test를 추가한다.
- [x] Edge `controlplane.Connector`가 Control Plane status request를 수신하면 `apps/edge/internal/service`의 node snapshot 표면으로 응답하도록 option과 bootstrap wiring을 추가한다.
- [x] Status proto roundtrip, Control Plane request/response, Edge service-backed response, bootstrap wiring 테스트를 추가하고 대상 Go 테스트를 통과시킨다.
- [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/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이 있어 유지했다고 확인한다.
- [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로 이동한다.
## 계획 대비 변경 사항
- `make proto`는 로컬 protoc 버전(v3.21.12)이 repo 생성물 헤더(v1.36.11 / v5.29.3)와 달라서 `control.pb.go` 외에 `node.pb.go`/`job.pb.go`/`runtime.pb.go`의 버전 주석 1줄까지 함께 바꿨다. 이 세 파일은 계약 변경이 없고 버전 주석만 달랐으므로 `git checkout`으로 복원했고, 이번 계약 변경 대상인 `control.pb.go`만 생성물로 남겼다. 따라서 `control.pb.go` 헤더 protoc 버전은 다른 생성물과 일시적으로 다를 수 있으나 메시지 계약은 정확하다.
- 계획의 STATUS-2 예시는 `edge_registry.go`에 "disconnect 갱신 성공 여부 helper"를 두는 방향이었다. 구현은 기존 `MarkDisconnected`의 시그니처를 `bool` 반환으로 확장해(현재 token에 적용됐을 때만 true), `EdgeServer`가 같은 stale-safe 기준으로 active client map을 정리하도록 했다. 기존 호출부는 반환값을 무시해도 호환된다.
- 계획에 명시되지 않았지만 bootstrap wiring을 관찰 가능하게 검증하기 위해 `Connector.StatusProviderConfigured() bool`를 추가했다. runtime이 status provider를 연결했는지 외부에서 확인할 표면이 없었기 때문이다.
- `EdgeNodeSnapshot.connected`는 Edge service `ListNodeSnapshots()`가 현재 연결된 registry entry만 반환하므로 응답 변환 시 `true`로 설정했다. service DTO(`NodeSnapshot`)에는 connected 필드가 없다.
- 계획이 요구한 핵심 테스트 외에 negative-path 테스트(`TestEdgeServerRequestStatusFailsWhenEdgeNotConnected`, `TestConnectorStatusRequestWithoutProviderReturnsError`, `TestEdgeStatusRequestDoesNotExposeNodeDirectScheduling`)를 추가해 경계/실패 동작을 고정했다.
- 검증 명령은 계획의 고정 계약대로 실행했다. 대체/생략 없음.
## 주요 설계 결정
- Control Plane은 `EdgeServer.activeClients`에 hello 수락 시점의 live `*proto_socket.TcpClient`만 edge_id별로 보관하고, `RequestStatus`가 그 client의 `Communicator`로 typed status 요청을 보낸다. Control Plane은 Node에 직접 연결하거나 Edge registry/transport를 복제하지 않고, Edge가 응답한 snapshot만 관찰한다.
- active client map은 registry connection token을 함께 저장하고, disconnect가 현재 token일 때만 정리한다. 동일 edge_id 재연결 시 stale 연결의 늦은 disconnect가 새 연결의 client를 제거하지 못하게 해 기존 stale-disconnect 회귀 테스트와 일관성을 유지한다.
- Edge `Connector`는 functional option(`WithStatusProvider`)으로 의존성을 받아 기존 4-인자 호출부와 테스트가 깨지지 않게 했다. status listener는 dial 직후(hello 이전)에 등록해, 수락 직후 도착하는 status 요청도 놓치지 않는 기존 disconnect-listener 사전 등록 불변식과 동일한 패턴을 따른다.
- status 응답은 표면 중립 `edgeservice.StatusProvider`(=`service.ListNodeSnapshots()`)만 통과한다. provider가 없으면 빈 응답 대신 `error` 필드를 채운 명시적 응답을 돌려 경계를 분명히 한다.
- status 계약은 Control Plane-Edge 전용이며 `EdgeStatusRequest`는 correlation id만 갖는다. node_id/address/token/schedule/target 같은 직접 스케줄링 필드를 추가하지 않았고 테스트로 고정했다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/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
protoc \
--go_out=. \
--go_opt=module=iop \
--proto_path=. \
proto/iop/runtime.proto \
proto/iop/node.proto \
proto/iop/control.proto \
proto/iop/job.proto
(exit 0; control.pb.go에 EdgeStatusRequest/EdgeNodeSnapshot/EdgeStatusResponse 생성됨.
로컬 protoc 버전 차이로만 바뀐 node/job/runtime.pb.go는 복원함 - 계획 대비 변경 사항 참조)
$ go test -count=1 ./apps/control-plane/internal/wire ./proto/gen/...
ok iop/apps/control-plane/internal/wire 0.645s
? iop/proto/gen/iop [no test files]
```
### STATUS-2 중간 검증
```text
$ go test -count=1 ./apps/control-plane/internal/wire
ok iop/apps/control-plane/internal/wire 0.745s
```
### STATUS-3 중간 검증
```text
$ go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/service
ok iop/apps/edge/internal/controlplane 4.413s
ok iop/apps/edge/internal/bootstrap 0.014s
ok iop/apps/edge/internal/service 0.004s
```
### STATUS-4 중간 검증
```text
$ go test -count=1 ./apps/control-plane/... ./apps/edge/... ./packages/... ./proto/gen/...
ok iop/apps/control-plane/cmd/control-plane 0.008s
ok iop/apps/control-plane/internal/wire 0.747s
ok iop/apps/edge/cmd/edge 0.042s
ok iop/apps/edge/internal/bootstrap 0.022s
ok iop/apps/edge/internal/controlplane 4.421s
ok iop/apps/edge/internal/events 0.003s
ok iop/apps/edge/internal/input 0.006s
ok iop/apps/edge/internal/input/a2a 0.005s
ok iop/apps/edge/internal/node 0.005s
ok iop/apps/edge/internal/openai 1.507s
ok iop/apps/edge/internal/opsconsole 0.008s
ok iop/apps/edge/internal/service 0.006s
ok iop/apps/edge/internal/transport 0.007s
? iop/packages/auth [no test files]
ok iop/packages/config 0.014s
ok iop/packages/hostsetup 0.005s
ok iop/packages/observability 0.012s
? iop/proto/gen/iop [no test files]
(version/jobs/metadata/policy/events: no test files)
$ make test-control-plane-edge-wire
[cp-edge-wire] CP: hello accepted
[cp-edge-wire] Edge: connected to control plane
[cp-edge-wire] CP: edge disconnected
...
{"msg":"edge hello accepted","edge_id":"smoke-edge-wire","edge_name":"Smoke Edge Wire","version":"0.1.0"}
{"msg":"edge disconnected","edge_id":"smoke-edge-wire","reason":"remote_closed"}
{"logger":"controlplane","msg":"connected to control plane","wire_addr":"127.0.0.1:31457","protocol":"protobuf-socket"}
[cp-edge-wire] Control Plane-Edge wire smoke PASSED.
(exit 0)
```
### 최종 검증
```text
$ make proto
(exit 0; STATUS-1 출력과 동일. control.pb.go만 생성물로 유지)
$ go test -count=1 ./apps/control-plane/internal/wire
ok iop/apps/control-plane/internal/wire 0.749s
$ go test -count=1 ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap ./apps/edge/internal/service
ok iop/apps/edge/internal/controlplane 4.421s
ok iop/apps/edge/internal/bootstrap 0.014s
ok iop/apps/edge/internal/service 0.004s
$ go test -count=1 ./apps/control-plane/... ./apps/edge/... ./packages/... ./proto/gen/...
(STATUS-4 중간 검증과 동일하게 모든 패키지 ok)
$ go test ./...
ok iop/apps/control-plane/cmd/control-plane 0.007s
ok iop/apps/control-plane/internal/wire 0.749s
ok iop/apps/edge/cmd/edge 0.048s
ok iop/apps/edge/internal/bootstrap 0.019s
ok iop/apps/edge/internal/controlplane 4.420s
ok iop/apps/edge/internal/events 0.010s
ok iop/apps/edge/internal/input 0.006s
ok iop/apps/edge/internal/input/a2a 0.006s
ok iop/apps/edge/internal/node 0.008s
ok iop/apps/edge/internal/openai 1.513s
ok iop/apps/edge/internal/opsconsole 0.009s
ok iop/apps/edge/internal/service 0.005s
ok iop/apps/edge/internal/transport 0.007s
ok iop/apps/node/cmd/node 0.018s
ok iop/apps/node/internal/adapters 0.005s
ok iop/apps/node/internal/adapters/cli 41.330s
ok iop/apps/node/internal/adapters/cli/status 39.678s
ok iop/apps/node/internal/bootstrap 0.161s
ok iop/apps/node/internal/node 0.011s
ok iop/apps/node/internal/router 0.008s
ok iop/apps/node/internal/store 0.040s
ok iop/apps/node/internal/transport 5.038s
ok iop/packages/config 0.013s
ok iop/packages/hostsetup 0.004s
ok iop/packages/observability 0.009s
(나머지 패키지: no test files; 전체 exit 0)
$ make test-control-plane-edge-wire
(exit 0; STATUS-4 출력과 동일. 마커: CP: hello accepted / Edge: connected to control plane /
CP: edge disconnected / Control Plane-Edge wire smoke PASSED.)
```
보조 확인: `gofmt -l`(대상 패키지 출력 없음 = 포맷 통과), `go vet ./apps/control-plane/internal/wire ./apps/edge/internal/controlplane ./apps/edge/internal/bootstrap`(exit 0).
---
> **[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 기록 |
| 코드리뷰 결과 | 리뷰 에이전트 | 스텁에 포함하지 않음 |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- correctness: Pass
- completeness: Fail
- test coverage: Pass
- API contract: Pass
- code quality: Pass
- plan deviation: Warn
- verification trust: Fail
- 발견된 문제:
- Required: `proto/gen/iop/control.pb.go:4`가 현재 저장소의 `make proto` 출력과 idempotent하지 않습니다. 리뷰에서 `protoc --version`은 `libprotoc 29.3`이고, repo 밖 temp dir에 동일한 `protoc --go_out ... proto/iop/runtime.proto proto/iop/node.proto proto/iop/control.proto proto/iop/job.proto`를 실행해 비교하니 유일한 차이가 이 파일의 header가 `protoc v3.21.12`에서 `protoc v5.29.3`으로 바뀌는 것이었습니다. 이는 `CODE_REVIEW-cloud-G08.md:196`-`197`의 "`make proto` exit 0; control.pb.go만 생성물로 유지" 검증 기록과도 맞지 않습니다. 현재 툴체인으로 `make proto`를 재실행해 generated output을 idempotent하게 맞추고, 실제 `protoc --version` 및 생성물 비교 결과를 review stub에 기록하세요.
- 다음 단계: FAIL 후속 `PLAN-cloud-G08.md`와 `CODE_REVIEW-cloud-G08.md`를 작성한다.

View file

@ -0,0 +1,380 @@
<!-- task=m-control-plane-edge-wire-baseline/04_status_snapshot plan=0 tag=STATUS -->
# 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/경계 회귀 위험이 있습니다.
## 구현 체크리스트
- [x] `proto/iop/control.proto`에 Control Plane-Edge status request/response와 node snapshot message를 추가하고 `make proto`로 Go 생성물을 갱신한다.
- [x] Control Plane `wire.EdgeServer`가 connected Edge client에 status request를 보내고 response를 받을 수 있도록 client tracking과 parser/test를 추가한다.
- [x] Edge `controlplane.Connector`가 Control Plane status request를 수신하면 `apps/edge/internal/service`의 node snapshot 표면으로 응답하도록 option과 bootstrap wiring을 추가한다.
- [x] Status proto roundtrip, Control Plane request/response, Edge service-backed response, bootstrap wiring 테스트를 추가하고 대상 Go 테스트를 통과시킨다.
- [x] 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<string, string> metadata = 6;
string error = 7;
}
```
### 수정 파일 및 체크리스트
- [x] `proto/iop/control.proto`: 새 message 추가, field number 안정화.
- [x] `proto/gen/iop/control.pb.go`: `make proto`로만 갱신.
- [x] `apps/control-plane/internal/wire/edge.go`: status request/response parser 추가.
- [x] `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,
)
}
```
### 수정 파일 및 체크리스트
- [x] `apps/control-plane/internal/wire/edge_server.go`: active client map, status request method, stale disconnect cleanup 추가.
- [x] `apps/control-plane/internal/wire/edge_registry.go`: disconnect 갱신 성공 여부를 반환하거나 EdgeServer가 stale cleanup을 판정할 수 있는 helper 추가.
- [x] `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 }
}
```
### 수정 파일 및 체크리스트
- [x] `apps/edge/internal/controlplane/connector.go`: option, status provider, parser, request listener 추가.
- [x] `apps/edge/internal/bootstrap/runtime.go`: `NewConnector(..., WithStatusProvider(svc))` wiring.
- [x] `apps/edge/internal/controlplane/connector_test.go`: fake Control Plane이 status request를 보내고 service-backed node snapshot을 받는 테스트 추가.
- [x] `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이 깨지지 않았는지 확인합니다.
### 수정 파일 및 체크리스트
- [x] `Makefile`/`scripts/e2e-control-plane-edge-wire.sh`는 status 구현을 위해 수정하지 않습니다.
- [x] package focused tests를 실행합니다.
- [x] 전체 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -19,5 +19,13 @@ func EdgeParserMap() proto_socket.ParserMap {
res := &iop.EdgeHelloResponse{}
return res, proto.Unmarshal(b, res)
},
proto_socket.TypeNameOf(&iop.EdgeStatusRequest{}): func(b []byte) (proto.Message, error) {
req := &iop.EdgeStatusRequest{}
return req, proto.Unmarshal(b, req)
},
proto_socket.TypeNameOf(&iop.EdgeStatusResponse{}): func(b []byte) (proto.Message, error) {
res := &iop.EdgeStatusResponse{}
return res, proto.Unmarshal(b, res)
},
}
}

View file

@ -63,17 +63,20 @@ func (r *EdgeRegistry) MarkConnected(req *iop.EdgeHelloRequest, at time.Time) ui
// 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) {
// superseded connection is ignored so a reconnection is not cleared. It returns
// true only when the current connection was flipped, so callers (e.g. the
// EdgeServer active client map) can apply the same stale-safe cleanup.
func (r *EdgeRegistry) MarkDisconnected(edgeID string, token uint64, at time.Time, info proto_socket.DisconnectInfo) bool {
r.mu.Lock()
defer r.mu.Unlock()
state, ok := r.edges[edgeID]
if !ok || state.token != token {
return
return false
}
state.Connected = false
state.LastSeen = at
state.DisconnectInfo = info
return true
}
// Snapshot returns a copy of the tracked state for an Edge.

View file

@ -24,6 +24,18 @@ type EdgeServer struct {
logger *zap.Logger
host string
port int
// activeMu guards activeClients, the live proto-socket client per edge_id
// the Control Plane uses to send typed requests (e.g. status) to a connected
// Edge. token mirrors the registry connection token so a stale connection's
// late disconnect does not evict a newer reconnection's client.
activeMu sync.Mutex
activeClients map[string]activeEdgeClient
}
type activeEdgeClient struct {
client *proto_socket.TcpClient
token uint64
}
// NewEdgeServer creates an EdgeServer bound to the given TCP listen address.
@ -39,10 +51,11 @@ func NewEdgeServer(listenAddr string, logger *zap.Logger) (*EdgeServer, error) {
registry := NewEdgeRegistry()
es := &EdgeServer{
registry: registry,
logger: logger,
host: host,
port: port,
registry: registry,
logger: logger,
host: host,
port: port,
activeClients: make(map[string]activeEdgeClient),
}
es.server = proto_socket.NewTcpServer(host, port, func(conn net.Conn) *proto_socket.TcpClient {
@ -76,6 +89,7 @@ func NewEdgeServer(listenAddr string, logger *zap.Logger) (*EdgeServer, error) {
edgeID = req.GetEdgeId()
token = connToken
idMu.Unlock()
es.setActiveClient(req.GetEdgeId(), connToken, client)
logger.Info("edge hello accepted",
zap.String("edge_id", req.GetEdgeId()),
@ -99,7 +113,9 @@ func NewEdgeServer(listenAddr string, logger *zap.Logger) (*EdgeServer, error) {
return
}
info := c.DisconnectInfo()
registry.MarkDisconnected(id, tok, time.Now(), info)
if registry.MarkDisconnected(id, tok, time.Now(), info) {
es.clearActiveClient(id, tok)
}
logger.Info("edge disconnected",
zap.String("edge_id", id),
zap.String("reason", info.Reason),
@ -131,6 +147,53 @@ func (s *EdgeServer) Stop() error {
// Registry exposes the internal Edge connection registry.
func (s *EdgeServer) Registry() *EdgeRegistry { return s.registry }
// setActiveClient records the live proto-socket client for a connected Edge so
// the Control Plane can send typed requests back to it. A newer connection
// always supersedes an older one for the same edge_id.
func (s *EdgeServer) setActiveClient(edgeID string, token uint64, client *proto_socket.TcpClient) {
s.activeMu.Lock()
defer s.activeMu.Unlock()
s.activeClients[edgeID] = activeEdgeClient{client: client, token: token}
}
// clearActiveClient removes the active client for an Edge, but only when token
// still matches the tracked connection so a stale disconnect cannot evict a
// newer reconnection.
func (s *EdgeServer) clearActiveClient(edgeID string, token uint64) {
s.activeMu.Lock()
defer s.activeMu.Unlock()
if entry, ok := s.activeClients[edgeID]; ok && entry.token == token {
delete(s.activeClients, edgeID)
}
}
// activeClient returns the live proto-socket client for a connected Edge.
func (s *EdgeServer) activeClient(edgeID string) (*proto_socket.TcpClient, bool) {
s.activeMu.Lock()
defer s.activeMu.Unlock()
entry, ok := s.activeClients[edgeID]
if !ok {
return nil, false
}
return entry.client, true
}
// RequestStatus asks a connected Edge for its Edge-owned node snapshot over the
// wire and returns the typed response. It observes only what the Edge reports;
// it does not connect to or schedule Node directly. It fails when the Edge is
// not currently connected.
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,
)
}
// Host returns the host the server is listening on.
func (s *EdgeServer) Host() string { return s.host }

View file

@ -225,6 +225,58 @@ func TestEdgeServerStaleDisconnectDoesNotClearReconnect(t *testing.T) {
}
}
func TestEdgeServerRequestsStatusFromConnectedEdge(t *testing.T) {
server, port := startEdgeServer(t)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
client := dialEdge(t, ctx, port)
defer client.Close()
const edgeID = "edge-dgx-group"
// The test acts as the Edge: it answers a Control Plane status request with a
// service-backed node snapshot.
proto_socket.AddRequestListenerTyped(&client.Communicator, func(req *iop.EdgeStatusRequest) (*iop.EdgeStatusResponse, error) {
return &iop.EdgeStatusResponse{
RequestId: req.GetRequestId(),
EdgeId: edgeID,
EdgeName: "DGX Group",
ObservedTimeUnixNano: time.Now().UnixNano(),
Nodes: []*iop.EdgeNodeSnapshot{
{NodeId: "node-1", Alias: "alpha", Label: "node0", Connected: true},
},
}, nil
})
sendEdgeHello(t, client, edgeID)
resp, err := server.RequestStatus(edgeID, 2*time.Second)
if err != nil {
t.Fatalf("RequestStatus: %v", err)
}
if resp.GetRequestId() == "" {
t.Errorf("expected a non-empty correlation request_id")
}
if resp.GetEdgeId() != edgeID {
t.Errorf("edge_id: got %q want %q", resp.GetEdgeId(), edgeID)
}
if len(resp.GetNodes()) != 1 {
t.Fatalf("nodes: got %d want 1", len(resp.GetNodes()))
}
if node := resp.GetNodes()[0]; node.GetNodeId() != "node-1" || node.GetLabel() != "node0" {
t.Errorf("node snapshot: %+v", node)
}
}
func TestEdgeServerRequestStatusFailsWhenEdgeNotConnected(t *testing.T) {
server, _ := startEdgeServer(t)
if _, err := server.RequestStatus("missing-edge", time.Second); err == nil {
t.Fatal("expected error requesting status from a non-connected edge")
}
}
func TestEdgeRegistrySnapshotsAreSortedAndCopied(t *testing.T) {
registry := NewEdgeRegistry()
at := time.Unix(1780142300, 0).UTC()

View file

@ -103,6 +103,94 @@ func TestEdgeParserMapEdgeHelloResponseRoundTrip(t *testing.T) {
}
}
func TestEdgeParserMapStatusRoundTrip(t *testing.T) {
parserMap := EdgeParserMap()
reqType := proto_socket.TypeNameOf(&iop.EdgeStatusRequest{})
parseReq, ok := parserMap[reqType]
if !ok {
t.Fatalf("missing parser for %s", reqType)
}
reqPayload, err := proto.Marshal(&iop.EdgeStatusRequest{RequestId: "edge-status-1"})
if err != nil {
t.Fatalf("marshal EdgeStatusRequest: %v", err)
}
gotReq, err := parseReq(reqPayload)
if err != nil {
t.Fatalf("parse EdgeStatusRequest: %v", err)
}
parsedReq, ok := gotReq.(*iop.EdgeStatusRequest)
if !ok {
t.Fatalf("expected *iop.EdgeStatusRequest, got %T", gotReq)
}
if parsedReq.GetRequestId() != "edge-status-1" {
t.Errorf("request_id: got %q want %q", parsedReq.GetRequestId(), "edge-status-1")
}
resType := proto_socket.TypeNameOf(&iop.EdgeStatusResponse{})
parseRes, ok := parserMap[resType]
if !ok {
t.Fatalf("missing parser for %s", resType)
}
res := &iop.EdgeStatusResponse{
RequestId: "edge-status-1",
EdgeId: "edge-dgx-group",
EdgeName: "DGX Group",
ObservedTimeUnixNano: time.Now().UnixNano(),
Nodes: []*iop.EdgeNodeSnapshot{
{NodeId: "node-1", Alias: "alpha", Label: "node0", Connected: true},
},
Metadata: map[string]string{"region": "local"},
}
resPayload, err := proto.Marshal(res)
if err != nil {
t.Fatalf("marshal EdgeStatusResponse: %v", err)
}
gotRes, err := parseRes(resPayload)
if err != nil {
t.Fatalf("parse EdgeStatusResponse: %v", err)
}
parsedRes, ok := gotRes.(*iop.EdgeStatusResponse)
if !ok {
t.Fatalf("expected *iop.EdgeStatusResponse, got %T", gotRes)
}
if parsedRes.GetEdgeId() != "edge-dgx-group" {
t.Errorf("edge_id: got %q want %q", parsedRes.GetEdgeId(), "edge-dgx-group")
}
if len(parsedRes.GetNodes()) != 1 {
t.Fatalf("nodes length: got %d want 1", len(parsedRes.GetNodes()))
}
node := parsedRes.GetNodes()[0]
if node.GetNodeId() != "node-1" || node.GetAlias() != "alpha" || node.GetLabel() != "node0" {
t.Errorf("node snapshot: %+v", node)
}
if !node.GetConnected() {
t.Errorf("expected node snapshot connected=true")
}
if parsedRes.GetMetadata()["region"] != "local" {
t.Errorf("metadata region: got %q want %q", parsedRes.GetMetadata()["region"], "local")
}
}
// TestEdgeStatusRequestDoesNotExposeNodeDirectScheduling guards that the status
// request stays a correlation-only contract and never grows direct Node
// addressing or scheduling fields.
func TestEdgeStatusRequestDoesNotExposeNodeDirectScheduling(t *testing.T) {
fields := (&iop.EdgeStatusRequest{}).ProtoReflect().Descriptor().Fields()
forbidden := []protoreflect.Name{
"node_id",
"node_address",
"schedule_request",
"target",
"token",
}
for _, name := range forbidden {
if fields.ByName(name) != nil {
t.Fatalf("EdgeStatusRequest must not expose direct Node scheduling field %q", name)
}
}
}
func TestEdgeHelloContractDoesNotExposeNodeDirectScheduling(t *testing.T) {
fields := (&iop.EdgeHelloRequest{}).ProtoReflect().Descriptor().Fields()
forbidden := []protoreflect.Name{

View file

@ -66,6 +66,7 @@ func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
cfg.ControlPlane,
version.Version,
logger.Named("controlplane"),
edgecontrolplane.WithStatusProvider(svc),
)
return &Runtime{

View file

@ -193,6 +193,20 @@ func TestNewRuntimeWiresControlPlaneConnector(t *testing.T) {
}
}
// TestNewRuntimeWiresStatusProviderIntoConnector verifies that NewRuntime wires
// the edge service as the Control Plane connector's status provider, so status
// requests are answered from the Edge-owned node snapshot surface.
func TestNewRuntimeWiresStatusProviderIntoConnector(t *testing.T) {
cfg := newTestConfig()
rt, err := NewRuntime(cfg)
if err != nil {
t.Fatalf("NewRuntime: %v", err)
}
if !rt.ControlPlane.StatusProviderConfigured() {
t.Fatal("expected NewRuntime to wire a status provider into the connector")
}
}
// TestRuntimeStartsControlPlaneConnectorWhenEnabled verifies that Start does
// not fail when a Control Plane connector is configured with a local fake
// server accepting hello. Uses a local TCP fake endpoint.

View file

@ -13,6 +13,7 @@ import (
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/config"
iop "iop/proto/gen/iop"
)
@ -36,6 +37,24 @@ const (
StateStopped
)
// StatusProvider supplies the Edge-owned node snapshot the Connector reports
// when the Control Plane sends a status request. It is the surface-neutral
// boundary (e.g. edge service) the Connector observes instead of reaching into
// the node registry or transport directly.
type StatusProvider interface {
ListNodeSnapshots() []edgeservice.NodeSnapshot
}
// Option configures optional Connector dependencies without breaking existing
// call sites.
type Option func(*Connector)
// WithStatusProvider wires the Edge-owned node snapshot source the Connector
// uses to answer Control Plane status requests.
func WithStatusProvider(provider StatusProvider) Option {
return func(c *Connector) { c.statusProvider = provider }
}
// Connector manages the outbound TCP connection from Edge to Control Plane.
// When disabled (enabled=false or empty wire_addr) it is a safe no-op.
type Connector struct {
@ -44,6 +63,8 @@ type Connector struct {
version string
logger *zap.Logger
statusProvider StatusProvider
state atomic.Int32 // stores State
cancelOnce sync.Once
cancel context.CancelFunc
@ -53,18 +74,28 @@ type Connector struct {
}
// NewConnector creates a Connector from edge identity, Control Plane config,
// version string, and logger. Start must be called separately.
func NewConnector(edgeInfo config.EdgeInfo, cpConf config.EdgeControlPlaneConf, version string, logger *zap.Logger) *Connector {
// version string, and logger. Optional dependencies are supplied via Option.
// Start must be called separately.
func NewConnector(edgeInfo config.EdgeInfo, cpConf config.EdgeControlPlaneConf, version string, logger *zap.Logger, opts ...Option) *Connector {
c := &Connector{
edgeInfo: edgeInfo,
cpConf: cpConf,
version: version,
logger: logger,
}
for _, opt := range opts {
opt(c)
}
c.state.Store(int32(StateDisconnected))
return c
}
// StatusProviderConfigured reports whether a status provider was wired, so
// callers (and bootstrap wiring tests) can observe the dependency.
func (c *Connector) StatusProviderConfigured() bool {
return c.statusProvider != nil
}
// Start begins the outbound connection loop. It is a no-op when enabled=false
// or wire_addr is empty. ctx cancellation is used only for the startup phase;
// the internal lifetime context drives reconnect. Stop() tears down the loop.
@ -178,6 +209,13 @@ func (c *Connector) connect(ctx context.Context) error {
closeDone()
})
// Answer Control Plane status requests from the Edge-owned snapshot surface.
// Registered before hello so a request that arrives right after acceptance is
// not missed.
toki.AddRequestListenerTyped(&cl.Communicator, func(req *iop.EdgeStatusRequest) (*iop.EdgeStatusResponse, error) {
return c.buildStatusResponse(req), nil
})
c.mu.Lock()
c.client = cl
c.mu.Unlock()
@ -230,12 +268,50 @@ func (c *Connector) connect(ctx context.Context) error {
return nil
}
// cpParserMap returns the ParserMap for Control Plane messages.
// buildStatusResponse converts the Edge-owned node snapshot surface into the
// status response. When no status provider is wired it returns an explicit
// error response rather than a silently empty snapshot.
func (c *Connector) buildStatusResponse(req *iop.EdgeStatusRequest) *iop.EdgeStatusResponse {
resp := &iop.EdgeStatusResponse{
RequestId: req.GetRequestId(),
EdgeId: c.edgeInfo.ID,
EdgeName: c.edgeInfo.Name,
ObservedTimeUnixNano: time.Now().UnixNano(),
}
if c.statusProvider == nil {
resp.Error = "edge status provider not configured"
return resp
}
snaps := c.statusProvider.ListNodeSnapshots()
nodes := make([]*iop.EdgeNodeSnapshot, 0, len(snaps))
for _, s := range snaps {
nodes = append(nodes, &iop.EdgeNodeSnapshot{
NodeId: s.NodeID,
Alias: s.Alias,
Label: s.Label,
Connected: true,
})
}
resp.Nodes = nodes
return resp
}
// cpParserMap returns the ParserMap for Control Plane messages. It parses the
// hello response and the Control Plane-initiated status request, plus the
// status response the Edge marshals back.
func cpParserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&iop.EdgeHelloResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.EdgeHelloResponse{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.EdgeStatusRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.EdgeStatusRequest{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.EdgeStatusResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.EdgeStatusResponse{}
return m, proto.Unmarshal(b, m)
},
}
}

View file

@ -10,6 +10,8 @@ import (
toki "git.toki-labs.com/toki/proto-socket/go"
"google.golang.org/protobuf/proto"
edgenode "iop/apps/edge/internal/node"
edgeservice "iop/apps/edge/internal/service"
"iop/packages/config"
iop "iop/proto/gen/iop"
)
@ -92,6 +94,171 @@ func wireAddr(port int) string {
return net.JoinHostPort("127.0.0.1", tcpPortStr(port))
}
// statusCapableCPParserMap parses the Edge hello plus the status request the
// Edge marshals back so the fake Control Plane can both accept hello and read
// the status response.
func statusCapableCPParserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&iop.EdgeHelloRequest{}): func(b []byte) (proto.Message, error) {
m := &iop.EdgeHelloRequest{}
return m, proto.Unmarshal(b, m)
},
toki.TypeNameOf(&iop.EdgeStatusResponse{}): func(b []byte) (proto.Message, error) {
m := &iop.EdgeStatusResponse{}
return m, proto.Unmarshal(b, m)
},
}
}
// startFakeCPServerForStatus starts a fake Control Plane that accepts hello and
// exposes connected clients, so a test can send a status request to the Edge.
func startFakeCPServerForStatus(t *testing.T) (int, <-chan *toki.TcpClient, func()) {
t.Helper()
port, err := freeTCPPort()
if err != nil {
t.Fatalf("startFakeCPServerForStatus freeTCPPort: %v", err)
}
clients := make(chan *toki.TcpClient, 16)
srv := toki.NewTcpServer("127.0.0.1", port, func(conn net.Conn) *toki.TcpClient {
return toki.NewTcpClient(conn, 0, 0, statusCapableCPParserMap())
})
srv.OnClientConnected = func(client *toki.TcpClient) {
clients <- client
toki.AddRequestListenerTyped[*iop.EdgeHelloRequest, *iop.EdgeHelloResponse](
&client.Communicator,
func(_ *iop.EdgeHelloRequest) (*iop.EdgeHelloResponse, error) {
return &iop.EdgeHelloResponse{Accepted: true, Protocol: "iop/1"}, nil
},
)
}
ctx, cancel := context.WithCancel(context.Background())
if err := srv.Start(ctx); err != nil {
cancel()
t.Fatalf("startFakeCPServerForStatus Start: %v", err)
}
return port, clients, func() {
cancel()
_ = srv.Stop()
}
}
// TestConnectorRespondsToStatusRequestFromService verifies that a status
// request from the Control Plane is answered from the Edge service node
// snapshot surface rather than a direct registry/transport read.
func TestConnectorRespondsToStatusRequestFromService(t *testing.T) {
reg := edgenode.NewRegistry()
reg.Register(&edgenode.NodeEntry{NodeID: "node-1", Alias: "alpha"})
reg.Register(&edgenode.NodeEntry{NodeID: "node-2"})
svc := edgeservice.New(reg, nil)
port, serverClients, stop := startFakeCPServerForStatus(t)
defer stop()
c := NewConnector(
config.EdgeInfo{ID: "edge-dgx", Name: "DGX Edge"},
config.EdgeControlPlaneConf{
Enabled: true,
WireAddr: wireAddr(port),
ReconnectIntervalSec: 60,
},
"0.2.0",
noopLogger(),
WithStatusProvider(svc),
)
if !c.StatusProviderConfigured() {
t.Fatal("expected status provider to be configured")
}
if err := c.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
defer c.Stop()
var client *toki.TcpClient
select {
case client = <-serverClients:
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for edge connection")
}
resp, err := toki.SendRequestTyped[*iop.EdgeStatusRequest, *iop.EdgeStatusResponse](
&client.Communicator,
&iop.EdgeStatusRequest{RequestId: "edge-status-test"},
2*time.Second,
)
if err != nil {
t.Fatalf("SendRequestTyped status: %v", err)
}
if resp.GetRequestId() != "edge-status-test" {
t.Errorf("request_id: got %q want %q", resp.GetRequestId(), "edge-status-test")
}
if resp.GetEdgeId() != "edge-dgx" {
t.Errorf("edge_id: got %q want %q", resp.GetEdgeId(), "edge-dgx")
}
if resp.GetError() != "" {
t.Errorf("unexpected error: %q", resp.GetError())
}
if len(resp.GetNodes()) != 2 {
t.Fatalf("nodes: got %d want 2", len(resp.GetNodes()))
}
byID := map[string]*iop.EdgeNodeSnapshot{}
for _, n := range resp.GetNodes() {
byID[n.GetNodeId()] = n
}
if n := byID["node-1"]; n == nil || n.GetAlias() != "alpha" || n.GetLabel() != "node0" {
t.Errorf("node-1 snapshot: %+v", n)
}
if n := byID["node-2"]; n == nil || n.GetLabel() != "node1" {
t.Errorf("node-2 snapshot: %+v", n)
}
}
// TestConnectorStatusRequestWithoutProviderReturnsError verifies that a status
// request is answered with an explicit error when no provider is wired.
func TestConnectorStatusRequestWithoutProviderReturnsError(t *testing.T) {
port, serverClients, stop := startFakeCPServerForStatus(t)
defer stop()
c := NewConnector(
config.EdgeInfo{ID: "edge-dgx", Name: "DGX Edge"},
config.EdgeControlPlaneConf{
Enabled: true,
WireAddr: wireAddr(port),
ReconnectIntervalSec: 60,
},
"0.2.0",
noopLogger(),
)
if c.StatusProviderConfigured() {
t.Fatal("expected no status provider configured")
}
if err := c.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
defer c.Stop()
var client *toki.TcpClient
select {
case client = <-serverClients:
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for edge connection")
}
resp, err := toki.SendRequestTyped[*iop.EdgeStatusRequest, *iop.EdgeStatusResponse](
&client.Communicator,
&iop.EdgeStatusRequest{RequestId: "edge-status-test"},
2*time.Second,
)
if err != nil {
t.Fatalf("SendRequestTyped status: %v", err)
}
if resp.GetError() == "" {
t.Error("expected an explicit error when no status provider is configured")
}
if len(resp.GetNodes()) != 0 {
t.Errorf("expected no nodes without provider, got %d", len(resp.GetNodes()))
}
}
// TestConnectorDisabledNoOp verifies that Start returns immediately when
// enabled=false, without touching the network.
func TestConnectorDisabledNoOp(t *testing.T) {

View file

@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v5.29.3
// protoc v3.21.12
// source: proto/iop/control.proto
package iop
@ -492,6 +492,219 @@ func (x *EdgeHelloResponse) GetReason() string {
return ""
}
// EdgeStatusRequest asks a connected Edge to report its Edge-owned node
// registry snapshot. It carries only a correlation id; the Control Plane does
// not address, connect to, or schedule Node directly through this contract.
type EdgeStatusRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EdgeStatusRequest) Reset() {
*x = EdgeStatusRequest{}
mi := &file_proto_iop_control_proto_msgTypes[7]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EdgeStatusRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EdgeStatusRequest) ProtoMessage() {}
func (x *EdgeStatusRequest) ProtoReflect() protoreflect.Message {
mi := &file_proto_iop_control_proto_msgTypes[7]
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 EdgeStatusRequest.ProtoReflect.Descriptor instead.
func (*EdgeStatusRequest) Descriptor() ([]byte, []int) {
return file_proto_iop_control_proto_rawDescGZIP(), []int{7}
}
func (x *EdgeStatusRequest) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
// EdgeNodeSnapshot is a surface-neutral view of a node owned by the Edge. It
// only mirrors what the Edge service exposes and must not carry Node address,
// token, or scheduling fields.
type EdgeNodeSnapshot struct {
state protoimpl.MessageState `protogen:"open.v1"`
NodeId string `protobuf:"bytes,1,opt,name=node_id,json=nodeId,proto3" json:"node_id,omitempty"`
Alias string `protobuf:"bytes,2,opt,name=alias,proto3" json:"alias,omitempty"`
Label string `protobuf:"bytes,3,opt,name=label,proto3" json:"label,omitempty"`
Connected bool `protobuf:"varint,4,opt,name=connected,proto3" json:"connected,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EdgeNodeSnapshot) Reset() {
*x = EdgeNodeSnapshot{}
mi := &file_proto_iop_control_proto_msgTypes[8]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EdgeNodeSnapshot) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EdgeNodeSnapshot) ProtoMessage() {}
func (x *EdgeNodeSnapshot) ProtoReflect() protoreflect.Message {
mi := &file_proto_iop_control_proto_msgTypes[8]
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 EdgeNodeSnapshot.ProtoReflect.Descriptor instead.
func (*EdgeNodeSnapshot) Descriptor() ([]byte, []int) {
return file_proto_iop_control_proto_rawDescGZIP(), []int{8}
}
func (x *EdgeNodeSnapshot) GetNodeId() string {
if x != nil {
return x.NodeId
}
return ""
}
func (x *EdgeNodeSnapshot) GetAlias() string {
if x != nil {
return x.Alias
}
return ""
}
func (x *EdgeNodeSnapshot) GetLabel() string {
if x != nil {
return x.Label
}
return ""
}
func (x *EdgeNodeSnapshot) GetConnected() bool {
if x != nil {
return x.Connected
}
return false
}
// EdgeStatusResponse returns the Edge-owned node snapshot list in reply to an
// EdgeStatusRequest. The Control Plane observes this snapshot rather than
// replicating the Edge-local registry.
type EdgeStatusResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
RequestId string `protobuf:"bytes,1,opt,name=request_id,json=requestId,proto3" json:"request_id,omitempty"`
EdgeId string `protobuf:"bytes,2,opt,name=edge_id,json=edgeId,proto3" json:"edge_id,omitempty"`
EdgeName string `protobuf:"bytes,3,opt,name=edge_name,json=edgeName,proto3" json:"edge_name,omitempty"`
ObservedTimeUnixNano int64 `protobuf:"varint,4,opt,name=observed_time_unix_nano,json=observedTimeUnixNano,proto3" json:"observed_time_unix_nano,omitempty"`
Nodes []*EdgeNodeSnapshot `protobuf:"bytes,5,rep,name=nodes,proto3" json:"nodes,omitempty"`
Metadata map[string]string `protobuf:"bytes,6,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"`
Error string `protobuf:"bytes,7,opt,name=error,proto3" json:"error,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *EdgeStatusResponse) Reset() {
*x = EdgeStatusResponse{}
mi := &file_proto_iop_control_proto_msgTypes[9]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *EdgeStatusResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*EdgeStatusResponse) ProtoMessage() {}
func (x *EdgeStatusResponse) ProtoReflect() protoreflect.Message {
mi := &file_proto_iop_control_proto_msgTypes[9]
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 EdgeStatusResponse.ProtoReflect.Descriptor instead.
func (*EdgeStatusResponse) Descriptor() ([]byte, []int) {
return file_proto_iop_control_proto_rawDescGZIP(), []int{9}
}
func (x *EdgeStatusResponse) GetRequestId() string {
if x != nil {
return x.RequestId
}
return ""
}
func (x *EdgeStatusResponse) GetEdgeId() string {
if x != nil {
return x.EdgeId
}
return ""
}
func (x *EdgeStatusResponse) GetEdgeName() string {
if x != nil {
return x.EdgeName
}
return ""
}
func (x *EdgeStatusResponse) GetObservedTimeUnixNano() int64 {
if x != nil {
return x.ObservedTimeUnixNano
}
return 0
}
func (x *EdgeStatusResponse) GetNodes() []*EdgeNodeSnapshot {
if x != nil {
return x.Nodes
}
return nil
}
func (x *EdgeStatusResponse) GetMetadata() map[string]string {
if x != nil {
return x.Metadata
}
return nil
}
func (x *EdgeStatusResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
var File_proto_iop_control_proto protoreflect.FileDescriptor
const file_proto_iop_control_proto_rawDesc = "" +
@ -541,7 +754,27 @@ const file_proto_iop_control_proto_rawDesc = "" +
"\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"
"\x06reason\x18\x05 \x01(\tR\x06reason\"2\n" +
"\x11EdgeStatusRequest\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\"u\n" +
"\x10EdgeNodeSnapshot\x12\x17\n" +
"\anode_id\x18\x01 \x01(\tR\x06nodeId\x12\x14\n" +
"\x05alias\x18\x02 \x01(\tR\x05alias\x12\x14\n" +
"\x05label\x18\x03 \x01(\tR\x05label\x12\x1c\n" +
"\tconnected\x18\x04 \x01(\bR\tconnected\"\xe3\x02\n" +
"\x12EdgeStatusResponse\x12\x1d\n" +
"\n" +
"request_id\x18\x01 \x01(\tR\trequestId\x12\x17\n" +
"\aedge_id\x18\x02 \x01(\tR\x06edgeId\x12\x1b\n" +
"\tedge_name\x18\x03 \x01(\tR\bedgeName\x125\n" +
"\x17observed_time_unix_nano\x18\x04 \x01(\x03R\x14observedTimeUnixNano\x12+\n" +
"\x05nodes\x18\x05 \x03(\v2\x15.iop.EdgeNodeSnapshotR\x05nodes\x12A\n" +
"\bmetadata\x18\x06 \x03(\v2%.iop.EdgeStatusResponse.MetadataEntryR\bmetadata\x12\x14\n" +
"\x05error\x18\a \x01(\tR\x05error\x1a;\n" +
"\rMetadataEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\x13Z\x11iop/proto/gen/iopb\x06proto3"
var (
file_proto_iop_control_proto_rawDescOnce sync.Once
@ -555,7 +788,7 @@ 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, 10)
var file_proto_iop_control_proto_msgTypes = make([]protoimpl.MessageInfo, 14)
var file_proto_iop_control_proto_goTypes = []any{
(*PolicyRule)(nil), // 0: iop.PolicyRule
(*ScheduleRequest)(nil), // 1: iop.ScheduleRequest
@ -564,20 +797,26 @@ var file_proto_iop_control_proto_goTypes = []any{
(*ClientHelloResponse)(nil), // 4: iop.ClientHelloResponse
(*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
(*EdgeStatusRequest)(nil), // 7: iop.EdgeStatusRequest
(*EdgeNodeSnapshot)(nil), // 8: iop.EdgeNodeSnapshot
(*EdgeStatusResponse)(nil), // 9: iop.EdgeStatusResponse
nil, // 10: iop.PolicyRule.ParamsEntry
nil, // 11: iop.ScheduleRequest.MetadataEntry
nil, // 12: iop.EdgeHelloRequest.MetadataEntry
nil, // 13: iop.EdgeStatusResponse.MetadataEntry
}
var file_proto_iop_control_proto_depIdxs = []int32{
7, // 0: iop.PolicyRule.params:type_name -> iop.PolicyRule.ParamsEntry
0, // 1: iop.ScheduleRequest.policies:type_name -> iop.PolicyRule
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
10, // 0: iop.PolicyRule.params:type_name -> iop.PolicyRule.ParamsEntry
0, // 1: iop.ScheduleRequest.policies:type_name -> iop.PolicyRule
11, // 2: iop.ScheduleRequest.metadata:type_name -> iop.ScheduleRequest.MetadataEntry
12, // 3: iop.EdgeHelloRequest.metadata:type_name -> iop.EdgeHelloRequest.MetadataEntry
8, // 4: iop.EdgeStatusResponse.nodes:type_name -> iop.EdgeNodeSnapshot
13, // 5: iop.EdgeStatusResponse.metadata:type_name -> iop.EdgeStatusResponse.MetadataEntry
6, // [6:6] is the sub-list for method output_type
6, // [6:6] is the sub-list for method input_type
6, // [6:6] is the sub-list for extension type_name
6, // [6:6] is the sub-list for extension extendee
0, // [0:6] is the sub-list for field type_name
}
func init() { file_proto_iop_control_proto_init() }
@ -591,7 +830,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: 10,
NumMessages: 14,
NumExtensions: 0,
NumServices: 0,
},

View file

@ -61,3 +61,33 @@ message EdgeHelloResponse {
string message = 4;
string reason = 5;
}
// EdgeStatusRequest asks a connected Edge to report its Edge-owned node
// registry snapshot. It carries only a correlation id; the Control Plane does
// not address, connect to, or schedule Node directly through this contract.
message EdgeStatusRequest {
string request_id = 1;
}
// EdgeNodeSnapshot is a surface-neutral view of a node owned by the Edge. It
// only mirrors what the Edge service exposes and must not carry Node address,
// token, or scheduling fields.
message EdgeNodeSnapshot {
string node_id = 1;
string alias = 2;
string label = 3;
bool connected = 4;
}
// EdgeStatusResponse returns the Edge-owned node snapshot list in reply to an
// EdgeStatusRequest. The Control Plane observes this snapshot rather than
// replicating the Edge-local registry.
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<string, string> metadata = 6;
string error = 7;
}