feat: architecture refactor foundation - edge service split and remote terminal bridge
This commit is contained in:
parent
d842c6a0f1
commit
9c7e5c342d
22 changed files with 2855 additions and 1651 deletions
|
|
@ -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를 만든다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
- 확인 필요: 없음
|
||||
|
|
@ -0,0 +1,166 @@
|
|||
<!-- task=m-architecture-refactor-foundation/01_edge_cmd_split plan=0 tag=REFACTOR -->
|
||||
|
||||
# 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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` 통과
|
||||
|
|
@ -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
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
<!-- task=m-architecture-refactor-foundation/01_edge_cmd_split plan=0 tag=REFACTOR -->
|
||||
|
||||
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<!-- task=m-architecture-refactor-foundation/02_edge_service_split plan=0 tag=REFACTOR -->
|
||||
|
||||
# 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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.
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
<!-- task=m-architecture-refactor-foundation/02_edge_service_split plan=0 tag=REFACTOR -->
|
||||
|
||||
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<!-- task=m-architecture-refactor-foundation/03+02_openai_surface_split plan=0 tag=REFACTOR -->
|
||||
|
||||
# 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-<milestone-slug>`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, 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.
|
||||
|
|
@ -0,0 +1,187 @@
|
|||
<!-- task=m-architecture-refactor-foundation/03+02_openai_surface_split plan=0 tag=REFACTOR -->
|
||||
|
||||
# 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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
341
apps/edge/internal/edgecmd/bootstrap.go
Normal file
341
apps/edge/internal/edgecmd/bootstrap.go
Normal file
|
|
@ -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 <artifact-base-url>/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" <<YAML
|
||||
transport:
|
||||
edge_addr: "$EDGE_ADDR"
|
||||
token: "$NODE_TOKEN"
|
||||
|
||||
logging:
|
||||
level: "${IOP_LOG_LEVEL:-info}"
|
||||
pretty: true
|
||||
|
||||
metrics:
|
||||
port: ${IOP_NODE_METRICS_PORT:-9091}
|
||||
YAML
|
||||
|
||||
echo "[iop-bootstrap] config=$CONFIG_FILE"
|
||||
echo "[iop-bootstrap] starting node against $EDGE_ADDR"
|
||||
exec "$BIN_FILE" serve --config "$CONFIG_FILE"
|
||||
`
|
||||
}
|
||||
154
apps/edge/internal/edgecmd/config.go
Normal file
154
apps/edge/internal/edgecmd/config.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package edgecmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"iop/apps/edge/internal/node"
|
||||
"iop/packages/go/config"
|
||||
"iop/packages/go/hostsetup"
|
||||
)
|
||||
|
||||
func LoadEdgeConfig(cmd *cobra.Command) (*config.EdgeConfig, string, error) {
|
||||
path, err := ResolveConfigPath(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 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 <node-id>
|
||||
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
|
||||
}
|
||||
218
apps/edge/internal/edgecmd/edgecmd_test.go
Normal file
218
apps/edge/internal/edgecmd/edgecmd_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
178
apps/edge/internal/edgecmd/env.go
Normal file
178
apps/edge/internal/edgecmd/env.go
Normal file
|
|
@ -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" -> "<host>: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, ", "))
|
||||
}
|
||||
376
apps/edge/internal/edgecmd/node_register.go
Normal file
376
apps/edge/internal/edgecmd/node_register.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
55
apps/edge/internal/edgecmd/nodes.go
Normal file
55
apps/edge/internal/edgecmd/nodes.go
Normal file
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
84
apps/edge/internal/edgecmd/paths.go
Normal file
84
apps/edge/internal/edgecmd/paths.go
Normal file
|
|
@ -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. <binary-dir>/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 <binary-dir>/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 "."
|
||||
}
|
||||
55
apps/edge/internal/edgecmd/root.go
Normal file
55
apps/edge/internal/edgecmd/root.go
Normal file
|
|
@ -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
|
||||
}
|
||||
34
apps/edge/internal/edgecmd/setup.go
Normal file
34
apps/edge/internal/edgecmd/setup.go
Normal file
|
|
@ -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
|
||||
}
|
||||
248
apps/edge/internal/edgecmd/smoke_openai.go
Normal file
248
apps/edge/internal/edgecmd/smoke_openai.go
Normal file
|
|
@ -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 <node-id>
|
||||
3. iop-edge serve
|
||||
4. iop-edge smoke openai --model <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
|
||||
}
|
||||
20
apps/edge/internal/edgecmd/version.go
Normal file
20
apps/edge/internal/edgecmd/version.go
Normal file
|
|
@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue