diff --git a/agent-roadmap/phase/automation-runtime-bridge/PHASE.md b/agent-roadmap/phase/automation-runtime-bridge/PHASE.md index d4d91da..1898b29 100644 --- a/agent-roadmap/phase/automation-runtime-bridge/PHASE.md +++ b/agent-roadmap/phase/automation-runtime-bridge/PHASE.md @@ -50,6 +50,10 @@ CLI 실행, specialized agent 등록, bootstrap/enrollment, 원격 터미널 브 - 경로: `agent-roadmap/archive/phase/automation-runtime-bridge/milestones/domain-agent-message-boundary.md` - 요약: 독립형 실행 전환으로 Edge 직접 domain payload boundary 정리가 현재 범위에서 필요 없어져 폐기한다. +- [진행중] 전역 설계 부채 리팩토링 기반 + - 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` + - 요약: 원격 터미널 브리지와 이후 운영 확장 전에 Edge, Node, Control Plane, Client, proto/config 경계의 설계 부채를 정리한다. + - [계획] 원격 터미널 브리지 POC - 경로: `agent-roadmap/phase/automation-runtime-bridge/milestones/remote-terminal-bridge-poc.md` - 요약: Agent를 설치하기 어려운 host/device를 위해 Edge broker와 Node terminal transport 기반 원격 터미널 브리지 POC를 만든다. diff --git a/agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md b/agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md new file mode 100644 index 0000000..f5655f7 --- /dev/null +++ b/agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md @@ -0,0 +1,92 @@ +# Milestone: 전역 설계 부채 리팩토링 기반 + +## 위치 + +- Roadmap: `agent-roadmap/ROADMAP.md` +- Phase: `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` + +## 목표 + +원격 터미널 브리지와 이후 Control Plane/Client 운영 확장을 얹기 전에 현재 코드베이스의 설계 부채를 정리한다. +Edge, Node, Control Plane, Client, proto/config 경계에서 책임이 섞인 부분을 분리하고, 공개 계약과 tracked 예시 설정이 장기 운영 방향을 흐리지 않게 만든다. + +## 상태 + +[진행중] + +## 구현 잠금 + +- 상태: 해제 +- 결정 필요: 없음 + +## 범위 + +- Edge CLI, Edge service, OpenAI-compatible 입력 표면의 책임 분리 +- Node CLI adapter, persistent terminal/SSE 실행기, adapter routing 기본값 정리 +- Control Plane HTTP/API handler와 fleet status 호출 구조 정리 +- Client 상태/DTO/repository/widget 경계 정리 +- proto status/legacy contract와 tracked config/artifact hygiene 정리 +- 원격 터미널 브리지 POC가 기대는 terminal/session/event 경계의 선행 리팩토링 + +## 기능 + +### Epic: [edge-boundary] Edge Boundary Refactor + +Edge 내부에서 CLI command, service orchestration, external compatibility surface가 서로 커지는 것을 막는다. + +- [ ] [edge-cmd-split] `apps/edge/cmd/edge/main.go`의 config resolution, bootstrap pack, node registration, YAML patch, smoke client 로직을 기존 동작을 유지한 채 내부 패키지 또는 작은 모듈로 분리한다. +- [ ] [edge-service-split] `apps/edge/internal/service`를 run dispatch, node command, control command, capability/status provider 책임으로 분리하고 health simulation 같은 임시 command 구현을 실제 상태 조회 경계와 분리한다. +- [ ] [openai-surface-split] `apps/edge/internal/openai`에서 HTTP lifecycle, chat request mapping, run stream/result collection, strict output policy, Ollama passthrough를 분리해 OpenAI-compatible 표면이 IOP native 기능을 흡수하지 않게 한다. +- [x] [event-bus-contract] Edge event bus의 drop, replay, immutability, future persistence 정책을 코드 경계와 테스트로 명확히 한다. 검증: `go test ./apps/edge/internal/events` 통과. + +### Epic: [node-runtime] Node Runtime Boundary + +Node CLI automation runtime을 remote terminal bridge가 재사용할 수 있는 단위와 provider-specific 실행 단위로 나눈다. + +- [ ] [cli-executor-split] `apps/node/internal/adapters/cli`의 mode dispatch와 session map 소유를 executor/strategy 경계로 분리한다. +- [ ] [terminal-core] persistent PTY 실행, screen rendering, resize/input/signal/close 기반이 될 terminal session core를 provider-specific Claude/OpenCode 처리와 분리한다. +- [ ] [router-default] Node adapter registry가 mock 기본값으로 오류를 숨기지 않도록 production 실행 경로의 empty adapter 처리 정책을 명시하고 적용한다. +- [ ] [vllm-surface] vLLM adapter skeleton을 명확히 experimental/disabled로 낮추거나 실행 가능한 streaming adapter로 승격한다. + +### Epic: [cp-client] Control Plane and Client Boundary + +Control Plane과 Flutter Client가 multi-edge 운영면으로 커질 때 선형 지연과 상태 결합이 커지지 않게 한다. + +- [ ] [cp-api-split] `apps/control-plane/cmd/control-plane/main.go`의 server lifecycle, HTTP handler, DTO conversion, Edge command/fleet orchestration을 분리한다. +- [ ] [fleet-polling] `/fleet/status`와 fleet command 호출을 bounded concurrency, cache, timeout 정책으로 정리한다. +- [ ] [client-state-split] Flutter client의 bootstrap, status state, DTO, repository, panel widget 경계를 분리하고 Firebase/Mattermost 같은 외부 통합은 선택적으로 초기화되게 한다. + +### Epic: [contract-hygiene] Contract and Repository Hygiene + +공개 계약과 tracked 예시 파일이 실제 운영 원칙과 어긋나지 않게 정리한다. + +- [ ] [status-config-summary] Control Plane status contract에서 full `NodeConfigPayload` 노출 여지를 제거하거나 summary-only DTO로 고정한다. +- [ ] [proto-legacy] legacy scheduling/node registration proto placeholder를 deprecated/reserved/remove 중 하나로 정리하고 생성 코드와 call-site를 맞춘다. +- [ ] [sample-config] tracked `configs/*.yaml`에서 로컬 IP, host명, token, 위험한 bypass flag, 개인 경로를 제거하고 안전한 example/default 기준으로 재정리한다. +- [ ] [git-artifacts] tracked Flutter build artifact를 제거하고 package build 산출물이 다시 추적되지 않도록 ignore 규칙을 보강한다. + +## 완료 리뷰 + +- 상태: 없음 +- 요청일: 없음 +- 완료 근거: 모든 기능 Task와 Task 안에 명시된 검증이 아직 충족되지 않았다. +- 리뷰 필요: + - [ ] 사용자가 완료 결과를 확인했다 + - [ ] archive 이동을 승인했다 +- 리뷰 코멘트: 없음 + +## 범위 제외 + +- 원격 터미널 브리지 open/input/output/resize/signal/close 기능 구현 +- 새로운 terminal transport(SSH/WinRM/serial) 구현 +- OpenAI Responses API, A2A streaming, provider 품질 최적화 같은 신규 제품 기능 +- 정책/audit 상세 schema 확정 +- 완료된 archive 문서 재포맷 + +## 작업 컨텍스트 + +- 관련 경로: `apps/edge`, `apps/node`, `apps/control-plane`, `apps/client`, `packages/flutter/iop_console`, `proto/iop`, `configs`, `.gitignore` +- 표준선(선택): Control Plane은 Edge를 통해 관찰/제어하고, Edge는 Node 실행 그룹 상태를 소유하며, Node는 adapter execution과 terminal transport 실행자 역할을 유지한다. OpenAI-compatible/A2A 표면은 IOP native terminal 제어를 흡수하지 않는다. +- 선행 작업: 설계 부채 색인 리뷰 +- 후속 작업: 원격 터미널 브리지 POC +- 확인 필요: 없음 diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/code_review_local_G05_0.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/code_review_local_G05_0.log new file mode 100644 index 0000000..2728a39 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/code_review_local_G05_0.log @@ -0,0 +1,166 @@ + + +# Code Review Reference - REFACTOR + +> **[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. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> 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-06-06 +task=m-architecture-refactor-foundation/01_edge_cmd_split, plan=0, tag=REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `edge-cmd-split`: `apps/edge/cmd/edge/main.go`의 config resolution, bootstrap pack, node registration, YAML patch, smoke client 로직을 기존 동작을 유지한 채 내부 패키지 또는 작은 모듈로 분리한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-local-G05.md` -> `code_review_local_G05_N.log`, `PLAN-local-G05.md` -> `plan_local_G05_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/01_edge_cmd_split/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-architecture-refactor-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REFACTOR-1] Extract Edge Command Support Modules | [x] | + +## 구현 체크리스트 + +- [x] `apps/edge/cmd/edge/main.go`에서 Cobra root/main/serve/console wiring만 남기고 config/env/bootstrap/node-register/YAML patch/smoke helper를 `apps/edge/internal/edgecmd` 같은 내부 package의 작은 파일들로 이동한다. +- [x] 기존 command help, flags, default config discovery, one-line bootstrap output, YAML preservation, smoke client 응답 검증을 유지하도록 `apps/edge/cmd/edge/main_test.go` 테스트를 새 package boundary에 맞게 옮기거나 조정한다. +- [x] `go test ./apps/edge/cmd/edge`와 `go test ./apps/edge/...`를 실행해 command package와 edge 전체 회귀를 확인한다. +- [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_local_G05_N.log`로 아카이브한다. +- [x] active `PLAN-*-G??.md`를 `plan_local_G05_M.log`로 아카이브한다. +- [x] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [x] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/01_edge_cmd_split/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/01_edge_cmd_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [x] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-local-G05.md`와 `CODE_REVIEW-local-G05.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로 이동한다. + +## 계획 대비 변경 사항 + +계획대로 `apps/edge/internal/edgecmd` 패키지로 command support 로직을 분리했다. + +- `main.go`는 `edgecmd.NewRoot()` 호출과 `serve`/`console` runtime config 조립만 남겼다. +- `edgecmd`는 root/help/version/config/env/setup/bootstrap/node/nodes/smoke command와 config discovery, bootstrap pack, YAML patch, smoke client helper를 소유한다. +- helper 직접 호출 테스트는 `apps/edge/internal/edgecmd/edgecmd_test.go`로 이동했고, `main_test.go`는 root command surface와 command-level 회귀 중심으로 유지했다. + +## 주요 설계 결정 + +- `serve`/`console` command는 Edge runtime bootstrap과 interactive console을 직접 호출해야 하므로 main package에 남기고, 공통 config/log/artifact path 해석은 `edgecmd.LoadEdgeConfig`, `edgecmd.ResolveEdgeLogPath`, `edgecmd.ResolveBootstrapArtifactDir`로 호출하게 했다. +- `edgecmd.NewRoot`는 main package가 제공하는 `serve`/`console` command를 받아 기존 command surface와 help flow를 유지한다. +- YAML patch와 bootstrap command 출력은 기존 보존형 동작을 유지했고, moved helper tests로 artifact URL derivation, node register upsert, log path fallback, bootstrap YAML patch를 직접 검증한다. + +## 사용자 리뷰 요청 + +_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `main.go`가 Cobra/root/serve/console wiring 중심으로 얇아졌는지 확인한다. +- command 이름, flags, help flow, bootstrap one-line command 출력이 유지되는지 확인한다. +- YAML patch가 기존 non-node/defaulted sections를 새로 materialize하지 않는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### REFACTOR-1 중간 검증 +```bash +$ go test ./apps/edge/cmd/edge +ok iop/apps/edge/cmd/edge 0.026s +``` + +### 최종 검증 +```bash +$ go test ./apps/edge/... +ok iop/apps/edge/cmd/edge 0.027s +ok iop/apps/edge/internal/bootstrap (cached) +ok iop/apps/edge/internal/controlplane (cached) +ok iop/apps/edge/internal/edgecmd 0.007s +ok iop/apps/edge/internal/events (cached) +ok iop/apps/edge/internal/input (cached) +ok iop/apps/edge/internal/input/a2a (cached) +ok iop/apps/edge/internal/node (cached) +ok iop/apps/edge/internal/openai (cached) +ok iop/apps/edge/internal/opsconsole (cached) +ok iop/apps/edge/internal/service (cached) +ok iop/apps/edge/internal/transport (cached) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. + +## 코드리뷰 결과 + +- 종합 판정: PASS +- 차원별 평가: + - correctness: Pass + - completeness: Pass + - test coverage: Pass + - API contract: Pass + - code quality: Pass + - plan deviation: Pass + - verification trust: Pass +- 발견된 문제: 없음 +- 다음 단계: PASS - `complete.log`를 작성하고 active task 디렉터리를 archive로 이동한다. + +### 리뷰 근거 + +- `main.go`는 `edgecmd.NewRoot`와 `loadRuntimeConfig` 중심으로 축소되었고, serve/console wiring만 main package에 남았다. +- config/env/bootstrap/node-register/YAML patch/nodes/smoke 구현은 `apps/edge/internal/edgecmd`로 이동했으며 기존 command surface와 helper 테스트가 유지되었다. +- 검증 재실행: + - `go test -count=1 ./apps/edge/cmd/edge` 통과 + - `go test -count=1 ./apps/edge/...` 통과 + - `make pack-node-target NODE_TARGET=$(go env GOOS)-$(go env GOARCH)` 통과 + - `./scripts/e2e-smoke.sh` 통과 diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/complete.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/complete.log new file mode 100644 index 0000000..a907530 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/complete.log @@ -0,0 +1,44 @@ +# Complete - m-architecture-refactor-foundation/01_edge_cmd_split + +## 완료 일시 + +2026-06-06 + +## 요약 + +Edge command support split review loop 1 completed with final verdict PASS. + +## 루프 이력 + +| Plan | Review | Verdict | 메모 | +|------|--------|---------|------| +| `plan_local_G05_0.log` | `code_review_local_G05_0.log` | PASS | `apps/edge/cmd/edge/main.go` was reduced to root/serve/console wiring while command support logic moved to `apps/edge/internal/edgecmd`; no Required or Suggested findings. | + +## 구현/정리 내용 + +- Split config discovery, env reporting, setup, bootstrap pack, node registration/YAML patch, nodes list, version, and OpenAI smoke command support into `apps/edge/internal/edgecmd`. +- Kept `serve` and `console` runtime entrypoints in `apps/edge/cmd/edge/main.go`, using `edgecmd.LoadEdgeConfig`, `edgecmd.ResolveEdgeLogPath`, and `edgecmd.ResolveBootstrapArtifactDir`. +- Moved helper-level tests into `apps/edge/internal/edgecmd/edgecmd_test.go` while keeping command surface regression tests in `apps/edge/cmd/edge/main_test.go`. + +## 최종 검증 + +- `go test -count=1 ./apps/edge/cmd/edge` - PASS; `ok iop/apps/edge/cmd/edge 0.035s`. +- `go test -count=1 ./apps/edge/...` - PASS; all edge packages passed, including `apps/edge/internal/edgecmd`. +- `make pack-node-target NODE_TARGET=$(go env GOOS)-$(go env GOARCH)` - PASS; built host edge binary, built `iop-node-linux-arm64`, and wrote bootstrap artifacts to `build/artifacts`. +- `cd build/artifacts/linux-arm64 && sha256sum -c SHA256SUMS` - PASS; `iop-node: OK`. +- `./scripts/e2e-smoke.sh` - PASS; auxiliary mock smoke verified node registration, `/nodes`, `/capabilities`, `/transport`, two foreground messages, background run, `/sessions`, and `/terminate-session`. + +## Roadmap Completion + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Completed task ids: + - `edge-cmd-split`: PASS; evidence=`agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/plan_local_G05_0.log`, `agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/code_review_local_G05_0.log`; verification=`go test -count=1 ./apps/edge/cmd/edge`, `go test -count=1 ./apps/edge/...`, `make pack-node-target NODE_TARGET=$(go env GOOS)-$(go env GOARCH)`, `./scripts/e2e-smoke.sh` +- Not completed task ids: 없음 + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/plan_local_G05_0.log b/agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/plan_local_G05_0.log new file mode 100644 index 0000000..115a4f4 --- /dev/null +++ b/agent-task/archive/2026/06/m-architecture-refactor-foundation/01_edge_cmd_split/plan_local_G05_0.log @@ -0,0 +1,159 @@ + + +# Edge Cmd Split Plan + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-local-G05.md`의 구현 에이전트 소유 섹션 작성까지가 구현의 일부다. 코드 변경 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, 범위 충돌 때문에 진행할 수 없으면 review stub의 `사용자 리뷰 요청`에 근거와 재개 조건을 적고 중단한다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 최종 판정, log archive, `complete.log` 작성은 code-review 전용이다. + +## 배경 + +현재 `apps/edge/cmd/edge/main.go`는 Cobra root, config discovery, bootstrap pack, node registration, YAML patch, OpenAI smoke client까지 1500라인 이상을 한 파일에서 처리한다. Edge domain rule은 공식 local/field UX를 `iop-edge` command 표면에 모으라고 하지만, command entrypoint가 구현 세부까지 소유하면 후속 command 확장과 테스트가 계속 커진다. 이 작업은 동작과 help text를 유지하면서 command support 로직을 내부 패키지로 분리한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active `CODE_REVIEW-local-G05.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자 프롬프트를 만들지 않으며, code-review가 요청의 타당성을 판단하고 필요할 때만 `USER_REVIEW.md`를 작성한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `edge-cmd-split`: `apps/edge/cmd/edge/main.go`의 config resolution, bootstrap pack, node registration, YAML patch, smoke client 로직을 기존 동작을 유지한 채 내부 패키지 또는 작은 모듈로 분리한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` +- `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- `apps/edge/cmd/edge/main.go` +- `apps/edge/cmd/edge/main_test.go` + +### 테스트 환경 규칙 + +- `test_env=local`; `agent-test/local/rules.md`를 읽었다. +- matching profile: `agent-test/local/edge-smoke.md`. +- 적용 명령: 변경한 edge 패키지 또는 `go test ./apps/edge/...`; command-only 변경은 `go test ./apps/edge/cmd/edge`와 최종 `go test ./apps/edge/...`로 검증한다. +- OpenAI-compatible 런타임 smoke는 실제 endpoint가 필요하므로 이 리팩토링의 필수 조건은 아니다. command smoke client 동작은 `httptest` 기반 `TestSmokeOpenAICommandSuccess/Failure`로 확인한다. + +### 테스트 커버리지 공백 + +- Config discovery, config init/check, env output, bootstrap pack, node register YAML patch, nodes list, OpenAI smoke command는 `apps/edge/cmd/edge/main_test.go`에 기존 회귀 테스트가 있다. +- 새 내부 package boundary 자체는 아직 없다. 구현 시 이동한 helper별 테스트를 새 package 또는 기존 command test로 유지해야 한다. + +### 심볼 참조 + +- `resolveConfigPath`, `resolveEdgeLogPath`, `resolveBootstrapArtifactDir`, `renderEnv`, `packNodeBootstrap`, `validateEdgeConfig`, `nodeRegisterCmd`, `patchYAML`, `resolveSmokeBaseURL`, `smokeOpenAICmd` 참조는 `apps/edge/cmd/edge/main.go`와 `apps/edge/cmd/edge/main_test.go`에 한정된다. +- repo 외부 공개 API는 없다. 이동 후 main package tests that depend on unexported helpers must move with the helpers or test via root command. + +### 분할 판단 + +- shared task group: `agent-task/m-architecture-refactor-foundation/`. +- sibling subtasks: `01_edge_cmd_split` independent, `02_edge_service_split` independent, `03+02_openai_surface_split` depends on service split. +- 이 plan은 CLI command boundary만 다루며 service/openai 코드와 독립 검증 가능하므로 첫 subtask로 분리한다. + +### 범위 결정 근거 + +- `apps/edge/internal/service/**`, `apps/edge/internal/openai/**`, `apps/edge/internal/events/**`는 이 plan에서 수정하지 않는다. +- 사용자-facing command 이름, flags, help flow, bootstrap one-line command 형식은 유지한다. +- 새 외부 dependency는 추가하지 않는다. + +### 빌드 등급 + +- `local-G05`: 파일 이동과 package boundary 정리가 크지만 동작 보존형이고 기존 Go 테스트가 촘촘해 local 검증으로 충분하다. + +## 구현 체크리스트 + +- [ ] `apps/edge/cmd/edge/main.go`에서 Cobra root/main/serve/console wiring만 남기고 config/env/bootstrap/node-register/YAML patch/smoke helper를 `apps/edge/internal/edgecmd` 같은 내부 package의 작은 파일들로 이동한다. +- [ ] 기존 command help, flags, default config discovery, one-line bootstrap output, YAML preservation, smoke client 응답 검증을 유지하도록 `apps/edge/cmd/edge/main_test.go` 테스트를 새 package boundary에 맞게 옮기거나 조정한다. +- [ ] `go test ./apps/edge/cmd/edge`와 `go test ./apps/edge/...`를 실행해 command package와 edge 전체 회귀를 확인한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REFACTOR-1] Extract Edge Command Support Modules + +#### 문제 + +- [main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:42)는 root command wiring에서 시작해 config/env/bootstrap/node/smoke 구현을 같은 package/file에 둔다. +- [main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:513), [main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:914), [main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:1357)는 bootstrap pack, node register, smoke client 구현이 각각 수백 라인으로 command entrypoint에 붙어 있다. + +#### 해결 방법 + +Before: + +```go +// apps/edge/cmd/edge/main.go:42 +func rootCmd() *cobra.Command { + root := &cobra.Command{Use: "edge", ...} + root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (defaults to bundle-local edge.yaml)") + root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), envCmd(), setupCmd(), bootstrapCmd(), nodeCmd(), nodesCmd(), smokeCmd()) + return root +} +``` + +After: + +```go +// apps/edge/cmd/edge/main.go +func rootCmd() *cobra.Command { + root := edgecmd.NewRoot(edgecmd.Options{ + ConfigPath: &cfgFile, + Serve: serveCmd(), + Console: consoleCmd(), + }) + return root +} +``` + +- `apps/edge/internal/edgecmd/config.go`: config discovery, config init/print/check, validation. +- `apps/edge/internal/edgecmd/env.go`: advertise host, derived URLs, env report rendering. +- `apps/edge/internal/edgecmd/bootstrap.go`: bootstrap pack, checksum, script rendering, artifact refresh. +- `apps/edge/internal/edgecmd/node_register.go`: node register flag state, config mutation, YAML patch. +- `apps/edge/internal/edgecmd/smoke_openai.go`: smoke openai client DTOs and checks. +- Keep command behavior stable; only package/file ownership changes. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/edge/cmd/edge/main.go` +- [ ] `apps/edge/cmd/edge/main_test.go` +- [ ] `apps/edge/internal/edgecmd/*.go` +- [ ] `apps/edge/internal/edgecmd/*_test.go` + +#### 테스트 작성 + +- 기존 command tests를 이동한 helper package와 root command package에 맞춰 유지한다. +- `TestRootCmdIncludesOperationalCommands`, config/env/bootstrap/node-register/smoke tests must still cover the user-visible command surface. + +#### 중간 검증 + +```bash +go test ./apps/edge/cmd/edge +``` + +Expected: command package tests pass. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/edge/cmd/edge/main.go` | REFACTOR-1 | +| `apps/edge/cmd/edge/main_test.go` | REFACTOR-1 | +| `apps/edge/internal/edgecmd/*.go` | REFACTOR-1 | +| `apps/edge/internal/edgecmd/*_test.go` | REFACTOR-1 | + +## 최종 검증 + +```bash +go test ./apps/edge/... +``` + +Expected: all edge package tests pass. Go test cache output is acceptable for unchanged packages; use `-count=1` only if a command behavior assertion is flaky or suspected stale. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-architecture-refactor-foundation/02_edge_service_split/CODE_REVIEW-cloud-G05.md b/agent-task/m-architecture-refactor-foundation/02_edge_service_split/CODE_REVIEW-cloud-G05.md new file mode 100644 index 0000000..2d23afc --- /dev/null +++ b/agent-task/m-architecture-refactor-foundation/02_edge_service_split/CODE_REVIEW-cloud-G05.md @@ -0,0 +1,126 @@ + + +# Code Review Reference - REFACTOR + +> **[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. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> 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-06-06 +task=m-architecture-refactor-foundation/02_edge_service_split, plan=0, tag=REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `edge-service-split`: `apps/edge/internal/service`를 run dispatch, node command, control command, capability/status provider 책임으로 분리하고 health simulation 같은 임시 command 구현을 실제 상태 조회 경계와 분리한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G05.md` -> `code_review_cloud_G05_N.log`, `PLAN-cloud-G05.md` -> `plan_cloud_G05_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/02_edge_service_split/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-architecture-refactor-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REFACTOR-1] Split Service Responsibilities | [ ] | + +## 구현 체크리스트 + +- [ ] `apps/edge/internal/service`를 같은 package 안에서 `service.go`, `status_provider.go`, `run_dispatch.go`, `node_command.go`, `control_command.go` 같은 책임별 파일로 나눈다. +- [ ] `ExecuteCommand("health.check")`의 `time.Sleep` 기반 임시 성공을 제거하고 `ListNodeSnapshots`/`GetCapabilities` 기반 `HealthStatus` 또는 equivalent private boundary를 사용하게 한다. +- [ ] 기존 public service API를 유지하고 openai/A2A/opsconsole/controlplane call-site 변경이 없거나 mechanical import-only 수준인지 확인한다. +- [ ] `go test ./apps/edge/internal/service`와 `go test ./apps/edge/...`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G05_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G05_M.log`로 아카이브한다. +- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/02_edge_service_split/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/02_edge_service_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G05.md`와 `CODE_REVIEW-cloud-G05.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 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- `Service` public API compatibility가 유지되는지 확인한다. +- `health.check`가 fixed sleep/fake success가 아니라 service status boundary에서 파생되는지 확인한다. +- controlplane connector, opsconsole, A2A/OpenAI compile/test가 유지되는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### REFACTOR-1 중간 검증 +```bash +$ go test ./apps/edge/internal/service +(output) +``` + +### 최종 검증 +```bash +$ go test ./apps/edge/... +(output) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. diff --git a/agent-task/m-architecture-refactor-foundation/02_edge_service_split/PLAN-cloud-G05.md b/agent-task/m-architecture-refactor-foundation/02_edge_service_split/PLAN-cloud-G05.md new file mode 100644 index 0000000..71b4725 --- /dev/null +++ b/agent-task/m-architecture-refactor-foundation/02_edge_service_split/PLAN-cloud-G05.md @@ -0,0 +1,167 @@ + + +# Edge Service Split Plan + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G05.md`의 구현 에이전트 소유 섹션 작성까지가 구현의 일부다. 코드 변경 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, 범위 충돌 때문에 진행할 수 없으면 review stub의 `사용자 리뷰 요청`에 근거와 재개 조건을 적고 중단한다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 최종 판정, log archive, `complete.log` 작성은 code-review 전용이다. + +## 배경 + +`apps/edge/internal/service/service.go`는 node snapshot/status provider, run dispatch, cancel/session commands, node command transport, Ollama passthrough, Control Plane command execution을 한 파일에서 처리한다. Control Plane connector와 console/openai/A2A 입력 표면이 service를 공유하므로, service 내부 책임 경계가 흐리면 후속 terminal bridge와 운영 command 확장이 서로 얽힌다. 이 작업은 service package API를 보존하면서 파일/헬퍼 경계를 나누고, `health.check`의 임시 sleep 성공을 실제 Edge-owned status 요약 경계로 대체한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active `CODE_REVIEW-cloud-G05.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자 프롬프트를 만들지 않으며, code-review가 요청의 타당성을 판단하고 필요할 때만 `USER_REVIEW.md`를 작성한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `edge-service-split`: `apps/edge/internal/service`를 run dispatch, node command, control command, capability/status provider 책임으로 분리하고 health simulation 같은 임시 command 구현을 실제 상태 조회 경계와 분리한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` +- `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- `apps/edge/internal/service/service.go` +- `apps/edge/internal/service/service_test.go` + +### 테스트 환경 규칙 + +- `test_env=local`; `agent-test/local/rules.md`를 읽었다. +- matching profile: `agent-test/local/edge-smoke.md`. +- 적용 명령: 변경한 edge 패키지 또는 `go test ./apps/edge/...`. +- service는 registry/command/status boundary라 focused `go test ./apps/edge/internal/service` 뒤에 connector/console/input call-site 포함 `go test ./apps/edge/...`를 실행한다. + +### 테스트 커버리지 공백 + +- `BuildRunRequest`, `ResolveNode`, `ListNodeSnapshots`, capabilities/domain agents, `ExecuteCommand`, node command success/error는 기존 `service_test.go`가 다룬다. +- `health.check`는 현재 event sequence/status 정도만 검증하며 실제 status summary 내용은 약하다. 구현 시 `health.check`가 registry/capability snapshot을 사용한다는 테스트를 추가한다. + +### 심볼 참조 + +- `SubmitRun`, `RunHandle`, `RunDispatch`, `RunStream`은 openai, A2A, opsconsole, tests에서 참조된다. +- `ListNodeSnapshots`, `GetCapabilities`, `ExecuteCommand`는 controlplane connector status provider interface에서 참조된다. +- `NodeCommandView`, `UsageStatus`, `Capabilities`, `SessionList`, `TransportStatus`는 opsconsole status rendering에서 참조된다. +- public service method/type names should not be renamed in this task. + +### 분할 판단 + +- shared task group: `agent-task/m-architecture-refactor-foundation/`. +- sibling subtasks: `01_edge_cmd_split` independent, `02_edge_service_split` independent, `03+02_openai_surface_split` waits for this task. +- 이 plan은 service package 내부의 responsibility split이며, openai split은 service API/stream semantics와 충돌할 수 있어 후속 dependent task로 둔다. + +### 범위 결정 근거 + +- `apps/edge/internal/openai/**`와 `apps/edge/cmd/edge/**`는 이 plan에서 수정하지 않는다. +- service package import path와 public method/type names는 유지한다. +- Control Plane proto 계약, node transport proto, registry implementation은 변경하지 않는다. + +### 빌드 등급 + +- `cloud-G05`: control command/status provider 의미를 정리하고 여러 입력 표면이 의존하는 service 경계를 다루므로 architecture judgment와 regression review가 중요하다. + +## 구현 체크리스트 + +- [ ] `apps/edge/internal/service`를 같은 package 안에서 `service.go`, `status_provider.go`, `run_dispatch.go`, `node_command.go`, `control_command.go` 같은 책임별 파일로 나눈다. +- [ ] `ExecuteCommand("health.check")`의 `time.Sleep` 기반 임시 성공을 제거하고 `ListNodeSnapshots`/`GetCapabilities` 기반 `HealthStatus` 또는 equivalent private boundary를 사용하게 한다. +- [ ] 기존 public service API를 유지하고 openai/A2A/opsconsole/controlplane call-site 변경이 없거나 mechanical import-only 수준인지 확인한다. +- [ ] `go test ./apps/edge/internal/service`와 `go test ./apps/edge/...`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +### [REFACTOR-1] Split Service Responsibilities + +#### 문제 + +- [service.go](/config/workspace/iop/apps/edge/internal/service/service.go:24)는 service dependencies를 정의한 뒤 같은 파일에서 status provider, run dispatch, node commands, control commands까지 이어진다. +- [service.go](/config/workspace/iop/apps/edge/internal/service/service.go:609)는 `ExecuteCommand`가 health simulation, agent status, node command routing을 한 switch에 모으고, [service.go](/config/workspace/iop/apps/edge/internal/service/service.go:619)는 `time.Sleep`으로 임시 health check 성공을 만든다. + +#### 해결 방법 + +Before: + +```go +// apps/edge/internal/service/service.go:609 +func (s *Service) ExecuteCommand(ctx context.Context, req *iop.EdgeCommandRequest, onEvent func(*iop.EdgeCommandEvent)) (*iop.EdgeCommandResponse, error) { + switch req.Operation { + case "health.check": + onEvent(started) + time.Sleep(10 * time.Millisecond) // simulation + onEvent(completed) + return &iop.EdgeCommandResponse{Status: "completed", Summary: "Edge health check OK"}, nil + ... + } +} +``` + +After: + +```go +// apps/edge/internal/service/control_command.go +func (s *Service) ExecuteCommand(ctx context.Context, req *iop.EdgeCommandRequest, onEvent func(*iop.EdgeCommandEvent)) (*iop.EdgeCommandResponse, error) { + return s.controlCommands().Execute(ctx, req, onEvent) +} + +// apps/edge/internal/service/status_provider.go +func (s *Service) HealthStatus() HealthStatusView { + return HealthStatusView{Nodes: s.ListNodeSnapshots(), Capabilities: s.GetCapabilities()} +} +``` + +- Keep the receiver methods on `*Service` for compatibility. +- Move private helpers near their responsibility; keep shared helpers such as `normalizeTimeoutSec` in a small common file. +- Add a service test that constructs registry state and asserts `health.check` summary/events are derived from service status, not a fixed fake delay. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/edge/internal/service/service.go` +- [ ] `apps/edge/internal/service/status_provider.go` +- [ ] `apps/edge/internal/service/run_dispatch.go` +- [ ] `apps/edge/internal/service/node_command.go` +- [ ] `apps/edge/internal/service/control_command.go` +- [ ] `apps/edge/internal/service/service_test.go` or split `*_test.go` files + +#### 테스트 작성 + +- Add or update service tests for `health.check` status summary. +- Existing node command and run dispatch tests must remain green without changing external call sites. + +#### 중간 검증 + +```bash +go test ./apps/edge/internal/service +``` + +Expected: service tests pass and cover the new status-backed health command. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/edge/internal/service/service.go` | REFACTOR-1 | +| `apps/edge/internal/service/status_provider.go` | REFACTOR-1 | +| `apps/edge/internal/service/run_dispatch.go` | REFACTOR-1 | +| `apps/edge/internal/service/node_command.go` | REFACTOR-1 | +| `apps/edge/internal/service/control_command.go` | REFACTOR-1 | +| `apps/edge/internal/service/*_test.go` | REFACTOR-1 | + +## 최종 검증 + +```bash +go test ./apps/edge/... +``` + +Expected: all edge package tests pass. Go test cache output is acceptable for unchanged packages; use `-count=1` if service/control command behavior appears stale. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-architecture-refactor-foundation/03+02_openai_surface_split/CODE_REVIEW-cloud-G05.md b/agent-task/m-architecture-refactor-foundation/03+02_openai_surface_split/CODE_REVIEW-cloud-G05.md new file mode 100644 index 0000000..ec1b4eb --- /dev/null +++ b/agent-task/m-architecture-refactor-foundation/03+02_openai_surface_split/CODE_REVIEW-cloud-G05.md @@ -0,0 +1,126 @@ + + +# Code Review Reference - REFACTOR + +> **[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. +> Do not ask the user directly, present choices in chat, or call `request_user_input` during implementation; record the needed decision in `사용자 리뷰 요청` and stop for code-review. +> 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-06-06 +task=m-architecture-refactor-foundation/03+02_openai_surface_split, plan=0, tag=REFACTOR + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `openai-surface-split`: `apps/edge/internal/openai`에서 HTTP lifecycle, chat request mapping, run stream/result collection, strict output policy, Ollama passthrough를 분리해 OpenAI-compatible 표면이 IOP native 기능을 흡수하지 않게 한다. +- Completion mode: check-on-pass + +## 이 파일을 읽는 리뷰 에이전트에게 + +> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다. + +각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요. +리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다. + +1. 판정을 append한다. +2. `CODE_REVIEW-cloud-G05.md` -> `code_review_cloud_G05_N.log`, `PLAN-cloud-G05.md` -> `plan_cloud_G05_M.log`로 아카이브한다. +3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/03+02_openai_surface_split/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다. +4. PASS이고 task group이 `m-architecture-refactor-foundation`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다. +5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다. + +--- + +## 구현 항목별 완료 여부 + +| 항목 | 완료 여부 | +|------|---------| +| [REFACTOR-1] Split OpenAI-Compatible Surface Responsibilities | [ ] | + +## 구현 체크리스트 + +- [ ] Confirm predecessor `02_edge_service_split` has `complete.log` before implementation; do not start from this plan while predecessor is active/incomplete. +- [ ] Split `apps/edge/internal/openai/server.go` into lifecycle/routes, chat request mapping, run collection/SSE streaming, strict output policy, Ollama passthrough, HTTP response/types files while keeping package API stable. +- [ ] Keep OpenAI-compatible behavior unchanged: request validation, model target resolution, strict output policy, SSE chunks, Ollama passthrough status/content-type/body. +- [ ] `go test ./apps/edge/internal/openai`와 `go test ./apps/edge/...`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 코드리뷰 전용 체크리스트 + +> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다. +> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다. + +- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다. +- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다. +- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G05_N.log`로 아카이브한다. +- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G05_M.log`로 아카이브한다. +- [ ] `.gitignore`의 Agent-Ops 관리 block이 `agent-task/**/*.md`와 `agent-task/**/*.log`를 unignore하고 `agent-roadmap/current.md`를 ignore하는지 확인한다. +- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다. +- [ ] PASS이면 active task 디렉터리 `agent-task/m-architecture-refactor-foundation/03+02_openai_surface_split/`를 `agent-task/archive/YYYY/MM/m-architecture-refactor-foundation/03+02_openai_surface_split/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다. +- [ ] PASS이고 task group이 `m-`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다. +- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-architecture-refactor-foundation/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다. +- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G05.md`와 `CODE_REVIEW-cloud-G05.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 파일을 그대로 둔 채 리뷰를 요청한다. 구현 에이전트는 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._ + +- 상태: 없음 +- 사유 유형: 없음 +- 결정 필요: 없음 +- 차단 근거: 없음 +- 실행한 검증/명령: 없음 +- 자동 후속 불가 이유: 없음 +- 재개 조건: 없음 + +## 리뷰어를 위한 체크포인트 + +- 구현이 predecessor `02_edge_service_split` 완료 후 진행되었는지 확인한다. +- HTTP lifecycle/routes, chat mapping, stream collection, strict output, Ollama passthrough가 파일 경계로만 분리되고 behavior가 유지되는지 확인한다. +- OpenAI-compatible surface에 native terminal/control 기능이 섞이지 않았는지 확인한다. + +## 검증 결과 + +_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._ + +필수 규칙: +- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다. +- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다. +- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다. +- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다. +- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다. + +### REFACTOR-1 중간 검증 +```bash +$ go test ./apps/edge/internal/openai +(output) +``` + +### 최종 검증 +```bash +$ go test ./apps/edge/... +(output) +``` + +--- + +> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?** +> If anything is blank, go back and fill it in before saving this file. +> Leave review-agent-only sections unchanged. diff --git a/agent-task/m-architecture-refactor-foundation/03+02_openai_surface_split/PLAN-cloud-G05.md b/agent-task/m-architecture-refactor-foundation/03+02_openai_surface_split/PLAN-cloud-G05.md new file mode 100644 index 0000000..5d6e78a --- /dev/null +++ b/agent-task/m-architecture-refactor-foundation/03+02_openai_surface_split/PLAN-cloud-G05.md @@ -0,0 +1,187 @@ + + +# OpenAI Surface Split Plan + +## 이 파일을 읽는 구현 에이전트에게 + +`CODE_REVIEW-cloud-G05.md`의 구현 에이전트 소유 섹션 작성까지가 구현의 일부다. 코드 변경 후 검증을 실행하고 실제 변경 내용, 검증 출력, 계획 대비 변경 사항을 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. 사용자 결정, 사용자 소유 외부 환경, 범위 충돌 때문에 진행할 수 없으면 review stub의 `사용자 리뷰 요청`에 근거와 재개 조건을 적고 중단한다. 구현 중 사용자에게 직접 질문하거나 선택지를 제시하거나 `request_user_input`을 호출하지 않는다. 최종 판정, log archive, `complete.log` 작성은 code-review 전용이다. + +## 배경 + +`apps/edge/internal/openai/server.go`는 HTTP lifecycle, route handlers, chat request mapping, run result collection, SSE streaming, strict output normalization, Ollama passthrough DTO를 한 파일에 담고 있다. OpenAI-compatible 표면은 edge inbound adapter여야 하며 IOP native 기능을 흡수하지 않아야 한다. 이 작업은 service split 완료 후 OpenAI package 내부 파일 경계를 나누고, behavior는 기존 tests로 보존한다. + +## 사용자 리뷰 요청 흐름 + +구현 중 blocker는 active `CODE_REVIEW-cloud-G05.md`의 `사용자 리뷰 요청` 섹션에 기록한다. 구현 에이전트는 직접 사용자 프롬프트를 만들지 않으며, code-review가 요청의 타당성을 판단하고 필요할 때만 `USER_REVIEW.md`를 작성한다. + +## Roadmap Targets + +- Milestone: `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- Task ids: + - `openai-surface-split`: `apps/edge/internal/openai`에서 HTTP lifecycle, chat request mapping, run stream/result collection, strict output policy, Ollama passthrough를 분리해 OpenAI-compatible 표면이 IOP native 기능을 흡수하지 않게 한다. +- Completion mode: check-on-pass + +## 분석 결과 + +### 읽은 파일 + +- `agent-ops/rules/project/rules.md` +- `agent-ops/rules/project/domain/edge/rules.md` +- `agent-ops/rules/common/rules-roadmap.md` +- `agent-ops/skills/common/plan/SKILL.md` +- `agent-test/local/rules.md` +- `agent-test/local/edge-smoke.md` +- `agent-roadmap/current.md` +- `agent-roadmap/phase/automation-runtime-bridge/PHASE.md` +- `agent-roadmap/phase/automation-runtime-bridge/milestones/architecture-refactor-foundation.md` +- `apps/edge/internal/openai/server.go` +- `apps/edge/internal/openai/server_test.go` +- `apps/edge/internal/service/service.go` +- `apps/edge/internal/service/service_test.go` + +### 테스트 환경 규칙 + +- `test_env=local`; `agent-test/local/rules.md`를 읽었다. +- matching profile: `agent-test/local/edge-smoke.md`. +- 적용 명령: OpenAI-compatible 경계 변경은 focused `go test ./apps/edge/internal/openai`와 최종 `go test ./apps/edge/...`를 실행한다. +- 실제 `iop-edge smoke openai`는 live edge/node endpoint가 필요해 이 리팩토링의 기본 필수 명령으로 두지 않는다. package tests use `httptest` and fake service. + +### 테스트 커버리지 공백 + +- Existing tests cover configured target dispatch, request model fallback, Ollama options passthrough, SSE streaming, reasoning content, strict output normalization/wrapping/buffered streaming/live streaming, model list, Ollama API passthrough, collect timeout. +- HTTP lifecycle route registration is lightly covered through direct handler calls, not `Start`; implementation should avoid changing lifecycle behavior beyond moving code. + +### 심볼 참조 + +- `NewServer` and `Start` are used by `apps/edge/internal/input/manager.go`. +- `handleChatCompletions`, `handleOllamaAPI`, `collectRunResult`, `streamChatCompletion`, and strict output helpers are package-private and used by `server.go`/`server_test.go`. +- No public API rename is planned. Package-private helpers may move files but should keep names unless tests are updated in the same package. + +### 분할 판단 + +- shared task group: `agent-task/m-architecture-refactor-foundation/`. +- sibling subtasks: `01_edge_cmd_split` independent, `02_edge_service_split` independent, `03+02_openai_surface_split` depends on predecessor `02_edge_service_split`. +- predecessor resolution: `02_edge_service_split` has active plan files and no `complete.log` yet. Runtime must wait for `agent-task/m-architecture-refactor-foundation/02_edge_service_split/complete.log` or its archived equivalent before implementing this plan. + +### 범위 결정 근거 + +- Do not add Responses API, A2A streaming, new provider quality behavior, or native terminal control to OpenAI-compatible routes. +- Do not change `edgeservice.RunHandle` semantics in this task; consume the service API produced by `02_edge_service_split`. +- Do not modify proto/config contracts. + +### 빌드 등급 + +- `cloud-G05`: user-visible HTTP/SSE behavior and strict output normalization are easy to regress despite package-only refactor, so review needs careful semantic comparison. + +## 구현 체크리스트 + +- [ ] Confirm predecessor `02_edge_service_split` has `complete.log` before implementation; do not start from this plan while predecessor is active/incomplete. +- [ ] Split `apps/edge/internal/openai/server.go` into lifecycle/routes, chat request mapping, run collection/SSE streaming, strict output policy, Ollama passthrough, HTTP response/types files while keeping package API stable. +- [ ] Keep OpenAI-compatible behavior unchanged: request validation, model target resolution, strict output policy, SSE chunks, Ollama passthrough status/content-type/body. +- [ ] `go test ./apps/edge/internal/openai`와 `go test ./apps/edge/...`를 실행한다. +- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다. + +## 의존 관계 및 구현 순서 + +- Directory dependency source: `03+02_openai_surface_split` depends on sibling index `02`. +- Required predecessor: `agent-task/m-architecture-refactor-foundation/02_edge_service_split/complete.log` or matching archived `complete.log`. +- Current status at plan creation: missing; predecessor has active plan/review files only. + +### [REFACTOR-1] Split OpenAI-Compatible Surface Responsibilities + +#### 문제 + +- [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:45)는 lifecycle and route registration을 소유하고, 같은 파일의 [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:119)는 chat request mapping and dispatch, [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:259)는 SSE streaming, [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:676)는 strict output normalization을 함께 담는다. +- [server.go](/config/workspace/iop/apps/edge/internal/openai/server.go:191)의 Ollama passthrough도 같은 file에 있어 OpenAI-compatible adapter 책임이 커지고 있다. + +#### 해결 방법 + +Before: + +```go +// apps/edge/internal/openai/server.go:45 +func (s *Server) Start(ctx context.Context) error { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", s.handleHealthz) + mux.HandleFunc("/v1/models", s.handleModels) + mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions) + mux.HandleFunc("/api/", s.handleOllamaAPI) + ... +} +``` + +After: + +```go +// apps/edge/internal/openai/server.go +func (s *Server) Start(ctx context.Context) error { + mux := s.routes() + return s.serve(ctx, mux) +} + +// apps/edge/internal/openai/routes.go +func (s *Server) routes() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", s.handleHealthz) + mux.HandleFunc("/v1/models", s.handleModels) + mux.HandleFunc("/v1/chat/completions", s.handleChatCompletions) + mux.HandleFunc("/api/", s.handleOllamaAPI) + return mux +} +``` + +- `chat_handler.go`: request validation, prompt mapping, `SubmitRun`. +- `run_result.go`: `collectRunResult`, timeout/error mapping. +- `stream.go`: SSE response writing and streaming collectors. +- `strict_output.go`: XML/strict policy inference and normalization. +- `ollama_passthrough.go`: `/api/` passthrough. +- `types.go`: OpenAI request/response DTOs. +- Keep tests in package `openai`; split test files only if it improves review. + +#### 수정 파일 및 체크리스트 + +- [ ] `apps/edge/internal/openai/server.go` +- [ ] `apps/edge/internal/openai/routes.go` +- [ ] `apps/edge/internal/openai/chat_handler.go` +- [ ] `apps/edge/internal/openai/run_result.go` +- [ ] `apps/edge/internal/openai/stream.go` +- [ ] `apps/edge/internal/openai/strict_output.go` +- [ ] `apps/edge/internal/openai/ollama_passthrough.go` +- [ ] `apps/edge/internal/openai/types.go` +- [ ] `apps/edge/internal/openai/*_test.go` + +#### 테스트 작성 + +- Existing tests should continue to cover behavior. Add route/lifecycle-focused tests only if extracting `routes()` changes route registration in a way direct handler tests cannot catch. + +#### 중간 검증 + +```bash +go test ./apps/edge/internal/openai +``` + +Expected: OpenAI package tests pass. + +## 수정 파일 요약 + +| 파일 | 항목 | +|------|------| +| `apps/edge/internal/openai/server.go` | REFACTOR-1 | +| `apps/edge/internal/openai/routes.go` | REFACTOR-1 | +| `apps/edge/internal/openai/chat_handler.go` | REFACTOR-1 | +| `apps/edge/internal/openai/run_result.go` | REFACTOR-1 | +| `apps/edge/internal/openai/stream.go` | REFACTOR-1 | +| `apps/edge/internal/openai/strict_output.go` | REFACTOR-1 | +| `apps/edge/internal/openai/ollama_passthrough.go` | REFACTOR-1 | +| `apps/edge/internal/openai/types.go` | REFACTOR-1 | +| `apps/edge/internal/openai/*_test.go` | REFACTOR-1 | + +## 최종 검증 + +```bash +go test ./apps/edge/... +``` + +Expected: all edge package tests pass. Go test cache output is acceptable for unchanged packages; use `-count=1` if strict output or SSE tests appear stale. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/cmd/edge/main.go b/apps/edge/cmd/edge/main.go index bc452ed..218470d 100644 --- a/apps/edge/cmd/edge/main.go +++ b/apps/edge/cmd/edge/main.go @@ -1,33 +1,17 @@ package main import ( - "bytes" "context" - "crypto/sha256" - "encoding/json" - "errors" "fmt" - "io" - "net" - "net/http" "os" "path/filepath" - "runtime" - "sort" - "strconv" - "strings" - "time" - "github.com/google/uuid" "github.com/spf13/cobra" "go.uber.org/fx" - "gopkg.in/yaml.v3" "iop/apps/edge/internal/bootstrap" - "iop/apps/edge/internal/node" + "iop/apps/edge/internal/edgecmd" "iop/packages/go/config" - "iop/packages/go/hostsetup" - "iop/packages/go/version" ) var cfgFile string @@ -40,97 +24,11 @@ func main() { } func rootCmd() *cobra.Command { - root := &cobra.Command{ - Use: "edge", - Short: "IOP Edge — execution group controller", - Long: `IOP Edge is the backend execution group controller that coordinates multiple execution nodes. -It manages token-based node registration, node registry, node configuration delivery, and OpenAI-compatible routing. - -The official local development and field test flow is as follows: - 1. config init - Write a clean local edge.yaml configuration template. - 2. env - Verify resolved local environment, log paths, and listen URLs. - 3. node register - Add a node record to edge.yaml and generate its one-line bootstrap command. - 4. config check - Validate the edge.yaml configuration structure and constraints. - 5. serve (or console) - Start the Edge server (or interactive console) to accept connections. - 6. nodes list - List configured nodes and show their connection status. - 7. smoke openai - Perform an E2E health and chat completion smoke test on the OpenAI endpoint.`, - Example: ` iop-edge config init - iop-edge env - iop-edge node register my-node --adapter cli - iop-edge config check - iop-edge serve - iop-edge nodes list - iop-edge smoke openai --model gemma4:26b`, - } - root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (defaults to bundle-local edge.yaml)") - root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), envCmd(), setupCmd(), bootstrapCmd(), nodeCmd(), nodesCmd(), smokeCmd()) - return root -} - -// resolveConfigPath returns the edge config path to load. Candidate order: -// 1. explicit --config flag -// 2. ./edge.yaml in the current working directory -// 3. /edge.yaml beside the executable -// 4. configs/edge.yaml repo-dev fallback (only if it exists) -// -// setup uses its own /etc/iop/edge.yaml default and does not call this helper. -func resolveConfigPath(cmd *cobra.Command) (string, error) { - if isConfigExplicit(cmd) { - return cfgFile, nil - } - if _, err := os.Stat("edge.yaml"); err == nil { - return "edge.yaml", nil - } - if exe, err := os.Executable(); err == nil { - candidate := filepath.Join(filepath.Dir(exe), "edge.yaml") - if _, err := os.Stat(candidate); err == nil { - return candidate, nil - } - } - if _, err := os.Stat("configs/edge.yaml"); err == nil { - return "configs/edge.yaml", nil - } - return "", errors.New("no edge.yaml found: pass --config or run beside a bundle edge.yaml") -} - -func isConfigExplicit(cmd *cobra.Command) bool { - flag := cmd.Root().PersistentFlags().Lookup("config") - return flag != nil && flag.Changed -} - -// resolveEdgeLogPath returns the effective edge log path. If explicit is set -// it wins. Otherwise it falls back to /logs/edge.log. -func resolveEdgeLogPath(explicit string) (string, error) { - if explicit != "" { - return explicit, nil - } - exe, err := os.Executable() - if err != nil { - return "", fmt.Errorf("resolve binary: %w", err) - } - return filepath.Join(filepath.Dir(exe), "logs", "edge.log"), nil -} - -func resolveBootstrapArtifactDir(dir string) string { - if dir == "" { - dir = "artifacts" - } - if filepath.IsAbs(dir) { - return dir - } - return filepath.Join(binaryDir(), dir) -} - -// binaryDir returns the directory of the running executable. Falls back to the -// current working directory if os.Executable fails. -func binaryDir() string { - if exe, err := os.Executable(); err == nil { - return filepath.Dir(exe) - } - if wd, err := os.Getwd(); err == nil { - return wd - } - return "." + return edgecmd.NewRoot(edgecmd.Options{ + ConfigPath: &cfgFile, + Serve: serveCmd(), + Console: consoleCmd(), + }) } func serveCmd() *cobra.Command { @@ -138,21 +36,10 @@ func serveCmd() *cobra.Command { Use: "serve", Short: "Start the edge server", RunE: func(cmd *cobra.Command, _ []string) error { - cfg, _, err := loadEdgeConfig(cmd) + cfg, err := loadRuntimeConfig(cmd) if err != nil { return err } - logPath, err := resolveEdgeLogPath(cfg.Logging.Path) - if err != nil { - return err - } - if dir := filepath.Dir(logPath); dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir log dir %s: %w", dir, err) - } - } - cfg.Logging.Path = logPath - cfg.Bootstrap.ArtifactDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir) fx.New(bootstrap.Module(cfg)).Run() return nil }, @@ -164,1344 +51,30 @@ func consoleCmd() *cobra.Command { Use: "console", Short: "Start the edge server with an interactive node console", RunE: func(cmd *cobra.Command, _ []string) error { - cfg, _, err := loadEdgeConfig(cmd) + cfg, err := loadRuntimeConfig(cmd) if err != nil { return err } - logPath, err := resolveEdgeLogPath(cfg.Logging.Path) - if err != nil { - return err - } - if dir := filepath.Dir(logPath); dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir log dir %s: %w", dir, err) - } - } - cfg.Logging.Path = logPath - cfg.Bootstrap.ArtifactDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir) return runConsole(context.Background(), cfg, os.Stdin, os.Stdout) }, } } -func loadEdgeConfig(cmd *cobra.Command) (*config.EdgeConfig, string, error) { - path, err := resolveConfigPath(cmd) +func loadRuntimeConfig(cmd *cobra.Command) (*config.EdgeConfig, error) { + cfg, _, err := edgecmd.LoadEdgeConfig(cmd) if err != nil { - return nil, "", err - } - cfg, err := config.LoadEdge(path) - if err != nil { - return nil, "", fmt.Errorf("load config: %w", err) - } - return cfg, path, nil -} - -func versionCmd() *cobra.Command { - return &cobra.Command{ - Use: "version", - Short: "Print edge binary version", - RunE: func(cmd *cobra.Command, _ []string) error { - fmt.Fprintln(cmd.OutOrStdout(), version.Version) - return nil - }, - } -} - -func configCmd() *cobra.Command { - c := &cobra.Command{ - Use: "config", - Short: "Create, inspect, and validate edge config", - Long: `Create, inspect, and validate the bundle-local edge.yaml configuration. - -Typical local flow: - 1. iop-edge config init - 2. iop-edge env - 3. iop-edge node register - 4. iop-edge config check - 5. iop-edge serve`, - Example: ` iop-edge config init - iop-edge env - iop-edge node register node-silicon-ollama --adapter ollama - iop-edge config check`, - } - c.AddCommand(configInitCmd(), configPrintCmd(), configCheckCmd()) - return c -} - -func configInitCmd() *cobra.Command { - output := "edge.yaml" - var force bool - var stdout bool - c := &cobra.Command{ - Use: "init", - Short: "Write an edge.yaml template", - Long: "Write a deployable edge.yaml template to the current bundle directory or stdout.\n" + - "Run this beside a local iop-edge binary, then run `iop-edge config check`.", - Example: " iop-edge config init\n" + - " iop-edge config init --stdout\n" + - " iop-edge config init --output edge.yaml\n" + - " iop-edge config check", - RunE: func(cmd *cobra.Command, _ []string) error { - template := []byte(hostsetup.EdgeBundleConfigTemplate()) - if stdout || output == "-" { - _, err := cmd.OutOrStdout().Write(template) - return err - } - if !force { - if _, err := os.Stat(output); err == nil { - return fmt.Errorf("%s already exists (use --force to overwrite)", output) - } else if !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("stat output: %w", err) - } - } - if dir := filepath.Dir(output); dir != "" && dir != "." { - if err := os.MkdirAll(dir, 0o755); err != nil { - return fmt.Errorf("mkdir %s: %w", dir, err) - } - } - if err := os.WriteFile(output, template, 0o600); err != nil { - return fmt.Errorf("write %s: %w", output, err) - } - fmt.Fprintf(cmd.OutOrStdout(), "wrote %s\n", output) - return nil - }, - } - c.Flags().StringVarP(&output, "output", "o", output, "output path") - c.Flags().BoolVar(&stdout, "stdout", false, "print template to stdout instead of writing a file") - c.Flags().BoolVar(&force, "force", false, "overwrite an existing output file") - return c -} - -func configPrintCmd() *cobra.Command { - return &cobra.Command{ - Use: "print", - Short: "Print effective edge config as YAML", - RunE: func(cmd *cobra.Command, _ []string) error { - cfg, _, err := loadEdgeConfig(cmd) - if err != nil { - return err - } - enc := yaml.NewEncoder(cmd.OutOrStdout()) - defer enc.Close() - return enc.Encode(cfg) - }, - } -} - -func configCheckCmd() *cobra.Command { - return &cobra.Command{ - Use: "check", - Short: "Validate edge config can be loaded", - RunE: func(cmd *cobra.Command, _ []string) error { - cfg, path, err := loadEdgeConfig(cmd) - if err != nil { - return err - } - if err := validateEdgeConfig(cfg); err != nil { - return fmt.Errorf("validate config: %w", err) - } - fmt.Fprintf(cmd.OutOrStdout(), "OK %s\n", path) - return nil - }, - } -} - -// envCmd prints the resolved environment for the bundled edge binary so the -// user can verify config discovery, advertise host, log path, transport -// address, artifact/bootstrap URL, and OpenAI-compatible URL without starting -// edge. It does not bind any port or connect to nodes. -func envCmd() *cobra.Command { - return &cobra.Command{ - Use: "env", - Short: "Print resolved edge environment without starting edge", - RunE: func(cmd *cobra.Command, _ []string) error { - cfg, path, err := loadEdgeConfig(cmd) - if err != nil { - return err - } - logPath, err := resolveEdgeLogPath(cfg.Logging.Path) - if err != nil { - return err - } - cfg.Bootstrap.ArtifactDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir) - renderEnv(cmd.OutOrStdout(), envReport{ - ConfigPath: path, - BinaryDir: binaryDir(), - LogPath: logPath, - Advertise: resolveAdvertiseHost(cfg.Server.AdvertiseHost), - Server: cfg.Server, - Bootstrap: cfg.Bootstrap, - OpenAI: cfg.OpenAI, - A2A: cfg.A2A, - Nodes: cfg.Nodes, - }) - return nil - }, - } -} - -type advertise struct { - Host string - Source string // "explicit" or "auto" -} - -func resolveAdvertiseHost(explicit string) advertise { - if explicit != "" { - return advertise{Host: explicit, Source: "explicit"} - } - if ip := nonLoopbackIPv4(); ip != "" { - return advertise{Host: ip, Source: "auto"} - } - if hn, err := os.Hostname(); err == nil && hn != "" { - return advertise{Host: hn, Source: "auto"} - } - return advertise{Host: "127.0.0.1", Source: "auto"} -} - -func nonLoopbackIPv4() string { - addrs, err := net.InterfaceAddrs() - if err != nil { - return "" - } - for _, a := range addrs { - ipnet, ok := a.(*net.IPNet) - if !ok || ipnet.IP.IsLoopback() { - continue - } - ip4 := ipnet.IP.To4() - if ip4 == nil { - continue - } - return ip4.String() - } - return "" -} - -// addressFor combines an advertise host with the port from a listen string -// (e.g. "0.0.0.0:9090" -> ":9090"). If the listen string is invalid it -// is returned unchanged. -func addressFor(host, listen string) string { - _, port, err := net.SplitHostPort(listen) - if err != nil || port == "" { - return listen - } - return net.JoinHostPort(host, port) -} - -func defaultArtifactBaseURL(host string, listen ...string) string { - bootstrapListen := "0.0.0.0:18080" - if len(listen) > 0 && listen[0] != "" { - bootstrapListen = listen[0] - } - return "http://" + addressFor(host, bootstrapListen) -} - -func bootstrapScriptURL(baseURL, target string) string { - return strings.TrimSuffix(baseURL, "/") + "/bootstrap/node-" + target + ".sh" -} - -func universalBootstrapScriptURL(baseURL string) string { - return strings.TrimSuffix(baseURL, "/") + "/bootstrap/node.sh" -} - -type envReport struct { - ConfigPath string - BinaryDir string - LogPath string - Advertise advertise - Server config.EdgeServerConf - Bootstrap config.EdgeBootstrapConf - OpenAI config.EdgeOpenAIConf - A2A config.EdgeA2AConf - Nodes []config.NodeDefinition -} - -func renderEnv(w io.Writer, r envReport) { - artifactURL := strings.TrimSuffix(r.Bootstrap.ArtifactBaseURL, "/") - artifactSource := "configured" - if artifactURL == "" { - artifactURL = defaultArtifactBaseURL(r.Advertise.Host, r.Bootstrap.Listen) - artifactSource = "derived" - } - - fmt.Fprintf(w, "config: %s\n", r.ConfigPath) - fmt.Fprintf(w, "binary_dir: %s\n", r.BinaryDir) - fmt.Fprintf(w, "log_path: %s\n", r.LogPath) - fmt.Fprintf(w, "advertise_host: %s (%s)\n", r.Advertise.Host, r.Advertise.Source) - fmt.Fprintf(w, "node_transport: %s\n", addressFor(r.Advertise.Host, r.Server.Listen)) - fmt.Fprintf(w, "artifact_dir: %s\n", r.Bootstrap.ArtifactDir) - fmt.Fprintf(w, "artifact_listen: %s\n", r.Bootstrap.Listen) - fmt.Fprintf(w, "artifact_base_url: %s (%s)\n", artifactURL, artifactSource) - fmt.Fprintf(w, "node_bootstrap: %s\n", universalBootstrapScriptURL(artifactURL)) - if r.OpenAI.Enabled { - fmt.Fprintf(w, "openai_url: http://%s/v1\n", addressFor(r.Advertise.Host, r.OpenAI.Listen)) - } else { - fmt.Fprintln(w, "openai_url: (disabled)") - } - if r.A2A.Enabled { - path := r.A2A.Path - if path == "" { - path = "/" - } - fmt.Fprintf(w, "a2a_url: http://%s%s\n", addressFor(r.Advertise.Host, r.A2A.Listen), path) - } else { - fmt.Fprintln(w, "a2a_url: (disabled)") - } - fmt.Fprintf(w, "configured_nodes: %s\n", summarizeNodes(r.Nodes)) - fmt.Fprintln(w, "connected_nodes: offline (env does not start edge)") -} - -func summarizeNodes(nodes []config.NodeDefinition) string { - if len(nodes) == 0 { - return "0 (none)" - } - labels := make([]string, 0, len(nodes)) - for _, n := range nodes { - label := n.ID - if label == "" { - label = n.Alias - } - if label == "" { - label = "(unnamed)" - } - labels = append(labels, label) - } - sort.Strings(labels) - return fmt.Sprintf("%d (%s)", len(nodes), strings.Join(labels, ", ")) -} - -func setupCmd() *cobra.Command { - var opts hostsetup.SetupOptions - c := &cobra.Command{ - Use: "setup", - Short: "Prepare host environment and systemd unit for iop-edge", - RunE: func(cmd *cobra.Command, _ []string) error { - if cmd.Flags().Changed("config") { - opts.ConfigPath = cfgFile - } else { - opts.ConfigPath = "/etc/iop/edge.yaml" - } - return hostsetup.Run(cmd.Context(), hostsetup.EdgeSpec(), opts, cmd.OutOrStdout()) - }, - } - c.Flags().StringVar(&opts.BinaryPath, "binary", "", "path to iop-edge binary (defaults to current executable)") - c.Flags().StringVar(&opts.DataDir, "data-dir", "", "data directory (defaults to spec)") - c.Flags().StringVar(&opts.UnitPath, "unit", "", "systemd unit path (defaults to spec)") - c.Flags().StringVar(&opts.User, "user", "", "service user") - c.Flags().StringVar(&opts.Group, "group", "", "service group") - c.Flags().BoolVar(&opts.Enable, "enable", false, "enable the systemd unit") - c.Flags().BoolVar(&opts.Start, "start", false, "start the systemd unit") - c.Flags().BoolVar(&opts.Restart, "restart", false, "restart the systemd unit") - c.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print plan and previews without making changes") - c.Flags().BoolVar(&opts.OverwriteConfig, "overwrite-config", false, "overwrite an existing config file") - return c -} - -func bootstrapCmd() *cobra.Command { - c := &cobra.Command{ - Use: "bootstrap", - Short: "Prepare node bootstrap artifacts", - Long: `Prepare node bootstrap artifacts served by iop-edge serve. - -The default output directory is the configured bootstrap.artifact_dir resolved -beside the iop-edge binary. The directory is static and can be copied as-is.`, - } - c.AddCommand(bootstrapPackCmd()) - return c -} - -func bootstrapPackCmd() *cobra.Command { - var nodeBinary string - var outputDir string - var target string - var artifactBaseURL string - var edgeAddr string - c := &cobra.Command{ - Use: "pack", - Short: "Pack iop-node into the configured bootstrap artifact directory", - RunE: func(cmd *cobra.Command, _ []string) error { - cfg, err := loadEdgeConfigForBootstrapPack(cmd) - if err != nil { - return err - } - adv := resolveAdvertiseHost(cfg.Server.AdvertiseHost) - if nodeBinary == "" { - nodeBinary = filepath.Join(binaryDir(), "iop-node") - } - if outputDir == "" { - outputDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir) - } - if target == "" { - target = runtime.GOOS + "-" + runtime.GOARCH - } - if artifactBaseURL == "" { - if cfg.Bootstrap.ArtifactBaseURL != "" { - artifactBaseURL = strings.TrimSuffix(cfg.Bootstrap.ArtifactBaseURL, "/") - } else { - artifactBaseURL = defaultArtifactBaseURL(adv.Host, cfg.Bootstrap.Listen) - } - } - if edgeAddr == "" { - edgeAddr = addressFor(adv.Host, cfg.Server.Listen) - } - if err := packNodeBootstrap(nodeBootstrapPackOptions{ - NodeBinary: nodeBinary, - OutputDir: outputDir, - Target: target, - Version: version.Version, - ArtifactBaseURL: artifactBaseURL, - EdgeAddr: edgeAddr, - }); err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "wrote bootstrap artifacts to %s\n", outputDir) - fmt.Fprintf(cmd.OutOrStdout(), "bootstrap: %s\n", universalBootstrapScriptURL(artifactBaseURL)) - return nil - }, - } - c.Flags().StringVar(&nodeBinary, "node-binary", "", "iop-node binary to pack (defaults to iop-node beside iop-edge)") - c.Flags().StringVarP(&outputDir, "output", "o", "", "artifact output directory (defaults to bootstrap.artifact_dir)") - c.Flags().StringVar(&target, "target", "", "target platform label (defaults to current GOOS-GOARCH)") - c.Flags().StringVar(&artifactBaseURL, "artifact-base-url", "", "artifact base URL baked into the bootstrap script") - c.Flags().StringVar(&edgeAddr, "edge-addr", "", "edge transport address baked into node.yaml") - return c -} - -func loadEdgeConfigForBootstrapPack(cmd *cobra.Command) (*config.EdgeConfig, error) { - cfg, _, err := loadEdgeConfig(cmd) - if err == nil { - return cfg, nil - } - if !strings.Contains(err.Error(), "no edge.yaml found") { return nil, err } - return &config.EdgeConfig{ - Server: config.EdgeServerConf{ - Listen: "0.0.0.0:9090", - }, - Bootstrap: config.EdgeBootstrapConf{ - Listen: "0.0.0.0:18080", - ArtifactDir: "artifacts", - }, - }, nil -} - -type nodeBootstrapPackOptions struct { - NodeBinary string - OutputDir string - Target string - Version string - ArtifactBaseURL string - EdgeAddr string -} - -func packNodeBootstrap(opts nodeBootstrapPackOptions) error { - if opts.NodeBinary == "" { - return errors.New("node binary path is required") - } - if opts.OutputDir == "" { - return errors.New("output directory is required") - } - if opts.Target == "" { - return errors.New("target is required") - } - if strings.ContainsAny(opts.Target, `/\`) { - return fmt.Errorf("target %q must not contain path separators", opts.Target) - } - targetDir := filepath.Join(opts.OutputDir, opts.Target) - if err := os.MkdirAll(targetDir, 0o755); err != nil { - return fmt.Errorf("mkdir target dir: %w", err) - } - dstNode := filepath.Join(targetDir, "iop-node") - same, err := sameFile(opts.NodeBinary, dstNode) + logPath, err := edgecmd.ResolveEdgeLogPath(cfg.Logging.Path) if err != nil { - return fmt.Errorf("stat iop-node: %w", err) + return nil, err } - if same { - if err := os.Chmod(dstNode, 0o755); err != nil { - return fmt.Errorf("chmod iop-node: %w", err) - } - } else { - if err := copyFile(opts.NodeBinary, dstNode, 0o755); err != nil { - return fmt.Errorf("copy iop-node: %w", err) + if dir := filepath.Dir(logPath); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("mkdir log dir %s: %w", dir, err) } } - sum, err := fileSHA256(dstNode) - if err != nil { - return err - } - sumLine := fmt.Sprintf("%x iop-node\n", sum) - if err := os.WriteFile(filepath.Join(targetDir, "iop-node.sha256"), []byte(sumLine), 0o644); err != nil { - return fmt.Errorf("write iop-node.sha256: %w", err) - } - if err := os.WriteFile(filepath.Join(targetDir, "SHA256SUMS"), []byte(sumLine), 0o644); err != nil { - return fmt.Errorf("write SHA256SUMS: %w", err) - } - bootstrapDir := filepath.Join(opts.OutputDir, "bootstrap") - if err := os.MkdirAll(bootstrapDir, 0o755); err != nil { - return fmt.Errorf("mkdir bootstrap dir: %w", err) - } - script := nodeBootstrapScript(opts) - scriptPath := filepath.Join(bootstrapDir, "node-"+opts.Target+".sh") - if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { - return fmt.Errorf("write bootstrap script: %w", err) - } - if err := writeUniversalBootstrapScript(bootstrapDir, opts); err != nil { - return err - } - return nil -} - -func writeUniversalBootstrapScript(bootstrapDir string, opts nodeBootstrapPackOptions) error { - universalOpts := opts - universalOpts.Target = "" - scriptPath := filepath.Join(bootstrapDir, "node.sh") - if err := os.MkdirAll(bootstrapDir, 0o755); err != nil { - return fmt.Errorf("mkdir bootstrap dir: %w", err) - } - if err := os.WriteFile(scriptPath, []byte(nodeBootstrapScript(universalOpts)), 0o755); err != nil { - return fmt.Errorf("write universal bootstrap script: %w", err) - } - return nil -} - -func sameFile(a, b string) (bool, error) { - ai, err := os.Stat(a) - if err != nil { - return false, err - } - bi, err := os.Stat(b) - if errors.Is(err, os.ErrNotExist) { - return false, nil - } - if err != nil { - return false, err - } - return os.SameFile(ai, bi), nil -} - -func copyFile(src, dst string, mode os.FileMode) error { - in, err := os.Open(src) - if err != nil { - return err - } - defer in.Close() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) - if err != nil { - return err - } - if _, err := io.Copy(out, in); err != nil { - _ = out.Close() - return err - } - if err := out.Close(); err != nil { - return err - } - return os.Chmod(dst, mode) -} - -func fileSHA256(path string) ([]byte, error) { - f, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open checksum file: %w", err) - } - defer f.Close() - h := sha256.New() - if _, err := io.Copy(h, f); err != nil { - return nil, fmt.Errorf("hash file: %w", err) - } - return h.Sum(nil), nil -} - -func shellQuote(s string) string { - return strconv.Quote(s) -} - -func nodeBootstrapScript(opts nodeBootstrapPackOptions) string { - return `#!/usr/bin/env bash -set -euo pipefail - -VERSION=` + shellQuote(opts.Version) + ` -DEFAULT_TARGET=` + shellQuote(opts.Target) + ` -DEFAULT_ARTIFACT_BASE_URL=` + shellQuote(strings.TrimSuffix(opts.ArtifactBaseURL, "/")) + ` -DEFAULT_EDGE_ADDR=` + shellQuote(opts.EdgeAddr) + ` -POSITIONAL_NODE_TOKEN="${1:-}" -TARGET="$DEFAULT_TARGET" -ARTIFACT_BASE_URL="$DEFAULT_ARTIFACT_BASE_URL" -EDGE_ADDR="$DEFAULT_EDGE_ADDR" -NODE_TOKEN="$POSITIONAL_NODE_TOKEN" -INSTALL_DIR="${IOP_HOME:-$HOME/iop-field}" -CONFIG_FILE="${IOP_NODE_CONFIG:-$INSTALL_DIR/node.yaml}" -TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iop-node-bootstrap.XXXXXX")" - -detect_target() { - local os arch - case "$(uname -s)" in - Linux*) os="linux" ;; - Darwin*) os="darwin" ;; - MINGW*|MSYS*|CYGWIN*) os="windows" ;; - *) - echo "[iop-bootstrap] unsupported OS: $(uname -s)" >&2 - return 2 - ;; - esac - - case "$(uname -m)" in - x86_64|amd64) arch="amd64" ;; - arm64|aarch64) arch="arm64" ;; - *) - echo "[iop-bootstrap] unsupported architecture: $(uname -m)" >&2 - return 2 - ;; - esac - - printf '%s-%s\n' "$os" "$arch" -} - -cleanup() { - rm -rf "$TMP_DIR" -} -trap cleanup EXIT - -ARTIFACT_BASE_URL="${ARTIFACT_BASE_URL%/}" - -if [[ -z "$NODE_TOKEN" ]]; then - echo "[iop-bootstrap] missing node token" >&2 - echo "[iop-bootstrap] usage: curl -fsSL /bootstrap/node.sh | bash -s TOKEN" >&2 - exit 2 -fi - -if [[ -z "$TARGET" ]]; then - TARGET="$(detect_target)" -fi - -BIN_FILE="$INSTALL_DIR/iop-node" -if [[ "$TARGET" == windows-* ]]; then - BIN_FILE="$INSTALL_DIR/iop-node.exe" -fi - -echo "[iop-bootstrap] version=$VERSION target=$TARGET" -echo "[iop-bootstrap] install_dir=$INSTALL_DIR" -mkdir -p "$INSTALL_DIR" - -curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/iop-node" -o "$TMP_DIR/iop-node" -curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/SHA256SUMS" -o "$TMP_DIR/SHA256SUMS" - -if command -v sha256sum >/dev/null 2>&1; then - (cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | sha256sum -c -) -elif command -v shasum >/dev/null 2>&1; then - (cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | shasum -a 256 -c -) -else - echo "[iop-bootstrap] warning: no sha256 checker found; skipping checksum verification" >&2 -fi - -cp "$TMP_DIR/iop-node" "$BIN_FILE" -chmod +x "$BIN_FILE" -if command -v xattr >/dev/null 2>&1; then - xattr -d com.apple.quarantine "$BIN_FILE" 2>/dev/null || true -fi - -cat > "$CONFIG_FILE" < 0 { - nodesValNode = tempDoc.Content[0] - } else { - nodesValNode = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} - } - - foundNodes := false - for i := 0; i < len(rootMap.Content); i += 2 { - if rootMap.Content[i].Value == "nodes" { - rootMap.Content[i+1] = nodesValNode - foundNodes = true - break - } - } - if !foundNodes { - keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "nodes"} - rootMap.Content = append(rootMap.Content, keyNode, nodesValNode) - } - - // 2. Patch bootstrap fields if any are present in the config - fieldsToPatch := make(map[string]string) - if cfg.Bootstrap.ArtifactBaseURL != "" { - fieldsToPatch["artifact_base_url"] = cfg.Bootstrap.ArtifactBaseURL - } - patchBootstrapStringFields(rootMap, fieldsToPatch) - - out, err := yaml.Marshal(&doc) - if err != nil { - return nil, fmt.Errorf("marshal patched doc: %w", err) - } - return out, nil -} - -func patchBootstrapStringFields(rootMap *yaml.Node, fields map[string]string) { - if len(fields) == 0 { - return - } - - var bootstrapVal *yaml.Node - foundBootstrap := false - for i := 0; i < len(rootMap.Content); i += 2 { - if rootMap.Content[i].Value == "bootstrap" { - bootstrapVal = rootMap.Content[i+1] - foundBootstrap = true - break - } - } - - if !foundBootstrap { - bootstrapKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bootstrap"} - bootstrapVal = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} - rootMap.Content = append(rootMap.Content, bootstrapKey, bootstrapVal) - } - - if bootstrapVal.Kind != yaml.MappingNode { - return - } - - // Iterate in a deterministic order - keys := []string{"artifact_base_url"} - for _, k := range keys { - v, ok := fields[k] - if !ok || v == "" { - continue - } - foundField := false - for j := 0; j < len(bootstrapVal.Content); j += 2 { - if bootstrapVal.Content[j].Value == k { - bootstrapVal.Content[j+1].Value = v - bootstrapVal.Content[j+1].Tag = "!!str" - foundField = true - break - } - } - if !foundField { - keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k} - valNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v} - bootstrapVal.Content = append(bootstrapVal.Content, keyNode, valNode) - } - } -} - -func nodesCmd() *cobra.Command { - c := &cobra.Command{ - Use: "nodes", - Short: "Inspect registered edge nodes", - Long: `Inspect and list configured or connected execution nodes.`, - } - c.AddCommand(nodesListCmd()) - return c -} - -func nodesListCmd() *cobra.Command { - return &cobra.Command{ - Use: "list", - Short: "List configured edge nodes and connection status", - Long: `List all nodes registered in the edge configuration file. -When the server is offline or this command is run standalone, all nodes will show their status as 'offline' or 'configured'.`, - Example: ` iop-edge nodes list`, - RunE: func(cmd *cobra.Command, _ []string) error { - cfg, path, err := loadEdgeConfig(cmd) - if err != nil { - return err - } - fmt.Fprintf(cmd.OutOrStdout(), "Config Source: %s\n\n", path) - if len(cfg.Nodes) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No nodes configured.") - return nil - } - - // Sort nodes by ID for deterministic output - nodes := make([]config.NodeDefinition, len(cfg.Nodes)) - copy(nodes, cfg.Nodes) - sort.Slice(nodes, func(i, j int) bool { - return nodes[i].ID < nodes[j].ID - }) - - fmt.Fprintf(cmd.OutOrStdout(), "%-25s %-20s %-40s %-12s\n", "NODE ID", "ALIAS", "TOKEN", "STATUS") - fmt.Fprintf(cmd.OutOrStdout(), "%s\n", strings.Repeat("-", 100)) - for _, n := range nodes { - fmt.Fprintf(cmd.OutOrStdout(), "%-25s %-20s %-40s %-12s\n", n.ID, n.Alias, n.Token, "offline (configured)") - } - return nil - }, - } -} - -func smokeCmd() *cobra.Command { - c := &cobra.Command{ - Use: "smoke", - Short: "Run verification and smoke tests", - Long: `Run diagnostic smoke tests to verify the connectivity and functionality of IOP Edge components. - -Typical local flow: - 1. iop-edge config init - 2. iop-edge node register - 3. iop-edge serve - 4. iop-edge smoke openai --model `, - Example: ` iop-edge smoke openai --model gemma4:26b - iop-edge smoke openai --model gemma4:26b --base-url http://127.0.0.1:8080`, - } - c.AddCommand(smokeOpenAICmd()) - return c -} - -var ( - smokeModel string - smokeBaseURL string - smokePrompt string - smokeTimeout string -) - -type openAIModel struct { - ID string `json:"id"` - Object string `json:"object"` - Created int64 `json:"created"` - OwnedBy string `json:"owned_by"` -} - -type openAIModelsResponse struct { - Object string `json:"object"` - Data []openAIModel `json:"data"` -} - -type chatCompletionMessage struct { - Role string `json:"role"` - Content string `json:"content"` -} - -type chatCompletionChoice struct { - Index int `json:"index"` - Message chatCompletionMessage `json:"message"` - FinishReason string `json:"finish_reason"` -} - -type chatCompletionUsage struct { - PromptTokens int `json:"prompt_tokens"` - CompletionTokens int `json:"completion_tokens"` - TotalTokens int `json:"total_tokens"` -} - -type chatCompletionResponse struct { - ID string `json:"id"` - Object string `json:"object"` - Created int64 `json:"created"` - Model string `json:"model"` - Choices []chatCompletionChoice `json:"choices"` - Usage *chatCompletionUsage `json:"usage"` -} - -func resolveSmokeBaseURL(cmd *cobra.Command) (string, error) { - if smokeBaseURL != "" { - return strings.TrimSuffix(smokeBaseURL, "/"), nil - } - cfg, _, err := loadEdgeConfig(cmd) - if err != nil { - return "", fmt.Errorf("failed to load edge config to resolve base URL (pass --base-url to override): %w", err) - } - listen := cfg.OpenAI.Listen - if listen == "" { - listen = "127.0.0.1:8080" - } - host, port, err := net.SplitHostPort(listen) - if err != nil { - return "http://127.0.0.1:8080", nil - } - if host == "0.0.0.0" || host == "" { - host = "127.0.0.1" - } - return "http://" + net.JoinHostPort(host, port), nil -} - -func smokeOpenAICmd() *cobra.Command { - c := &cobra.Command{ - Use: "openai", - Short: "Perform OpenAI-compatible endpoint smoke test", - Long: `Validate the IOP Edge OpenAI-compatible HTTP API surface. -It runs a three-step diagnostics check: - 1. Checks GET /healthz (server status). - 2. Checks GET /v1/models (advertised model compatibility). - 3. Checks POST /v1/chat/completions (inference request processing). - -If --base-url is not provided, it auto-discovers the OpenAI URL from the effective config's listen address.`, - Example: ` iop-edge smoke openai --model gemma4:26b - iop-edge smoke openai --model gemma4:26b --base-url http://127.0.0.1:8080 --prompt "say hello" --timeout 15s`, - RunE: func(cmd *cobra.Command, _ []string) error { - baseURL, err := resolveSmokeBaseURL(cmd) - if err != nil { - return err - } - - timeoutDur := 10 * time.Second - if smokeTimeout != "" { - d, err := time.ParseDuration(smokeTimeout) - if err != nil { - return fmt.Errorf("invalid timeout duration: %w", err) - } - timeoutDur = d - } - - client := &http.Client{ - Timeout: timeoutDur, - } - - fmt.Fprintf(cmd.OutOrStdout(), "IOP Edge OpenAI Smoke Test\n") - fmt.Fprintf(cmd.OutOrStdout(), "========================================\n") - fmt.Fprintf(cmd.OutOrStdout(), "Target Endpoint: %s\n", baseURL) - fmt.Fprintf(cmd.OutOrStdout(), "Target Model: %s\n", smokeModel) - fmt.Fprintf(cmd.OutOrStdout(), "Timeout: %s\n\n", timeoutDur) - - // Step 1: Health check - fmt.Fprintf(cmd.OutOrStdout(), "Step 1: Checking /healthz ... ") - healthURL := baseURL + "/healthz" - resp, err := client.Get(healthURL) - if err != nil { - fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") - return fmt.Errorf("GET %s failed: %w (make sure the edge server is running)", healthURL, err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") - return fmt.Errorf("GET %s returned non-200 status %d: %s", healthURL, resp.StatusCode, string(body)) - } - var healthData map[string]interface{} - if err := json.NewDecoder(resp.Body).Decode(&healthData); err != nil { - fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") - return fmt.Errorf("failed to parse /healthz JSON response: %w", err) - } - fmt.Fprintln(cmd.OutOrStdout(), "[OK]") - - // Step 2: Models check - fmt.Fprintf(cmd.OutOrStdout(), "Step 2: Checking /v1/models ... ") - modelsURL := baseURL + "/v1/models" - resp, err = client.Get(modelsURL) - if err != nil { - fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") - return fmt.Errorf("GET %s failed: %w", modelsURL, err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") - return fmt.Errorf("GET %s returned non-200 status %d: %s", modelsURL, resp.StatusCode, string(body)) - } - var modelsResp openAIModelsResponse - if err := json.NewDecoder(resp.Body).Decode(&modelsResp); err != nil { - fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") - return fmt.Errorf("failed to parse /v1/models JSON response: %w", err) - } - modelIDs := make([]string, 0, len(modelsResp.Data)) - modelMatched := false - for _, m := range modelsResp.Data { - modelIDs = append(modelIDs, m.ID) - if m.ID == smokeModel { - modelMatched = true - } - } - fmt.Fprintf(cmd.OutOrStdout(), "[OK] (found models: %s)\n", strings.Join(modelIDs, ", ")) - if !modelMatched { - fmt.Fprintf(cmd.OutOrStdout(), "WARNING: Target model %q is not advertised by /v1/models (advertised: %s)\n", smokeModel, strings.Join(modelIDs, ", ")) - } - - // Step 3: Chat Completions check - fmt.Fprintf(cmd.OutOrStdout(), "Step 3: Checking /v1/chat/completions ... ") - prompt := smokePrompt - if prompt == "" { - prompt = "ping" - } - reqPayload := map[string]interface{}{ - "model": smokeModel, - "messages": []map[string]string{ - {"role": "user", "content": prompt}, - }, - "stream": false, - } - payloadBytes, err := json.Marshal(reqPayload) - if err != nil { - return fmt.Errorf("failed to marshal request payload: %w", err) - } - - chatURL := baseURL + "/v1/chat/completions" - resp, err = client.Post(chatURL, "application/json", bytes.NewReader(payloadBytes)) - if err != nil { - fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") - return fmt.Errorf("POST %s failed: %w (make sure active nodes are online)", chatURL, err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") - return fmt.Errorf("POST %s returned non-200 status %d: %s", chatURL, resp.StatusCode, string(body)) - } - var chatResp chatCompletionResponse - if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil { - fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") - return fmt.Errorf("failed to parse /v1/chat/completions JSON response: %w", err) - } - if len(chatResp.Choices) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") - return fmt.Errorf("POST %s returned empty choices", chatURL) - } - content := chatResp.Choices[0].Message.Content - if strings.TrimSpace(content) == "" { - fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") - return fmt.Errorf("POST %s returned empty assistant message content", chatURL) - } - fmt.Fprintf(cmd.OutOrStdout(), "[OK]\n") - fmt.Fprintf(cmd.OutOrStdout(), "Response Content: %q\n\n", content) - fmt.Fprintf(cmd.OutOrStdout(), "IOP Edge OpenAI Smoke Test SUCCESS!\n") - - return nil - }, - } - - c.Flags().StringVar(&smokeModel, "model", "", "model identifier to check (required)") - c.Flags().StringVar(&smokeBaseURL, "base-url", "", "override OpenAI base URL (e.g. http://127.0.0.1:8080)") - c.Flags().StringVar(&smokePrompt, "prompt", "ping", "prompt text to send") - c.Flags().StringVar(&smokeTimeout, "timeout", "10s", "client timeout duration") - c.MarkFlagRequired("model") - - return c + cfg.Logging.Path = logPath + cfg.Bootstrap.ArtifactDir = edgecmd.ResolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir) + return cfg, nil } diff --git a/apps/edge/cmd/edge/main_test.go b/apps/edge/cmd/edge/main_test.go index 47bb442..c356286 100644 --- a/apps/edge/cmd/edge/main_test.go +++ b/apps/edge/cmd/edge/main_test.go @@ -24,6 +24,9 @@ func TestRootCmdIncludesOperationalCommands(t *testing.T) { "env": false, "setup": false, "bootstrap": false, + "node": false, + "nodes": false, + "smoke": false, } for _, c := range root.Commands() { if _, ok := want[c.Name()]; ok { @@ -339,65 +342,6 @@ nodes: } } -func TestEnvCmdMarksAutoAdvertiseHostAndOfflineStateForEmptyConfig(t *testing.T) { - dir := t.TempDir() - cfgPath := filepath.Join(dir, "edge.yaml") - writeMinimalEdgeYAML(t, cfgPath) - - root := rootCmd() - var out bytes.Buffer - root.SetOut(&out) - root.SetErr(&out) - root.SetArgs([]string{"env", "--config", cfgPath}) - if err := root.Execute(); err != nil { - t.Fatalf("execute: %v\n%s", err, out.String()) - } - - got := out.String() - if !strings.Contains(got, "advertise_host: ") || !strings.Contains(got, " (auto)") { - t.Errorf("expected advertise_host with (auto) source, got:\n%s", got) - } - expectedArtifact := "artifact_base_url: " + defaultArtifactBaseURL(resolveAdvertiseHost("").Host) + " (derived)" - if !strings.Contains(got, expectedArtifact) { - t.Errorf("expected derived artifact_base_url %q, got:\n%s", expectedArtifact, got) - } - expectedBootstrap := "node_bootstrap: " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) - if !strings.Contains(got, expectedBootstrap) { - t.Errorf("expected node_bootstrap %q, got:\n%s", expectedBootstrap, got) - } - if !strings.Contains(got, "openai_url: (disabled)") { - t.Errorf("expected openai_url (disabled), got:\n%s", got) - } - if !strings.Contains(got, "configured_nodes: 0 (none)") { - t.Errorf("expected configured_nodes 0 (none), got:\n%s", got) - } - if !strings.Contains(got, "connected_nodes: offline") { - t.Errorf("expected connected_nodes offline placeholder, got:\n%s", got) - } -} - -func TestResolveEdgeLogPathFallsBackToBinaryDirLogsEdgeLog(t *testing.T) { - got, err := resolveEdgeLogPath("") - if err != nil { - t.Fatalf("resolveEdgeLogPath: %v", err) - } - want := filepath.Join(binaryDir(), "logs", "edge.log") - if got != want { - t.Fatalf("resolveEdgeLogPath empty: got %q, want %q", got, want) - } -} - -func TestResolveEdgeLogPathRespectsExplicitPath(t *testing.T) { - explicit := filepath.Join(t.TempDir(), "custom.log") - got, err := resolveEdgeLogPath(explicit) - if err != nil { - t.Fatalf("resolveEdgeLogPath: %v", err) - } - if got != explicit { - t.Fatalf("resolveEdgeLogPath explicit: got %q, want %q", got, explicit) - } -} - func TestBootstrapPackCreatesNodeArtifacts(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "edge.yaml") @@ -528,32 +472,6 @@ nodes: [] } } -func TestResolveAdvertiseHostPrefersExplicit(t *testing.T) { - got := resolveAdvertiseHost("explicit.example.test") - if got.Host != "explicit.example.test" || got.Source != "explicit" { - t.Fatalf("explicit advertise: got %+v", got) - } -} - -func TestResolveAdvertiseHostFallsBackToAuto(t *testing.T) { - got := resolveAdvertiseHost("") - if got.Host == "" { - t.Fatal("auto advertise host empty") - } - if got.Source != "auto" { - t.Fatalf("expected auto source, got %q", got.Source) - } -} - -func TestAddressForCombinesAdvertiseHostWithListenPort(t *testing.T) { - if got := addressFor("10.0.0.1", "0.0.0.0:9090"); got != "10.0.0.1:9090" { - t.Errorf("addressFor: got %q", got) - } - if got := addressFor("host", ""); got != "" { - t.Errorf("addressFor empty listen: got %q", got) - } -} - func TestConfigCheckRejectsDuplicateNodeTokenAndInvalidConfigs(t *testing.T) { // Duplicate token test dir := t.TempDir() @@ -669,89 +587,6 @@ nodes: } } -func TestNodeRegisterUpsertsYAMLAndOutputsBootstrap(t *testing.T) { - // Mock token generator - origGen := tokenGenerator - defer func() { tokenGenerator = origGen }() - tokenGenerator = func() string { - return "mocked-token-1234" - } - - dir := t.TempDir() - cfgPath := filepath.Join(dir, "edge.yaml") - - // Start with minimal YAML - writeMinimalEdgeYAML(t, cfgPath) - - // 1. Register node-a (new registration) - root := rootCmd() - var out bytes.Buffer - root.SetOut(&out) - root.SetErr(&out) - root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--alias", "alias-a"}) - if err := root.Execute(); err != nil { - t.Fatalf("execute register node-a: %v\n%s", err, out.String()) - } - - output := out.String() - // Check one-line bootstrap output - expectedCmd := "curl -fsSL " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) + " | bash -s mocked-token-1234" - if strings.TrimSpace(output) != expectedCmd { - t.Errorf("expected only bootstrap command %q, got %q", expectedCmd, output) - } - - // Read and parse persisted YAML - data, err := os.ReadFile(cfgPath) - if err != nil { - t.Fatalf("read persisted config: %v", err) - } - var cfg config.EdgeConfig - if err := yaml.Unmarshal(data, &cfg); err != nil { - t.Fatalf("unmarshal config: %v", err) - } - - // Assert exactly 1 node record and generated token persisted - if len(cfg.Nodes) != 1 { - t.Fatalf("expected exactly 1 node, got %d", len(cfg.Nodes)) - } - if cfg.Nodes[0].ID != "node-a" || cfg.Nodes[0].Alias != "alias-a" || cfg.Nodes[0].Token != "mocked-token-1234" { - t.Fatalf("unexpected node state in YAML: %+v", cfg.Nodes[0]) - } - - // 2. Update node-a (explicit token & change adapter to ollama) - root = rootCmd() - out.Reset() - root.SetOut(&out) - root.SetErr(&out) - root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--token", "explicit-token-abc", "--adapter", "ollama", "--ollama-base-url", "http://ollama-host:11434"}) - if err := root.Execute(); err != nil { - t.Fatalf("execute update node-a: %v\n%s", err, out.String()) - } - - output = out.String() - expectedCmd2 := "curl -fsSL " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) + " | bash -s explicit-token-abc" - if strings.TrimSpace(output) != expectedCmd2 { - t.Errorf("expected only bootstrap command %q, got %q", expectedCmd2, output) - } - - // Read and parse persisted YAML again - data, err = os.ReadFile(cfgPath) - if err != nil { - t.Fatalf("read persisted config: %v", err) - } - if err := yaml.Unmarshal(data, &cfg); err != nil { - t.Fatalf("unmarshal config: %v", err) - } - - // Assert exactly 1 node record and updated token/adapter persisted - if len(cfg.Nodes) != 1 { - t.Fatalf("expected exactly 1 node, got %d", len(cfg.Nodes)) - } - if cfg.Nodes[0].Token != "explicit-token-abc" || !cfg.Nodes[0].Adapters.Ollama.Enabled || cfg.Nodes[0].Adapters.Ollama.BaseURL != "http://ollama-host:11434" { - t.Fatalf("unexpected node state after explicit token update: %+v", cfg.Nodes[0]) - } -} - func TestNodeRegisterSwitchesCLIToOllama(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "edge.yaml") @@ -1334,41 +1169,3 @@ func TestSmokeOpenAICommandFailure(t *testing.T) { t.Errorf("unexpected error message: %v", err3) } } - -func TestPatchYAMLPreservesArtifactBootstrapField(t *testing.T) { - origYAML := `server: - listen: "0.0.0.0:9090" -bootstrap: - artifact_base_url: "http://old.example.com/artifacts" -` - cfg := &config.EdgeConfig{ - Bootstrap: config.EdgeBootstrapConf{ - ArtifactBaseURL: "http://example.com/artifacts", - }, - } - - patched, err := patchYAML([]byte(origYAML), cfg) - if err != nil { - t.Fatalf("patchYAML: %v", err) - } - - var doc struct { - Server struct { - Listen string `yaml:"listen"` - } `yaml:"server"` - Bootstrap struct { - ArtifactBaseURL string `yaml:"artifact_base_url"` - } `yaml:"bootstrap"` - } - - if err := yaml.Unmarshal(patched, &doc); err != nil { - t.Fatalf("unmarshal patched: %v", err) - } - - if doc.Server.Listen != "0.0.0.0:9090" { - t.Errorf("server.listen was modified: %q", doc.Server.Listen) - } - if doc.Bootstrap.ArtifactBaseURL != "http://example.com/artifacts" { - t.Errorf("artifact_base_url = %q", doc.Bootstrap.ArtifactBaseURL) - } -} diff --git a/apps/edge/internal/edgecmd/bootstrap.go b/apps/edge/internal/edgecmd/bootstrap.go new file mode 100644 index 0000000..5489a1a --- /dev/null +++ b/apps/edge/internal/edgecmd/bootstrap.go @@ -0,0 +1,341 @@ +package edgecmd + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "iop/packages/go/config" + "iop/packages/go/version" +) + +func bootstrapCmd() *cobra.Command { + c := &cobra.Command{ + Use: "bootstrap", + Short: "Prepare node bootstrap artifacts", + Long: `Prepare node bootstrap artifacts served by iop-edge serve. + +The default output directory is the configured bootstrap.artifact_dir resolved +beside the iop-edge binary. The directory is static and can be copied as-is.`, + } + c.AddCommand(bootstrapPackCmd()) + return c +} + +func bootstrapPackCmd() *cobra.Command { + var nodeBinary string + var outputDir string + var target string + var artifactBaseURL string + var edgeAddr string + c := &cobra.Command{ + Use: "pack", + Short: "Pack iop-node into the configured bootstrap artifact directory", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := loadEdgeConfigForBootstrapPack(cmd) + if err != nil { + return err + } + adv := resolveAdvertiseHost(cfg.Server.AdvertiseHost) + if nodeBinary == "" { + nodeBinary = filepath.Join(binaryDir(), "iop-node") + } + if outputDir == "" { + outputDir = ResolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir) + } + if target == "" { + target = runtime.GOOS + "-" + runtime.GOARCH + } + if artifactBaseURL == "" { + if cfg.Bootstrap.ArtifactBaseURL != "" { + artifactBaseURL = strings.TrimSuffix(cfg.Bootstrap.ArtifactBaseURL, "/") + } else { + artifactBaseURL = defaultArtifactBaseURL(adv.Host, cfg.Bootstrap.Listen) + } + } + if edgeAddr == "" { + edgeAddr = addressFor(adv.Host, cfg.Server.Listen) + } + if err := packNodeBootstrap(nodeBootstrapPackOptions{ + NodeBinary: nodeBinary, + OutputDir: outputDir, + Target: target, + Version: version.Version, + ArtifactBaseURL: artifactBaseURL, + EdgeAddr: edgeAddr, + }); err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "wrote bootstrap artifacts to %s\n", outputDir) + fmt.Fprintf(cmd.OutOrStdout(), "bootstrap: %s\n", universalBootstrapScriptURL(artifactBaseURL)) + return nil + }, + } + c.Flags().StringVar(&nodeBinary, "node-binary", "", "iop-node binary to pack (defaults to iop-node beside iop-edge)") + c.Flags().StringVarP(&outputDir, "output", "o", "", "artifact output directory (defaults to bootstrap.artifact_dir)") + c.Flags().StringVar(&target, "target", "", "target platform label (defaults to current GOOS-GOARCH)") + c.Flags().StringVar(&artifactBaseURL, "artifact-base-url", "", "artifact base URL baked into the bootstrap script") + c.Flags().StringVar(&edgeAddr, "edge-addr", "", "edge transport address baked into node.yaml") + return c +} + +func loadEdgeConfigForBootstrapPack(cmd *cobra.Command) (*config.EdgeConfig, error) { + cfg, _, err := LoadEdgeConfig(cmd) + if err == nil { + return cfg, nil + } + if !strings.Contains(err.Error(), "no edge.yaml found") { + return nil, err + } + return &config.EdgeConfig{ + Server: config.EdgeServerConf{ + Listen: "0.0.0.0:9090", + }, + Bootstrap: config.EdgeBootstrapConf{ + Listen: "0.0.0.0:18080", + ArtifactDir: "artifacts", + }, + }, nil +} + +type nodeBootstrapPackOptions struct { + NodeBinary string + OutputDir string + Target string + Version string + ArtifactBaseURL string + EdgeAddr string +} + +func packNodeBootstrap(opts nodeBootstrapPackOptions) error { + if opts.NodeBinary == "" { + return errors.New("node binary path is required") + } + if opts.OutputDir == "" { + return errors.New("output directory is required") + } + if opts.Target == "" { + return errors.New("target is required") + } + if strings.ContainsAny(opts.Target, `/\`) { + return fmt.Errorf("target %q must not contain path separators", opts.Target) + } + targetDir := filepath.Join(opts.OutputDir, opts.Target) + if err := os.MkdirAll(targetDir, 0o755); err != nil { + return fmt.Errorf("mkdir target dir: %w", err) + } + dstNode := filepath.Join(targetDir, "iop-node") + same, err := sameFile(opts.NodeBinary, dstNode) + if err != nil { + return fmt.Errorf("stat iop-node: %w", err) + } + if same { + if err := os.Chmod(dstNode, 0o755); err != nil { + return fmt.Errorf("chmod iop-node: %w", err) + } + } else { + if err := copyFile(opts.NodeBinary, dstNode, 0o755); err != nil { + return fmt.Errorf("copy iop-node: %w", err) + } + } + sum, err := fileSHA256(dstNode) + if err != nil { + return err + } + sumLine := fmt.Sprintf("%x iop-node\n", sum) + if err := os.WriteFile(filepath.Join(targetDir, "iop-node.sha256"), []byte(sumLine), 0o644); err != nil { + return fmt.Errorf("write iop-node.sha256: %w", err) + } + if err := os.WriteFile(filepath.Join(targetDir, "SHA256SUMS"), []byte(sumLine), 0o644); err != nil { + return fmt.Errorf("write SHA256SUMS: %w", err) + } + bootstrapDir := filepath.Join(opts.OutputDir, "bootstrap") + if err := os.MkdirAll(bootstrapDir, 0o755); err != nil { + return fmt.Errorf("mkdir bootstrap dir: %w", err) + } + script := nodeBootstrapScript(opts) + scriptPath := filepath.Join(bootstrapDir, "node-"+opts.Target+".sh") + if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil { + return fmt.Errorf("write bootstrap script: %w", err) + } + if err := writeUniversalBootstrapScript(bootstrapDir, opts); err != nil { + return err + } + return nil +} + +func writeUniversalBootstrapScript(bootstrapDir string, opts nodeBootstrapPackOptions) error { + universalOpts := opts + universalOpts.Target = "" + scriptPath := filepath.Join(bootstrapDir, "node.sh") + if err := os.MkdirAll(bootstrapDir, 0o755); err != nil { + return fmt.Errorf("mkdir bootstrap dir: %w", err) + } + if err := os.WriteFile(scriptPath, []byte(nodeBootstrapScript(universalOpts)), 0o755); err != nil { + return fmt.Errorf("write universal bootstrap script: %w", err) + } + return nil +} + +func sameFile(a, b string) (bool, error) { + ai, err := os.Stat(a) + if err != nil { + return false, err + } + bi, err := os.Stat(b) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + return os.SameFile(ai, bi), nil +} + +func copyFile(src, dst string, mode os.FileMode) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + _ = out.Close() + return err + } + if err := out.Close(); err != nil { + return err + } + return os.Chmod(dst, mode) +} + +func fileSHA256(path string) ([]byte, error) { + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("open checksum file: %w", err) + } + defer f.Close() + h := sha256.New() + if _, err := io.Copy(h, f); err != nil { + return nil, fmt.Errorf("hash file: %w", err) + } + return h.Sum(nil), nil +} + +func shellQuote(s string) string { + return strconv.Quote(s) +} + +func nodeBootstrapScript(opts nodeBootstrapPackOptions) string { + return `#!/usr/bin/env bash +set -euo pipefail + +VERSION=` + shellQuote(opts.Version) + ` +DEFAULT_TARGET=` + shellQuote(opts.Target) + ` +DEFAULT_ARTIFACT_BASE_URL=` + shellQuote(strings.TrimSuffix(opts.ArtifactBaseURL, "/")) + ` +DEFAULT_EDGE_ADDR=` + shellQuote(opts.EdgeAddr) + ` +POSITIONAL_NODE_TOKEN="${1:-}" +TARGET="$DEFAULT_TARGET" +ARTIFACT_BASE_URL="$DEFAULT_ARTIFACT_BASE_URL" +EDGE_ADDR="$DEFAULT_EDGE_ADDR" +NODE_TOKEN="$POSITIONAL_NODE_TOKEN" +INSTALL_DIR="${IOP_HOME:-$HOME/iop-field}" +CONFIG_FILE="${IOP_NODE_CONFIG:-$INSTALL_DIR/node.yaml}" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iop-node-bootstrap.XXXXXX")" + +detect_target() { + local os arch + case "$(uname -s)" in + Linux*) os="linux" ;; + Darwin*) os="darwin" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + *) + echo "[iop-bootstrap] unsupported OS: $(uname -s)" >&2 + return 2 + ;; + esac + + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + arm64|aarch64) arch="arm64" ;; + *) + echo "[iop-bootstrap] unsupported architecture: $(uname -m)" >&2 + return 2 + ;; + esac + + printf '%s-%s\n' "$os" "$arch" +} + +cleanup() { + rm -rf "$TMP_DIR" +} +trap cleanup EXIT + +ARTIFACT_BASE_URL="${ARTIFACT_BASE_URL%/}" + +if [[ -z "$NODE_TOKEN" ]]; then + echo "[iop-bootstrap] missing node token" >&2 + echo "[iop-bootstrap] usage: curl -fsSL /bootstrap/node.sh | bash -s TOKEN" >&2 + exit 2 +fi + +if [[ -z "$TARGET" ]]; then + TARGET="$(detect_target)" +fi + +BIN_FILE="$INSTALL_DIR/iop-node" +if [[ "$TARGET" == windows-* ]]; then + BIN_FILE="$INSTALL_DIR/iop-node.exe" +fi + +echo "[iop-bootstrap] version=$VERSION target=$TARGET" +echo "[iop-bootstrap] install_dir=$INSTALL_DIR" +mkdir -p "$INSTALL_DIR" + +curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/iop-node" -o "$TMP_DIR/iop-node" +curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/SHA256SUMS" -o "$TMP_DIR/SHA256SUMS" + +if command -v sha256sum >/dev/null 2>&1; then + (cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | sha256sum -c -) +elif command -v shasum >/dev/null 2>&1; then + (cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | shasum -a 256 -c -) +else + echo "[iop-bootstrap] warning: no sha256 checker found; skipping checksum verification" >&2 +fi + +cp "$TMP_DIR/iop-node" "$BIN_FILE" +chmod +x "$BIN_FILE" +if command -v xattr >/dev/null 2>&1; then + xattr -d com.apple.quarantine "$BIN_FILE" 2>/dev/null || true +fi + +cat > "$CONFIG_FILE" < + 4. iop-edge config check + 5. iop-edge serve`, + Example: ` iop-edge config init + iop-edge env + iop-edge node register node-silicon-ollama --adapter ollama + iop-edge config check`, + } + c.AddCommand(configInitCmd(), configPrintCmd(), configCheckCmd()) + return c +} + +func configInitCmd() *cobra.Command { + output := "edge.yaml" + var force bool + var stdout bool + c := &cobra.Command{ + Use: "init", + Short: "Write an edge.yaml template", + Long: "Write a deployable edge.yaml template to the current bundle directory or stdout.\n" + + "Run this beside a local iop-edge binary, then run `iop-edge config check`.", + Example: " iop-edge config init\n" + + " iop-edge config init --stdout\n" + + " iop-edge config init --output edge.yaml\n" + + " iop-edge config check", + RunE: func(cmd *cobra.Command, _ []string) error { + template := []byte(hostsetup.EdgeBundleConfigTemplate()) + if stdout || output == "-" { + _, err := cmd.OutOrStdout().Write(template) + return err + } + if !force { + if _, err := os.Stat(output); err == nil { + return fmt.Errorf("%s already exists (use --force to overwrite)", output) + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("stat output: %w", err) + } + } + if dir := filepath.Dir(output); dir != "" && dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + } + if err := os.WriteFile(output, template, 0o600); err != nil { + return fmt.Errorf("write %s: %w", output, err) + } + fmt.Fprintf(cmd.OutOrStdout(), "wrote %s\n", output) + return nil + }, + } + c.Flags().StringVarP(&output, "output", "o", output, "output path") + c.Flags().BoolVar(&stdout, "stdout", false, "print template to stdout instead of writing a file") + c.Flags().BoolVar(&force, "force", false, "overwrite an existing output file") + return c +} + +func configPrintCmd() *cobra.Command { + return &cobra.Command{ + Use: "print", + Short: "Print effective edge config as YAML", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, _, err := LoadEdgeConfig(cmd) + if err != nil { + return err + } + enc := yaml.NewEncoder(cmd.OutOrStdout()) + defer enc.Close() + return enc.Encode(cfg) + }, + } +} + +func configCheckCmd() *cobra.Command { + return &cobra.Command{ + Use: "check", + Short: "Validate edge config can be loaded", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, path, err := LoadEdgeConfig(cmd) + if err != nil { + return err + } + if err := validateEdgeConfig(cfg); err != nil { + return fmt.Errorf("validate config: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "OK %s\n", path) + return nil + }, + } +} + +func validateEdgeConfig(cfg *config.EdgeConfig) error { + _, err := node.LoadFromConfig(cfg.Nodes) + if err != nil { + return err + } + + for i, n := range cfg.Nodes { + name := n.ID + if name == "" { + name = n.Alias + } + if name == "" { + name = fmt.Sprintf("index %d", i) + } + + if !n.Adapters.Ollama.Enabled && !n.Adapters.CLI.Enabled && !n.Adapters.Vllm.Enabled { + return fmt.Errorf("node %q: at least one adapter must be enabled", name) + } + if n.Adapters.Ollama.Enabled && n.Adapters.Ollama.BaseURL == "" { + return fmt.Errorf("node %q: ollama adapter base_url must not be empty", name) + } + if n.Runtime.Concurrency < 0 { + return fmt.Errorf("node %q: runtime concurrency must be non-negative", name) + } + } + return nil +} diff --git a/apps/edge/internal/edgecmd/edgecmd_test.go b/apps/edge/internal/edgecmd/edgecmd_test.go new file mode 100644 index 0000000..1c0cdb0 --- /dev/null +++ b/apps/edge/internal/edgecmd/edgecmd_test.go @@ -0,0 +1,218 @@ +package edgecmd + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" + + "iop/packages/go/config" +) + +const minimalEdgeYAML = "server:\n listen: \"0.0.0.0:9090\"\n" + +func writeMinimalEdgeYAML(t *testing.T, path string) { + t.Helper() + if err := os.WriteFile(path, []byte(minimalEdgeYAML), 0o600); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} + +func TestEnvCmdMarksAutoAdvertiseHostAndOfflineStateForEmptyConfig(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "edge.yaml") + writeMinimalEdgeYAML(t, cfgPath) + + root := NewRoot(Options{}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"env", "--config", cfgPath}) + if err := root.Execute(); err != nil { + t.Fatalf("execute: %v\n%s", err, out.String()) + } + + got := out.String() + if !strings.Contains(got, "advertise_host: ") || !strings.Contains(got, " (auto)") { + t.Errorf("expected advertise_host with (auto) source, got:\n%s", got) + } + expectedArtifact := "artifact_base_url: " + defaultArtifactBaseURL(resolveAdvertiseHost("").Host) + " (derived)" + if !strings.Contains(got, expectedArtifact) { + t.Errorf("expected derived artifact_base_url %q, got:\n%s", expectedArtifact, got) + } + expectedBootstrap := "node_bootstrap: " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) + if !strings.Contains(got, expectedBootstrap) { + t.Errorf("expected node_bootstrap %q, got:\n%s", expectedBootstrap, got) + } + if !strings.Contains(got, "openai_url: (disabled)") { + t.Errorf("expected openai_url (disabled), got:\n%s", got) + } + if !strings.Contains(got, "configured_nodes: 0 (none)") { + t.Errorf("expected configured_nodes 0 (none), got:\n%s", got) + } + if !strings.Contains(got, "connected_nodes: offline") { + t.Errorf("expected connected_nodes offline placeholder, got:\n%s", got) + } +} + +func TestResolveEdgeLogPathFallsBackToBinaryDirLogsEdgeLog(t *testing.T) { + got, err := ResolveEdgeLogPath("") + if err != nil { + t.Fatalf("ResolveEdgeLogPath: %v", err) + } + want := filepath.Join(binaryDir(), "logs", "edge.log") + if got != want { + t.Fatalf("ResolveEdgeLogPath empty: got %q, want %q", got, want) + } +} + +func TestResolveEdgeLogPathRespectsExplicitPath(t *testing.T) { + explicit := filepath.Join(t.TempDir(), "custom.log") + got, err := ResolveEdgeLogPath(explicit) + if err != nil { + t.Fatalf("ResolveEdgeLogPath: %v", err) + } + if got != explicit { + t.Fatalf("ResolveEdgeLogPath explicit: got %q, want %q", got, explicit) + } +} + +func TestResolveAdvertiseHostPrefersExplicit(t *testing.T) { + got := resolveAdvertiseHost("explicit.example.test") + if got.Host != "explicit.example.test" || got.Source != "explicit" { + t.Fatalf("explicit advertise: got %+v", got) + } +} + +func TestResolveAdvertiseHostFallsBackToAuto(t *testing.T) { + got := resolveAdvertiseHost("") + if got.Host == "" { + t.Fatal("auto advertise host empty") + } + if got.Source != "auto" { + t.Fatalf("expected auto source, got %q", got.Source) + } +} + +func TestAddressForCombinesAdvertiseHostWithListenPort(t *testing.T) { + if got := addressFor("10.0.0.1", "0.0.0.0:9090"); got != "10.0.0.1:9090" { + t.Errorf("addressFor: got %q", got) + } + if got := addressFor("host", ""); got != "" { + t.Errorf("addressFor empty listen: got %q", got) + } +} + +func TestNodeRegisterUpsertsYAMLAndOutputsBootstrap(t *testing.T) { + origGen := tokenGenerator + defer func() { tokenGenerator = origGen }() + tokenGenerator = func() string { + return "mocked-token-1234" + } + + dir := t.TempDir() + cfgPath := filepath.Join(dir, "edge.yaml") + writeMinimalEdgeYAML(t, cfgPath) + + root := NewRoot(Options{}) + var out bytes.Buffer + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--alias", "alias-a"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute register node-a: %v\n%s", err, out.String()) + } + + output := out.String() + expectedCmd := "curl -fsSL " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) + " | bash -s mocked-token-1234" + if strings.TrimSpace(output) != expectedCmd { + t.Errorf("expected only bootstrap command %q, got %q", expectedCmd, output) + } + + data, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read persisted config: %v", err) + } + var cfg config.EdgeConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + + if len(cfg.Nodes) != 1 { + t.Fatalf("expected exactly 1 node, got %d", len(cfg.Nodes)) + } + if cfg.Nodes[0].ID != "node-a" || cfg.Nodes[0].Alias != "alias-a" || cfg.Nodes[0].Token != "mocked-token-1234" { + t.Fatalf("unexpected node state in YAML: %+v", cfg.Nodes[0]) + } + + root = NewRoot(Options{}) + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--token", "explicit-token-abc", "--adapter", "ollama", "--ollama-base-url", "http://ollama-host:11434"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute update node-a: %v\n%s", err, out.String()) + } + + output = out.String() + expectedCmd2 := "curl -fsSL " + universalBootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host)) + " | bash -s explicit-token-abc" + if strings.TrimSpace(output) != expectedCmd2 { + t.Errorf("expected only bootstrap command %q, got %q", expectedCmd2, output) + } + + data, err = os.ReadFile(cfgPath) + if err != nil { + t.Fatalf("read persisted config: %v", err) + } + if err := yaml.Unmarshal(data, &cfg); err != nil { + t.Fatalf("unmarshal config: %v", err) + } + + if len(cfg.Nodes) != 1 { + t.Fatalf("expected exactly 1 node, got %d", len(cfg.Nodes)) + } + if cfg.Nodes[0].Token != "explicit-token-abc" || !cfg.Nodes[0].Adapters.Ollama.Enabled || cfg.Nodes[0].Adapters.Ollama.BaseURL != "http://ollama-host:11434" { + t.Fatalf("unexpected node state after explicit token update: %+v", cfg.Nodes[0]) + } +} + +func TestPatchYAMLPreservesArtifactBootstrapField(t *testing.T) { + origYAML := `server: + listen: "0.0.0.0:9090" +bootstrap: + artifact_base_url: "http://old.example.com/artifacts" +` + cfg := &config.EdgeConfig{ + Bootstrap: config.EdgeBootstrapConf{ + ArtifactBaseURL: "http://example.com/artifacts", + }, + } + + patched, err := patchYAML([]byte(origYAML), cfg) + if err != nil { + t.Fatalf("patchYAML: %v", err) + } + + var doc struct { + Server struct { + Listen string `yaml:"listen"` + } `yaml:"server"` + Bootstrap struct { + ArtifactBaseURL string `yaml:"artifact_base_url"` + } `yaml:"bootstrap"` + } + + if err := yaml.Unmarshal(patched, &doc); err != nil { + t.Fatalf("unmarshal patched: %v", err) + } + + if doc.Server.Listen != "0.0.0.0:9090" { + t.Errorf("server.listen was modified: %q", doc.Server.Listen) + } + if doc.Bootstrap.ArtifactBaseURL != "http://example.com/artifacts" { + t.Errorf("artifact_base_url = %q", doc.Bootstrap.ArtifactBaseURL) + } +} diff --git a/apps/edge/internal/edgecmd/env.go b/apps/edge/internal/edgecmd/env.go new file mode 100644 index 0000000..affbf6c --- /dev/null +++ b/apps/edge/internal/edgecmd/env.go @@ -0,0 +1,178 @@ +package edgecmd + +import ( + "fmt" + "io" + "net" + "os" + "sort" + "strings" + + "github.com/spf13/cobra" + + "iop/packages/go/config" +) + +// envCmd prints the resolved environment for the bundled edge binary so the +// user can verify config discovery, advertise host, log path, transport +// address, artifact/bootstrap URL, and OpenAI-compatible URL without starting +// edge. It does not bind any port or connect to nodes. +func envCmd() *cobra.Command { + return &cobra.Command{ + Use: "env", + Short: "Print resolved edge environment without starting edge", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, path, err := LoadEdgeConfig(cmd) + if err != nil { + return err + } + logPath, err := ResolveEdgeLogPath(cfg.Logging.Path) + if err != nil { + return err + } + cfg.Bootstrap.ArtifactDir = ResolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir) + renderEnv(cmd.OutOrStdout(), envReport{ + ConfigPath: path, + BinaryDir: binaryDir(), + LogPath: logPath, + Advertise: resolveAdvertiseHost(cfg.Server.AdvertiseHost), + Server: cfg.Server, + Bootstrap: cfg.Bootstrap, + OpenAI: cfg.OpenAI, + A2A: cfg.A2A, + Nodes: cfg.Nodes, + }) + return nil + }, + } +} + +type advertise struct { + Host string + Source string +} + +func resolveAdvertiseHost(explicit string) advertise { + if explicit != "" { + return advertise{Host: explicit, Source: "explicit"} + } + if ip := nonLoopbackIPv4(); ip != "" { + return advertise{Host: ip, Source: "auto"} + } + if hn, err := os.Hostname(); err == nil && hn != "" { + return advertise{Host: hn, Source: "auto"} + } + return advertise{Host: "127.0.0.1", Source: "auto"} +} + +func nonLoopbackIPv4() string { + addrs, err := net.InterfaceAddrs() + if err != nil { + return "" + } + for _, a := range addrs { + ipnet, ok := a.(*net.IPNet) + if !ok || ipnet.IP.IsLoopback() { + continue + } + ip4 := ipnet.IP.To4() + if ip4 == nil { + continue + } + return ip4.String() + } + return "" +} + +// addressFor combines an advertise host with the port from a listen string +// (e.g. "0.0.0.0:9090" -> ":9090"). If the listen string is invalid it +// is returned unchanged. +func addressFor(host, listen string) string { + _, port, err := net.SplitHostPort(listen) + if err != nil || port == "" { + return listen + } + return net.JoinHostPort(host, port) +} + +func defaultArtifactBaseURL(host string, listen ...string) string { + bootstrapListen := "0.0.0.0:18080" + if len(listen) > 0 && listen[0] != "" { + bootstrapListen = listen[0] + } + return "http://" + addressFor(host, bootstrapListen) +} + +func bootstrapScriptURL(baseURL, target string) string { + return strings.TrimSuffix(baseURL, "/") + "/bootstrap/node-" + target + ".sh" +} + +func universalBootstrapScriptURL(baseURL string) string { + return strings.TrimSuffix(baseURL, "/") + "/bootstrap/node.sh" +} + +type envReport struct { + ConfigPath string + BinaryDir string + LogPath string + Advertise advertise + Server config.EdgeServerConf + Bootstrap config.EdgeBootstrapConf + OpenAI config.EdgeOpenAIConf + A2A config.EdgeA2AConf + Nodes []config.NodeDefinition +} + +func renderEnv(w io.Writer, r envReport) { + artifactURL := strings.TrimSuffix(r.Bootstrap.ArtifactBaseURL, "/") + artifactSource := "configured" + if artifactURL == "" { + artifactURL = defaultArtifactBaseURL(r.Advertise.Host, r.Bootstrap.Listen) + artifactSource = "derived" + } + + fmt.Fprintf(w, "config: %s\n", r.ConfigPath) + fmt.Fprintf(w, "binary_dir: %s\n", r.BinaryDir) + fmt.Fprintf(w, "log_path: %s\n", r.LogPath) + fmt.Fprintf(w, "advertise_host: %s (%s)\n", r.Advertise.Host, r.Advertise.Source) + fmt.Fprintf(w, "node_transport: %s\n", addressFor(r.Advertise.Host, r.Server.Listen)) + fmt.Fprintf(w, "artifact_dir: %s\n", r.Bootstrap.ArtifactDir) + fmt.Fprintf(w, "artifact_listen: %s\n", r.Bootstrap.Listen) + fmt.Fprintf(w, "artifact_base_url: %s (%s)\n", artifactURL, artifactSource) + fmt.Fprintf(w, "node_bootstrap: %s\n", universalBootstrapScriptURL(artifactURL)) + if r.OpenAI.Enabled { + fmt.Fprintf(w, "openai_url: http://%s/v1\n", addressFor(r.Advertise.Host, r.OpenAI.Listen)) + } else { + fmt.Fprintln(w, "openai_url: (disabled)") + } + if r.A2A.Enabled { + path := r.A2A.Path + if path == "" { + path = "/" + } + fmt.Fprintf(w, "a2a_url: http://%s%s\n", addressFor(r.Advertise.Host, r.A2A.Listen), path) + } else { + fmt.Fprintln(w, "a2a_url: (disabled)") + } + fmt.Fprintf(w, "configured_nodes: %s\n", summarizeNodes(r.Nodes)) + fmt.Fprintln(w, "connected_nodes: offline (env does not start edge)") +} + +func summarizeNodes(nodes []config.NodeDefinition) string { + if len(nodes) == 0 { + return "0 (none)" + } + labels := make([]string, 0, len(nodes)) + for _, n := range nodes { + label := n.ID + if label == "" { + label = n.Alias + } + if label == "" { + label = "(unnamed)" + } + labels = append(labels, label) + } + sort.Strings(labels) + return fmt.Sprintf("%d (%s)", len(nodes), strings.Join(labels, ", ")) +} diff --git a/apps/edge/internal/edgecmd/node_register.go b/apps/edge/internal/edgecmd/node_register.go new file mode 100644 index 0000000..f025fb1 --- /dev/null +++ b/apps/edge/internal/edgecmd/node_register.go @@ -0,0 +1,376 @@ +package edgecmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/google/uuid" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "iop/packages/go/config" + "iop/packages/go/version" +) + +var ( + regAlias string + regToken string + regAdapter string + regOllamaBaseURL string + regOllamaContextSize int + regRuntimeConcurrency int + regWorkspaceRoot string + regTarget string + regArtifactBaseURL string +) + +var tokenGenerator = func() string { + return uuid.NewString() +} + +func refreshBootstrapForExistingArtifact(cfg *config.EdgeConfig, target, artifactURL string) error { + outputDir := ResolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir) + adv := resolveAdvertiseHost(cfg.Server.AdvertiseHost) + bootstrapDir := filepath.Join(outputDir, "bootstrap") + baseOpts := nodeBootstrapPackOptions{ + OutputDir: outputDir, + Version: version.Version, + ArtifactBaseURL: artifactURL, + EdgeAddr: addressFor(adv.Host, cfg.Server.Listen), + } + if err := writeUniversalBootstrapScript(bootstrapDir, baseOpts); err != nil { + return err + } + + nodeBinary := filepath.Join(outputDir, target, "iop-node") + if _, err := os.Stat(nodeBinary); err != nil { + if errors.Is(err, os.ErrNotExist) { + return nil + } + return fmt.Errorf("stat node artifact: %w", err) + } + targetOpts := baseOpts + targetOpts.NodeBinary = nodeBinary + targetOpts.Target = target + return packNodeBootstrap(targetOpts) +} + +func nodeCmd() *cobra.Command { + c := &cobra.Command{ + Use: "node", + Short: "Manage edge nodes", + Long: `Manage node records stored in edge.yaml. + +Use node register to create or update the Node definition and print the +one-line bootstrap command for the target host. After registration, run +iop-edge config check and then start iop-edge serve.`, + Example: ` iop-edge node register node-silicon-ollama --adapter ollama + iop-edge config check + iop-edge serve`, + } + c.AddCommand(nodeRegisterCmd()) + return c +} + +func nodeRegisterCmd() *cobra.Command { + c := &cobra.Command{ + Use: "register [node-id]", + Short: "Register or update a node record", + Long: `Register or update a node record in edge.yaml and print a one-line bootstrap command. + +The command writes the node id, alias, token, adapter, and runtime settings into +edge.yaml. The printed bootstrap command is the user-facing command to run on +the Node host; the user should not create node.yaml or run iop-node serve +directly in the default flow.`, + Example: ` iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 + iop-edge node register node-linux-cli --adapter cli + iop-edge config check`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + nodeID := args[0] + + path, err := ResolveConfigPath(cmd) + if err != nil { + return err + } + + cfgPtr, err := config.LoadEdge(path) + if err != nil { + return fmt.Errorf("load config: %w", err) + } + cfg := *cfgPtr + + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read config file: %w", err) + } + + if cmd.Flags().Changed("adapter") { + if regAdapter != "ollama" && regAdapter != "cli" { + return fmt.Errorf("invalid adapter %q: must be ollama or cli", regAdapter) + } + } + if cmd.Flags().Changed("runtime-concurrency") && regRuntimeConcurrency < 0 { + return fmt.Errorf("runtime concurrency must be non-negative") + } + + var nodeDef *config.NodeDefinition + exists := false + for i, n := range cfg.Nodes { + if n.ID == nodeID { + nodeDef = &cfg.Nodes[i] + exists = true + break + } + } + + if exists { + if cmd.Flags().Changed("alias") { + nodeDef.Alias = regAlias + } + if cmd.Flags().Changed("token") { + nodeDef.Token = regToken + } + + if cmd.Flags().Changed("adapter") { + if regAdapter == "ollama" { + nodeDef.Adapters.Ollama.Enabled = true + nodeDef.Adapters.CLI.Enabled = false + if nodeDef.Adapters.Ollama.BaseURL == "" && regOllamaBaseURL == "" { + nodeDef.Adapters.Ollama.BaseURL = "http://127.0.0.1:11434" + } + } else if regAdapter == "cli" { + nodeDef.Adapters.CLI.Enabled = true + nodeDef.Adapters.Ollama.Enabled = false + } + } + + if cmd.Flags().Changed("ollama-base-url") { + nodeDef.Adapters.Ollama.BaseURL = regOllamaBaseURL + } + if cmd.Flags().Changed("ollama-context-size") { + nodeDef.Adapters.Ollama.ContextSize = regOllamaContextSize + } + + if cmd.Flags().Changed("runtime-concurrency") { + nodeDef.Runtime.Concurrency = regRuntimeConcurrency + } + if cmd.Flags().Changed("workspace-root") { + nodeDef.Runtime.WorkspaceRoot = regWorkspaceRoot + } + + if nodeDef.Token == "" { + nodeDef.Token = tokenGenerator() + } + } else { + alias := nodeID + if cmd.Flags().Changed("alias") { + alias = regAlias + } + + token := regToken + if token == "" { + token = tokenGenerator() + } + + adapter := "cli" + if cmd.Flags().Changed("adapter") { + adapter = regAdapter + } + + var ollama config.OllamaConf + var cli config.CLIConf + + if adapter == "ollama" { + ollama.Enabled = true + ollama.BaseURL = "http://127.0.0.1:11434" + if cmd.Flags().Changed("ollama-base-url") { + ollama.BaseURL = regOllamaBaseURL + } + ollama.ContextSize = 2048 + if cmd.Flags().Changed("ollama-context-size") { + ollama.ContextSize = regOllamaContextSize + } + } else { + cli.Enabled = true + } + + concurrency := 1 + if cmd.Flags().Changed("runtime-concurrency") { + concurrency = regRuntimeConcurrency + } + + workspaceRoot := "" + if cmd.Flags().Changed("workspace-root") { + workspaceRoot = regWorkspaceRoot + } + + newDef := config.NodeDefinition{ + ID: nodeID, + Alias: alias, + Token: token, + Adapters: config.AdaptersConf{ + Ollama: ollama, + CLI: cli, + }, + Runtime: config.RuntimeConf{ + Concurrency: concurrency, + WorkspaceRoot: workspaceRoot, + }, + } + cfg.Nodes = append(cfg.Nodes, newDef) + nodeDef = &cfg.Nodes[len(cfg.Nodes)-1] + } + + target := runtime.GOOS + "-" + runtime.GOARCH + if cmd.Flags().Changed("target") { + target = regTarget + } + + artifactURL := cfg.Bootstrap.ArtifactBaseURL + if cmd.Flags().Changed("artifact-base-url") { + artifactURL = regArtifactBaseURL + cfg.Bootstrap.ArtifactBaseURL = regArtifactBaseURL + } + if artifactURL == "" { + artifactURL = defaultArtifactBaseURL(resolveAdvertiseHost(cfg.Server.AdvertiseHost).Host, cfg.Bootstrap.Listen) + } + + if err := validateEdgeConfig(&cfg); err != nil { + return fmt.Errorf("validate node register: %w", err) + } + if err := refreshBootstrapForExistingArtifact(&cfg, target, artifactURL); err != nil { + return fmt.Errorf("refresh bootstrap artifacts: %w", err) + } + + outData, err := patchYAML(data, &cfg) + if err != nil { + return fmt.Errorf("patch config: %w", err) + } + if err := os.WriteFile(path, outData, 0o600); err != nil { + return fmt.Errorf("write config file: %w", err) + } + + bootstrapURL := universalBootstrapScriptURL(artifactURL) + fmt.Fprintf(cmd.OutOrStdout(), "curl -fsSL %s | bash -s %s\n", bootstrapURL, nodeDef.Token) + + return nil + }, + } + + c.Flags().StringVar(®Alias, "alias", "", "node alias (defaults to node-id)") + c.Flags().StringVar(®Token, "token", "", "node token (defaults to auto-generated uuid)") + c.Flags().StringVar(®Adapter, "adapter", "cli", "node adapter: ollama or cli") + c.Flags().StringVar(®OllamaBaseURL, "ollama-base-url", "", "ollama base URL") + c.Flags().IntVar(®OllamaContextSize, "ollama-context-size", 2048, "ollama context size") + c.Flags().IntVar(®RuntimeConcurrency, "runtime-concurrency", 1, "runtime concurrency limit") + c.Flags().StringVar(®WorkspaceRoot, "workspace-root", "", "workspace root path") + c.Flags().StringVar(®Target, "target", "", "bootstrap target platform (e.g. darwin-arm64, linux-amd64)") + c.Flags().StringVar(®ArtifactBaseURL, "artifact-base-url", "", "artifact base URL for bootstrap download") + + return c +} + +func patchYAML(data []byte, cfg *config.EdgeConfig) ([]byte, error) { + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, fmt.Errorf("unmarshal yaml node: %w", err) + } + + if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode { + return nil, errors.New("invalid yaml structure: root is not a map") + } + + rootMap := doc.Content[0] + + nodesBytes, err := yaml.Marshal(cfg.Nodes) + if err != nil { + return nil, fmt.Errorf("marshal nodes: %w", err) + } + var tempDoc yaml.Node + if err := yaml.Unmarshal(nodesBytes, &tempDoc); err != nil { + return nil, fmt.Errorf("unmarshal nodes temp doc: %w", err) + } + var nodesValNode *yaml.Node + if len(tempDoc.Content) > 0 { + nodesValNode = tempDoc.Content[0] + } else { + nodesValNode = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + } + + foundNodes := false + for i := 0; i < len(rootMap.Content); i += 2 { + if rootMap.Content[i].Value == "nodes" { + rootMap.Content[i+1] = nodesValNode + foundNodes = true + break + } + } + if !foundNodes { + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "nodes"} + rootMap.Content = append(rootMap.Content, keyNode, nodesValNode) + } + + fieldsToPatch := make(map[string]string) + if cfg.Bootstrap.ArtifactBaseURL != "" { + fieldsToPatch["artifact_base_url"] = cfg.Bootstrap.ArtifactBaseURL + } + patchBootstrapStringFields(rootMap, fieldsToPatch) + + out, err := yaml.Marshal(&doc) + if err != nil { + return nil, fmt.Errorf("marshal patched doc: %w", err) + } + return out, nil +} + +func patchBootstrapStringFields(rootMap *yaml.Node, fields map[string]string) { + if len(fields) == 0 { + return + } + + var bootstrapVal *yaml.Node + foundBootstrap := false + for i := 0; i < len(rootMap.Content); i += 2 { + if rootMap.Content[i].Value == "bootstrap" { + bootstrapVal = rootMap.Content[i+1] + foundBootstrap = true + break + } + } + + if !foundBootstrap { + bootstrapKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bootstrap"} + bootstrapVal = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + rootMap.Content = append(rootMap.Content, bootstrapKey, bootstrapVal) + } + + if bootstrapVal.Kind != yaml.MappingNode { + return + } + + keys := []string{"artifact_base_url"} + for _, k := range keys { + v, ok := fields[k] + if !ok || v == "" { + continue + } + foundField := false + for j := 0; j < len(bootstrapVal.Content); j += 2 { + if bootstrapVal.Content[j].Value == k { + bootstrapVal.Content[j+1].Value = v + bootstrapVal.Content[j+1].Tag = "!!str" + foundField = true + break + } + } + if !foundField { + keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: k} + valNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: v} + bootstrapVal.Content = append(bootstrapVal.Content, keyNode, valNode) + } + } +} diff --git a/apps/edge/internal/edgecmd/nodes.go b/apps/edge/internal/edgecmd/nodes.go new file mode 100644 index 0000000..e02472d --- /dev/null +++ b/apps/edge/internal/edgecmd/nodes.go @@ -0,0 +1,55 @@ +package edgecmd + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "iop/packages/go/config" +) + +func nodesCmd() *cobra.Command { + c := &cobra.Command{ + Use: "nodes", + Short: "Inspect registered edge nodes", + Long: `Inspect and list configured or connected execution nodes.`, + } + c.AddCommand(nodesListCmd()) + return c +} + +func nodesListCmd() *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List configured edge nodes and connection status", + Long: `List all nodes registered in the edge configuration file. +When the server is offline or this command is run standalone, all nodes will show their status as 'offline' or 'configured'.`, + Example: ` iop-edge nodes list`, + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, path, err := LoadEdgeConfig(cmd) + if err != nil { + return err + } + fmt.Fprintf(cmd.OutOrStdout(), "Config Source: %s\n\n", path) + if len(cfg.Nodes) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No nodes configured.") + return nil + } + + nodes := make([]config.NodeDefinition, len(cfg.Nodes)) + copy(nodes, cfg.Nodes) + sort.Slice(nodes, func(i, j int) bool { + return nodes[i].ID < nodes[j].ID + }) + + fmt.Fprintf(cmd.OutOrStdout(), "%-25s %-20s %-40s %-12s\n", "NODE ID", "ALIAS", "TOKEN", "STATUS") + fmt.Fprintf(cmd.OutOrStdout(), "%s\n", strings.Repeat("-", 100)) + for _, n := range nodes { + fmt.Fprintf(cmd.OutOrStdout(), "%-25s %-20s %-40s %-12s\n", n.ID, n.Alias, n.Token, "offline (configured)") + } + return nil + }, + } +} diff --git a/apps/edge/internal/edgecmd/paths.go b/apps/edge/internal/edgecmd/paths.go new file mode 100644 index 0000000..c02a716 --- /dev/null +++ b/apps/edge/internal/edgecmd/paths.go @@ -0,0 +1,84 @@ +package edgecmd + +import ( + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" +) + +// ResolveConfigPath returns the edge config path to load. Candidate order: +// 1. explicit --config flag +// 2. ./edge.yaml in the current working directory +// 3. /edge.yaml beside the executable +// 4. configs/edge.yaml repo-dev fallback (only if it exists) +// +// setup uses its own /etc/iop/edge.yaml default and does not call this helper. +func ResolveConfigPath(cmd *cobra.Command) (string, error) { + if isConfigExplicit(cmd) { + return configFlagValue(cmd), nil + } + if _, err := os.Stat("edge.yaml"); err == nil { + return "edge.yaml", nil + } + if exe, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exe), "edge.yaml") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + if _, err := os.Stat("configs/edge.yaml"); err == nil { + return "configs/edge.yaml", nil + } + return "", errors.New("no edge.yaml found: pass --config or run beside a bundle edge.yaml") +} + +func isConfigExplicit(cmd *cobra.Command) bool { + flag := cmd.Root().PersistentFlags().Lookup("config") + return flag != nil && flag.Changed +} + +func configFlagValue(cmd *cobra.Command) string { + flag := cmd.Root().PersistentFlags().Lookup("config") + if flag == nil { + return "" + } + return flag.Value.String() +} + +// ResolveEdgeLogPath returns the effective edge log path. If explicit is set +// it wins. Otherwise it falls back to /logs/edge.log. +func ResolveEdgeLogPath(explicit string) (string, error) { + if explicit != "" { + return explicit, nil + } + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resolve binary: %w", err) + } + return filepath.Join(filepath.Dir(exe), "logs", "edge.log"), nil +} + +func ResolveBootstrapArtifactDir(dir string) string { + if dir == "" { + dir = "artifacts" + } + if filepath.IsAbs(dir) { + return dir + } + return filepath.Join(binaryDir(), dir) +} + +// binaryDir returns the directory of the running executable. Falls back to the +// current working directory if os.Executable fails. +func binaryDir() string { + if exe, err := os.Executable(); err == nil { + return filepath.Dir(exe) + } + if wd, err := os.Getwd(); err == nil { + return wd + } + return "." +} diff --git a/apps/edge/internal/edgecmd/root.go b/apps/edge/internal/edgecmd/root.go new file mode 100644 index 0000000..be02868 --- /dev/null +++ b/apps/edge/internal/edgecmd/root.go @@ -0,0 +1,55 @@ +package edgecmd + +import "github.com/spf13/cobra" + +// Options provides command roots owned by the edge main package while keeping +// command support logic inside the edgecmd package. +type Options struct { + ConfigPath *string + Serve *cobra.Command + Console *cobra.Command +} + +// NewRoot builds the iop-edge command surface. +func NewRoot(opts Options) *cobra.Command { + configPath := opts.ConfigPath + if configPath == nil { + var localConfigPath string + configPath = &localConfigPath + } + + root := &cobra.Command{ + Use: "edge", + Short: "IOP Edge — execution group controller", + Long: `IOP Edge is the backend execution group controller that coordinates multiple execution nodes. +It manages token-based node registration, node registry, node configuration delivery, and OpenAI-compatible routing. + +The official local development and field test flow is as follows: + 1. config init - Write a clean local edge.yaml configuration template. + 2. env - Verify resolved local environment, log paths, and listen URLs. + 3. node register - Add a node record to edge.yaml and generate its one-line bootstrap command. + 4. config check - Validate the edge.yaml configuration structure and constraints. + 5. serve (or console) - Start the Edge server (or interactive console) to accept connections. + 6. nodes list - List configured nodes and show their connection status. + 7. smoke openai - Perform an E2E health and chat completion smoke test on the OpenAI endpoint.`, + Example: ` iop-edge config init + iop-edge env + iop-edge node register my-node --adapter cli + iop-edge config check + iop-edge serve + iop-edge nodes list + iop-edge smoke openai --model gemma4:26b`, + } + root.PersistentFlags().StringVarP(configPath, "config", "c", "", "config file path (defaults to bundle-local edge.yaml)") + + commands := make([]*cobra.Command, 0, 10) + if opts.Serve != nil { + commands = append(commands, opts.Serve) + } + if opts.Console != nil { + commands = append(commands, opts.Console) + } + commands = append(commands, versionCmd(), configCmd(), envCmd(), setupCmd(), bootstrapCmd(), nodeCmd(), nodesCmd(), smokeCmd()) + root.AddCommand(commands...) + return root +} diff --git a/apps/edge/internal/edgecmd/setup.go b/apps/edge/internal/edgecmd/setup.go new file mode 100644 index 0000000..dea2d48 --- /dev/null +++ b/apps/edge/internal/edgecmd/setup.go @@ -0,0 +1,34 @@ +package edgecmd + +import ( + "github.com/spf13/cobra" + + "iop/packages/go/hostsetup" +) + +func setupCmd() *cobra.Command { + var opts hostsetup.SetupOptions + c := &cobra.Command{ + Use: "setup", + Short: "Prepare host environment and systemd unit for iop-edge", + RunE: func(cmd *cobra.Command, _ []string) error { + if isConfigExplicit(cmd) { + opts.ConfigPath = configFlagValue(cmd) + } else { + opts.ConfigPath = "/etc/iop/edge.yaml" + } + return hostsetup.Run(cmd.Context(), hostsetup.EdgeSpec(), opts, cmd.OutOrStdout()) + }, + } + c.Flags().StringVar(&opts.BinaryPath, "binary", "", "path to iop-edge binary (defaults to current executable)") + c.Flags().StringVar(&opts.DataDir, "data-dir", "", "data directory (defaults to spec)") + c.Flags().StringVar(&opts.UnitPath, "unit", "", "systemd unit path (defaults to spec)") + c.Flags().StringVar(&opts.User, "user", "", "service user") + c.Flags().StringVar(&opts.Group, "group", "", "service group") + c.Flags().BoolVar(&opts.Enable, "enable", false, "enable the systemd unit") + c.Flags().BoolVar(&opts.Start, "start", false, "start the systemd unit") + c.Flags().BoolVar(&opts.Restart, "restart", false, "restart the systemd unit") + c.Flags().BoolVar(&opts.DryRun, "dry-run", false, "print plan and previews without making changes") + c.Flags().BoolVar(&opts.OverwriteConfig, "overwrite-config", false, "overwrite an existing config file") + return c +} diff --git a/apps/edge/internal/edgecmd/smoke_openai.go b/apps/edge/internal/edgecmd/smoke_openai.go new file mode 100644 index 0000000..3d0da50 --- /dev/null +++ b/apps/edge/internal/edgecmd/smoke_openai.go @@ -0,0 +1,248 @@ +package edgecmd + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "time" + + "github.com/spf13/cobra" +) + +func smokeCmd() *cobra.Command { + c := &cobra.Command{ + Use: "smoke", + Short: "Run verification and smoke tests", + Long: `Run diagnostic smoke tests to verify the connectivity and functionality of IOP Edge components. + +Typical local flow: + 1. iop-edge config init + 2. iop-edge node register + 3. iop-edge serve + 4. iop-edge smoke openai --model `, + Example: ` iop-edge smoke openai --model gemma4:26b + iop-edge smoke openai --model gemma4:26b --base-url http://127.0.0.1:8080`, + } + c.AddCommand(smokeOpenAICmd()) + return c +} + +var ( + smokeModel string + smokeBaseURL string + smokePrompt string + smokeTimeout string +) + +type openAIModel struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + OwnedBy string `json:"owned_by"` +} + +type openAIModelsResponse struct { + Object string `json:"object"` + Data []openAIModel `json:"data"` +} + +type chatCompletionMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatCompletionChoice struct { + Index int `json:"index"` + Message chatCompletionMessage `json:"message"` + FinishReason string `json:"finish_reason"` +} + +type chatCompletionUsage struct { + PromptTokens int `json:"prompt_tokens"` + CompletionTokens int `json:"completion_tokens"` + TotalTokens int `json:"total_tokens"` +} + +type chatCompletionResponse struct { + ID string `json:"id"` + Object string `json:"object"` + Created int64 `json:"created"` + Model string `json:"model"` + Choices []chatCompletionChoice `json:"choices"` + Usage *chatCompletionUsage `json:"usage"` +} + +func resolveSmokeBaseURL(cmd *cobra.Command) (string, error) { + if smokeBaseURL != "" { + return strings.TrimSuffix(smokeBaseURL, "/"), nil + } + cfg, _, err := LoadEdgeConfig(cmd) + if err != nil { + return "", fmt.Errorf("failed to load edge config to resolve base URL (pass --base-url to override): %w", err) + } + listen := cfg.OpenAI.Listen + if listen == "" { + listen = "127.0.0.1:8080" + } + host, port, err := net.SplitHostPort(listen) + if err != nil { + return "http://127.0.0.1:8080", nil + } + if host == "0.0.0.0" || host == "" { + host = "127.0.0.1" + } + return "http://" + net.JoinHostPort(host, port), nil +} + +func smokeOpenAICmd() *cobra.Command { + c := &cobra.Command{ + Use: "openai", + Short: "Perform OpenAI-compatible endpoint smoke test", + Long: `Validate the IOP Edge OpenAI-compatible HTTP API surface. +It runs a three-step diagnostics check: + 1. Checks GET /healthz (server status). + 2. Checks GET /v1/models (advertised model compatibility). + 3. Checks POST /v1/chat/completions (inference request processing). + +If --base-url is not provided, it auto-discovers the OpenAI URL from the effective config's listen address.`, + Example: ` iop-edge smoke openai --model gemma4:26b + iop-edge smoke openai --model gemma4:26b --base-url http://127.0.0.1:8080 --prompt "say hello" --timeout 15s`, + RunE: func(cmd *cobra.Command, _ []string) error { + baseURL, err := resolveSmokeBaseURL(cmd) + if err != nil { + return err + } + + timeoutDur := 10 * time.Second + if smokeTimeout != "" { + d, err := time.ParseDuration(smokeTimeout) + if err != nil { + return fmt.Errorf("invalid timeout duration: %w", err) + } + timeoutDur = d + } + + client := &http.Client{ + Timeout: timeoutDur, + } + + fmt.Fprintf(cmd.OutOrStdout(), "IOP Edge OpenAI Smoke Test\n") + fmt.Fprintf(cmd.OutOrStdout(), "========================================\n") + fmt.Fprintf(cmd.OutOrStdout(), "Target Endpoint: %s\n", baseURL) + fmt.Fprintf(cmd.OutOrStdout(), "Target Model: %s\n", smokeModel) + fmt.Fprintf(cmd.OutOrStdout(), "Timeout: %s\n\n", timeoutDur) + + fmt.Fprintf(cmd.OutOrStdout(), "Step 1: Checking /healthz ... ") + healthURL := baseURL + "/healthz" + resp, err := client.Get(healthURL) + if err != nil { + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("GET %s failed: %w (make sure the edge server is running)", healthURL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("GET %s returned non-200 status %d: %s", healthURL, resp.StatusCode, string(body)) + } + var healthData map[string]interface{} + if err := json.NewDecoder(resp.Body).Decode(&healthData); err != nil { + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("failed to parse /healthz JSON response: %w", err) + } + fmt.Fprintln(cmd.OutOrStdout(), "[OK]") + + fmt.Fprintf(cmd.OutOrStdout(), "Step 2: Checking /v1/models ... ") + modelsURL := baseURL + "/v1/models" + resp, err = client.Get(modelsURL) + if err != nil { + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("GET %s failed: %w", modelsURL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("GET %s returned non-200 status %d: %s", modelsURL, resp.StatusCode, string(body)) + } + var modelsResp openAIModelsResponse + if err := json.NewDecoder(resp.Body).Decode(&modelsResp); err != nil { + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("failed to parse /v1/models JSON response: %w", err) + } + modelIDs := make([]string, 0, len(modelsResp.Data)) + modelMatched := false + for _, m := range modelsResp.Data { + modelIDs = append(modelIDs, m.ID) + if m.ID == smokeModel { + modelMatched = true + } + } + fmt.Fprintf(cmd.OutOrStdout(), "[OK] (found models: %s)\n", strings.Join(modelIDs, ", ")) + if !modelMatched { + fmt.Fprintf(cmd.OutOrStdout(), "WARNING: Target model %q is not advertised by /v1/models (advertised: %s)\n", smokeModel, strings.Join(modelIDs, ", ")) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Step 3: Checking /v1/chat/completions ... ") + prompt := smokePrompt + if prompt == "" { + prompt = "ping" + } + reqPayload := map[string]interface{}{ + "model": smokeModel, + "messages": []map[string]string{ + {"role": "user", "content": prompt}, + }, + "stream": false, + } + payloadBytes, err := json.Marshal(reqPayload) + if err != nil { + return fmt.Errorf("failed to marshal request payload: %w", err) + } + + chatURL := baseURL + "/v1/chat/completions" + resp, err = client.Post(chatURL, "application/json", bytes.NewReader(payloadBytes)) + if err != nil { + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("POST %s failed: %w (make sure active nodes are online)", chatURL, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("POST %s returned non-200 status %d: %s", chatURL, resp.StatusCode, string(body)) + } + var chatResp chatCompletionResponse + if err := json.NewDecoder(resp.Body).Decode(&chatResp); err != nil { + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("failed to parse /v1/chat/completions JSON response: %w", err) + } + if len(chatResp.Choices) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("POST %s returned empty choices", chatURL) + } + content := chatResp.Choices[0].Message.Content + if strings.TrimSpace(content) == "" { + fmt.Fprintln(cmd.OutOrStdout(), "[FAILED]") + return fmt.Errorf("POST %s returned empty assistant message content", chatURL) + } + fmt.Fprintf(cmd.OutOrStdout(), "[OK]\n") + fmt.Fprintf(cmd.OutOrStdout(), "Response Content: %q\n\n", content) + fmt.Fprintf(cmd.OutOrStdout(), "IOP Edge OpenAI Smoke Test SUCCESS!\n") + + return nil + }, + } + + c.Flags().StringVar(&smokeModel, "model", "", "model identifier to check (required)") + c.Flags().StringVar(&smokeBaseURL, "base-url", "", "override OpenAI base URL (e.g. http://127.0.0.1:8080)") + c.Flags().StringVar(&smokePrompt, "prompt", "ping", "prompt text to send") + c.Flags().StringVar(&smokeTimeout, "timeout", "10s", "client timeout duration") + c.MarkFlagRequired("model") + + return c +} diff --git a/apps/edge/internal/edgecmd/version.go b/apps/edge/internal/edgecmd/version.go new file mode 100644 index 0000000..b19a552 --- /dev/null +++ b/apps/edge/internal/edgecmd/version.go @@ -0,0 +1,20 @@ +package edgecmd + +import ( + "fmt" + + "github.com/spf13/cobra" + + "iop/packages/go/version" +) + +func versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print edge binary version", + RunE: func(cmd *cobra.Command, _ []string) error { + fmt.Fprintln(cmd.OutOrStdout(), version.Version) + return nil + }, + } +}