feat: edge local dev config and observability improvements
- Add edge node bootstrap and runtime configuration - Update observability with test coverage - Add hostsetup test and template updates - Add m-edge-local-dev-config-runtime task tracking
This commit is contained in:
parent
7d3e24bb08
commit
5bb8a4bc85
20 changed files with 2428 additions and 27 deletions
|
|
@ -0,0 +1,208 @@
|
|||
<!-- task=m-edge-local-dev-config-runtime/01_config_runtime_env plan=0 tag=CONFIG -->
|
||||
|
||||
# Code Review Reference - CONFIG
|
||||
|
||||
> **[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.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-05-28
|
||||
task=m-edge-local-dev-config-runtime/01_config_runtime_env, plan=0, tag=CONFIG
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G07.md` → `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/01_config_runtime_env/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [CONFIG-1] Bundle Config Discovery | [x] |
|
||||
| [CONFIG-2] Env Report And Advertise Host | [x] |
|
||||
| [CONFIG-3] Bundle Log Defaults | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] `iop-edge` default config resolution uses explicit `--config` first, then bundle-local `edge.yaml`, without breaking setup defaults.
|
||||
- [x] `iop-edge env` prints resolved config path, binary dir, log path, advertise host, Node transport address, artifact/bootstrap URL, OpenAI-compatible URL, configured node summary, and connected node summary placeholder/offline state.
|
||||
- [x] Empty advertise host is resolved from host interfaces and explicit advertise host wins over auto-detection.
|
||||
- [x] Empty local/dev log path resolves to `<binary-dir>/logs/edge.log`, creates the log directory on `serve`, and explicit log path wins.
|
||||
- [x] Verification covers `config-discovery`, `env-report`, `advertise-host`, and `log-defaults` roadmap checks with deterministic CLI/unit tests.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/`를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/01_config_runtime_env/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-edge-local-dev-config-runtime/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [x] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- `env` 출력 키 이름을 계획의 자연어 표현(`config path`, `binary dir` 등) 대신 stable parsable `key: value` (`config:`, `binary_dir:`, `log_path:`, `advertise_host:`, `node_transport:`, `artifact_base_url:`, `openai_url:`, `a2a_url:`, `configured_nodes:`, `connected_nodes:`) 형태로 통일했다. 사용자 가이드(`docs/field-bootstrap-user-test.md`)가 요구한 모든 항목(config path, binary dir, log dir, advertise host, Node transport address, artifact/bootstrap URL, OpenAI-compatible URL, configured nodes, connected nodes)을 포함하면서 CLI 테스트에서 동등성 비교가 가능하도록 했다.
|
||||
- `a2a_url` 한 줄을 추가로 출력한다. `a2a`는 `EdgeConfig`의 외부 입력 표면이고 OpenAI-compatible URL과 같은 advertise host 계산을 공유하므로 동일 기준으로 노출하는 편이 일관성이 높다. (`enabled=false`이면 `(disabled)`로 표시.)
|
||||
- `bootstrap.runtime` 의 logger 생성을 `observability.NewLoggerWithFile`로 옮기되, 기존 `NewLogger(level, pretty)`는 Node/Control Plane 호출자(`apps/node/internal/bootstrap/module.go`, `apps/control-plane/cmd/control-plane/main.go`)가 그대로 사용하도록 유지했다. 빈 path를 넘기면 `NewLoggerWithFile`이 내부적으로 기존 `NewLogger` 호출로 위임한다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- `resolveConfigPath` 후보 순서: explicit `--config` → `./edge.yaml` (cwd) → `<binary-dir>/edge.yaml` → `configs/edge.yaml` (있을 때만). `setup`은 `/etc/iop/edge.yaml` 기본을 유지하기 위해 helper를 호출하지 않고 기존 분기를 그대로 사용한다. 후보가 모두 없으면 `no edge.yaml found: pass --config or run beside a bundle edge.yaml` 에러로 사용자가 어떻게 진행해야 하는지를 알려준다.
|
||||
- `cfgFile`을 빈 문자열 기본값으로 두고 explicit 여부를 `cmd.Root().PersistentFlags().Lookup("config").Changed`로 판단한다. 기존 `TestSetupDryRun*` 테스트는 setup이 자신의 `/etc/iop/edge.yaml` 기본을 쓰는지만 확인하므로 영향이 없다.
|
||||
- advertise host 자동 판별 순서는 plan과 동일: 명시값 → non-loopback IPv4 → hostname → `127.0.0.1`. 자동 판별값 옆에 `(auto)`, 명시값 옆에 `(explicit)` source tag를 붙여 사용자에게 출처가 보이게 했다.
|
||||
- `addressFor(host, listen)`은 `0.0.0.0:9090` 같은 listen 값에서 port만 추출해 advertise host와 결합한다. listen에 port가 없거나 파싱 실패하면 listen을 그대로 반환해 잘못된 값이라도 가시화한다.
|
||||
- 로그 파일 기본값은 `<binary-dir>/logs/edge.log`. `serve`/`console` 진입에서 디렉터리를 미리 만들고 `cfg.Logging.Path`를 effective path로 덮어쓴 뒤 `bootstrap.Module(cfg)`에 전달한다. runtime은 `Logging.Path`만 보고 `observability.NewLoggerWithFile`을 호출하기 때문에 `env`/`config`/`setup` 같은 비실행 경로는 파일을 만들지 않는다.
|
||||
- 파일 로거는 `O_APPEND|O_CREATE|O_WRONLY (0o600)`으로 열고 디렉터리는 `0o755`로 생성한다. 기존 stderr 기반 zap 구성과 동일한 production encoder/level 정책을 유지하면서 sink만 파일로 바꿔 다른 호출자에게 영향이 없다.
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- Default config discovery must not silently prefer repo `configs/edge.yaml` when bundle `edge.yaml` exists.
|
||||
- `setup` must still default to `/etc/iop/edge.yaml`.
|
||||
- `env` output must be usable without starting Edge.
|
||||
- Log file defaults must not change Node or Control Plane logging behavior unless explicitly intended.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
|
||||
### CONFIG-1 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/cmd/edge
|
||||
ok iop/apps/edge/cmd/edge 0.010s
|
||||
```
|
||||
|
||||
### CONFIG-2 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./packages/config ./apps/edge/cmd/edge
|
||||
ok iop/packages/config 0.011s
|
||||
ok iop/apps/edge/cmd/edge 0.010s
|
||||
```
|
||||
|
||||
### CONFIG-3 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./packages/config ./packages/observability ./apps/edge/cmd/edge ./apps/edge/internal/bootstrap
|
||||
ok iop/packages/config 0.009s
|
||||
ok iop/packages/observability 0.014s
|
||||
ok iop/apps/edge/cmd/edge 0.011s
|
||||
ok iop/apps/edge/internal/bootstrap 0.010s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```text
|
||||
$ go test -count=1 ./packages/config ./packages/observability ./apps/edge/cmd/edge ./apps/edge/internal/bootstrap
|
||||
ok iop/packages/config 0.009s
|
||||
ok iop/packages/observability 0.014s
|
||||
ok iop/apps/edge/cmd/edge 0.011s
|
||||
ok iop/apps/edge/internal/bootstrap 0.010s
|
||||
```
|
||||
|
||||
```text
|
||||
$ tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge config check && ./iop-edge env)
|
||||
wrote edge.yaml
|
||||
OK edge.yaml
|
||||
config: edge.yaml
|
||||
binary_dir: /tmp/tmp.lud8wx9r3j
|
||||
log_path: /tmp/tmp.lud8wx9r3j/logs/edge.log
|
||||
advertise_host: 192.168.16.14 (auto)
|
||||
node_transport: 192.168.16.14:9090
|
||||
artifact_base_url: (unset)
|
||||
openai_url: (disabled)
|
||||
a2a_url: (disabled)
|
||||
configured_nodes: 0 (none)
|
||||
connected_nodes: offline (env does not start edge)
|
||||
```
|
||||
|
||||
`config check`는 `cd "$tmp"` 결과로 bundle-local `edge.yaml`을 explicit `--config` 없이 발견했다. `binary_dir`은 임시 디렉터리, `log_path`는 `<binary_dir>/logs/edge.log`, `advertise_host`는 컨테이너 non-loopback IPv4 자동 판별값(`192.168.16.14 (auto)`)이다. `hostsetup.EdgeSpec()` 기본 템플릿은 `openai`/`a2a`/`bootstrap`/`advertise_host`를 비워두므로 `artifact_base_url: (unset)`, `openai_url: (disabled)`, `a2a_url: (disabled)`, `configured_nodes: 0 (none)`가 정상이다. `connected_nodes: offline (env does not start edge)`는 env 명령이 edge를 실행하지 않음을 명시한다.
|
||||
|
||||
추가 회귀 확인용 풀테스트:
|
||||
|
||||
```text
|
||||
$ go test -count=1 ./...
|
||||
ok iop/apps/control-plane/cmd/control-plane 0.008s
|
||||
ok iop/apps/control-plane/internal/wire 0.114s
|
||||
ok iop/apps/edge/cmd/edge 0.016s
|
||||
ok iop/apps/edge/internal/bootstrap 0.020s
|
||||
ok iop/apps/edge/internal/events 0.005s
|
||||
ok iop/apps/edge/internal/input 0.009s
|
||||
ok iop/apps/edge/internal/input/a2a 0.011s
|
||||
ok iop/apps/edge/internal/node 0.009s
|
||||
ok iop/apps/edge/internal/openai 1.507s
|
||||
ok iop/apps/edge/internal/opsconsole 0.006s
|
||||
ok iop/apps/edge/internal/service 0.004s
|
||||
ok iop/apps/edge/internal/transport 0.007s
|
||||
ok iop/apps/node/cmd/node 0.010s
|
||||
ok iop/apps/node/internal/adapters 0.005s
|
||||
ok iop/apps/node/internal/adapters/cli 41.371s
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.730s
|
||||
ok iop/apps/node/internal/adapters/ollama 0.009s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.007s
|
||||
ok iop/apps/node/internal/bootstrap 0.162s
|
||||
ok iop/apps/node/internal/node 0.013s
|
||||
ok iop/apps/node/internal/router 0.005s
|
||||
ok iop/apps/node/internal/store 0.041s
|
||||
ok iop/apps/node/internal/transport 5.036s
|
||||
ok iop/packages/config 0.019s
|
||||
ok iop/packages/hostsetup 0.014s
|
||||
ok iop/packages/observability 0.016s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
|
||||
> If anything is blank, go back and fill it in before saving this file.
|
||||
> Leave review-agent-only sections unchanged.
|
||||
|
||||
## 코드리뷰 결과
|
||||
|
||||
### 종합 판정
|
||||
|
||||
FAIL
|
||||
|
||||
### 차원별 평가
|
||||
|
||||
| 차원 | 평가 | 근거 |
|
||||
|------|------|------|
|
||||
| Correctness | Fail | setup 설치 경로에서 빈 `logging.path`가 service user가 쓰기 어려운 binary dir log path로 해석될 수 있다. |
|
||||
| Completeness | Fail | bundle-local log default는 구현됐지만 setup/default config template과의 상호작용 검증이 빠졌다. |
|
||||
| Test coverage | Fail | `iop-edge setup --dry-run`/hostsetup template이 service-writable log path를 제공하는지 확인하는 회귀 테스트가 없다. |
|
||||
| API contract | Fail | `setup`이 생성하는 `/etc/iop/edge.yaml` + systemd unit 조합이 기본 `serve` 실행 계약을 깨뜨릴 수 있다. |
|
||||
| Code quality | Pass | 변경 자체는 좁고 구조적이며 불필요한 debug 출력은 보이지 않는다. |
|
||||
| Plan deviation | Warn | `config init`이 `hostsetup.EdgeSpec().ConfigTemplate`을 공유하면서 local bundle template과 setup template 책임이 섞였다. |
|
||||
| Verification trust | Pass | 계획의 대상 테스트, bundle smoke, `go test -count=1 ./...`를 재실행해 기록된 결과와 일치함을 확인했다. |
|
||||
|
||||
### 발견된 문제
|
||||
|
||||
- Required `packages/hostsetup/templates.go:13`: `EdgeSpec()`가 쓰는 setup config template은 `logging.path`를 비워 둔 채 유지됩니다. 그런데 `iop-edge serve`는 빈 값을 `<binary-dir>/logs/edge.log`로 확정합니다(`apps/edge/cmd/edge/main.go:108`), setup unit은 `User=iop`로 `/usr/local/bin/iop-edge serve --config /etc/iop/edge.yaml` 형태를 렌더링합니다(`packages/hostsetup/systemd.go:17`). 따라서 setup으로 설치한 서비스는 기본 설정 그대로 시작할 때 `iop` 사용자가 보통 쓸 수 없는 `/usr/local/bin/logs` 생성을 시도할 수 있습니다. setup용 template에는 service-owned path(예: `/var/lib/iop/edge/logs/edge.log`)를 명시하고, bundle `config init`은 별도 template으로 빈 log path를 유지하도록 분리하세요. 이 둘을 각각 dry-run/template 테스트로 고정해야 합니다.
|
||||
|
||||
### 다음 단계
|
||||
|
||||
FAIL 후속으로 `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.md`를 작성한다.
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
<!-- task=m-edge-local-dev-config-runtime/01_config_runtime_env plan=1 tag=REVIEW_CONFIG -->
|
||||
|
||||
# Code Review Reference - REVIEW_CONFIG
|
||||
|
||||
> **[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.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-05-28
|
||||
task=m-edge-local-dev-config-runtime/01_config_runtime_env, plan=1, tag=REVIEW_CONFIG
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G07.md` -> `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` -> `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/01_config_runtime_env/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [REVIEW_CONFIG-1] Setup-Safe Edge Log Path | [x] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [x] Setup-installed Edge config gets an explicit service-writable log path while `config init` keeps bundle-local empty log-path behavior.
|
||||
- [x] Regression tests cover setup template/dry-run log path and bundle `config init` new-field/empty-log-path output.
|
||||
- [x] `go test -count=1 ./packages/hostsetup ./apps/edge/cmd/edge ./apps/edge/internal/bootstrap` passes and bundle `config init`/`env` smoke still reports `<binary-dir>/logs/edge.log`.
|
||||
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [x] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [x] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [x] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
||||
- [x] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
- [x] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [x] PASS이면 active task 디렉터리 `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/`를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/01_config_runtime_env/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [x] PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [x] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-edge-local-dev-config-runtime/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
- 계획에 없던 회귀 테스트 `TestEdgeBundleConfigTemplateKeepsEmptyLogPath`를 `packages/hostsetup/setup_test.go`에 추가했다. setup template에 service log path가 들어가는 것을 고정한다면, 반대로 bundle template에는 service path가 누설되지 않아야 두 책임이 진짜로 분리된다. 한쪽만 고정하면 향후 둘을 다시 합치는 변경이 silently 통과할 수 있어 negative assertion을 함께 둔 것이 안전하다.
|
||||
- 그 외 수정 파일과 테스트 이름은 plan과 동일하다.
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
- 두 template은 `packages/hostsetup/templates.go` 안에 인접해 둔다. setup용은 `EdgeSpec().ConfigTemplate`로 그대로 유지해 기존 `hostsetup.Run` 호출 계약을 깨지 않고, bundle용은 새 accessor `EdgeBundleConfigTemplate()`로만 노출한다. `AppSpec`에 새 필드를 추가하면 `NodeSpec`/Control Plane 등 다른 host에도 의미가 흘러가버려 의도와 어긋난다.
|
||||
- setup용 path는 `/var/lib/iop/edge/logs/edge.log`로 고정한다. `EdgeSpec().DefaultDataDir == "/var/lib/iop/edge"`는 setup이 `chown -R iop:iop`로 만드는 service-owned 디렉터리이고, runtime의 `observability.NewLoggerWithFile`이 `logs/` 하위를 0o755로 생성하므로 service user가 그대로 쓸 수 있다. `/var/log/iop/edge.log` 같은 시스템 공유 경로는 service user 권한 충돌 위험이 더 크다.
|
||||
- bundle template은 `logging.path: ""`를 명시 키로 노출한다. 사용자가 `config init` 결과를 보고 새 키의 존재(`server.advertise_host`, `bootstrap.artifact_base_url`, `logging.path`)를 알게 되고, 빈 값을 그대로 두면 `iop-edge serve`/`env`가 `<binary-dir>/logs/edge.log`로 해석한다는 contract가 `env` 출력과 함께 닫힌다.
|
||||
- 기존 `TestConfigInitPrintsTemplateToStdout`의 `nodes: []` 검사 등을 깨지 않도록 두 template 모두 같은 top-level key 순서와 `nodes: []` empty list를 유지한다. setup unit 렌더링(`packages/hostsetup/systemd.go`)은 손대지 않는다 — systemd unit 변경 없이도 path 충돌이 해소되기 때문에 표면을 작게 유지한다.
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- setup-generated config must not require the `iop` service user to write under `/usr/local/bin`.
|
||||
- bundle `config init` must keep empty `logging.path` so local bundle logs still resolve to `<binary-dir>/logs/edge.log`.
|
||||
- setup template and bundle template must both expose the new config keys users need to discover.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
|
||||
### REVIEW_CONFIG-1 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./packages/hostsetup ./apps/edge/cmd/edge ./apps/edge/internal/bootstrap
|
||||
ok iop/packages/hostsetup 0.005s
|
||||
ok iop/apps/edge/cmd/edge 0.012s
|
||||
ok iop/apps/edge/internal/bootstrap 0.010s
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```text
|
||||
$ go test -count=1 ./packages/hostsetup ./apps/edge/cmd/edge ./apps/edge/internal/bootstrap
|
||||
ok iop/packages/hostsetup 0.005s
|
||||
ok iop/apps/edge/cmd/edge 0.012s
|
||||
ok iop/apps/edge/internal/bootstrap 0.010s
|
||||
```
|
||||
|
||||
```text
|
||||
$ tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge config check && ./iop-edge env)
|
||||
wrote edge.yaml
|
||||
OK edge.yaml
|
||||
config: edge.yaml
|
||||
binary_dir: /tmp/tmp.7L3PTxa4Oa
|
||||
log_path: /tmp/tmp.7L3PTxa4Oa/logs/edge.log
|
||||
advertise_host: 192.168.16.14 (auto)
|
||||
node_transport: 192.168.16.14:9090
|
||||
artifact_base_url: (unset)
|
||||
openai_url: (disabled)
|
||||
a2a_url: (disabled)
|
||||
configured_nodes: 0 (none)
|
||||
connected_nodes: offline (env does not start edge)
|
||||
```
|
||||
|
||||
`config init`이 bundle template을 쓰면서 `logging.path`가 비어 있고, `env` 출력의 `log_path`가 임시 binary 디렉터리의 `logs/edge.log`로 해석되는 것을 확인했다. setup 경로는 `TestSetupDryRunUsesEdgeDefaults`가 `path: "/var/lib/iop/edge/logs/edge.log"`를 dry-run config preview에서 직접 검증한다.
|
||||
|
||||
회귀 확인용 풀테스트:
|
||||
|
||||
```text
|
||||
$ go test -count=1 ./...
|
||||
ok iop/apps/control-plane/cmd/control-plane 0.007s
|
||||
ok iop/apps/control-plane/internal/wire 0.112s
|
||||
ok iop/apps/edge/cmd/edge 0.014s
|
||||
ok iop/apps/edge/internal/bootstrap 0.014s
|
||||
ok iop/apps/edge/internal/events 0.003s
|
||||
ok iop/apps/edge/internal/input 0.004s
|
||||
ok iop/apps/edge/internal/input/a2a 0.007s
|
||||
ok iop/apps/edge/internal/node 0.004s
|
||||
ok iop/apps/edge/internal/openai 1.507s
|
||||
ok iop/apps/edge/internal/opsconsole 0.005s
|
||||
ok iop/apps/edge/internal/service 0.004s
|
||||
ok iop/apps/edge/internal/transport 0.010s
|
||||
ok iop/apps/node/cmd/node 0.009s
|
||||
ok iop/apps/node/internal/adapters 0.005s
|
||||
ok iop/apps/node/internal/adapters/cli 41.356s
|
||||
ok iop/apps/node/internal/adapters/cli/status 39.733s
|
||||
ok iop/apps/node/internal/adapters/ollama 0.008s
|
||||
ok iop/apps/node/internal/adapters/vllm 0.006s
|
||||
ok iop/apps/node/internal/bootstrap 0.164s
|
||||
ok iop/apps/node/internal/node 0.012s
|
||||
ok iop/apps/node/internal/router 0.004s
|
||||
ok iop/apps/node/internal/store 0.041s
|
||||
ok iop/apps/node/internal/transport 5.029s
|
||||
ok iop/packages/config 0.016s
|
||||
ok iop/packages/hostsetup 0.007s
|
||||
ok iop/packages/observability 0.007s
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
> **[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 | setup template에는 service-owned log path가 명시되고, bundle template은 empty path를 유지해 두 실행 경로의 log contract가 분리됐다. |
|
||||
| Completeness | Pass | 이전 Required의 수정 파일과 테스트가 모두 반영됐고 구현 체크리스트도 완료됐다. |
|
||||
| Test coverage | Pass | setup template, bundle template, setup dry-run, bundle smoke 경로가 모두 회귀 테스트/검증으로 확인된다. |
|
||||
| API contract | Pass | `EdgeSpec().ConfigTemplate` 기존 setup 호출 계약은 유지하고, bundle `config init`만 새 accessor를 사용한다. |
|
||||
| Code quality | Pass | 변경 범위가 좁고 debug 출력, dead code, 불필요한 리팩터가 보이지 않는다. |
|
||||
| Plan deviation | Pass | 추가 negative test는 template 책임 분리 검증을 강화하는 범위 내 변경이다. |
|
||||
| Verification trust | Pass | 계획 검증 명령, bundle smoke, `go test -count=1 ./...`를 재실행해 통과를 확인했다. |
|
||||
|
||||
### 발견된 문제
|
||||
|
||||
없음
|
||||
|
||||
### 다음 단계
|
||||
|
||||
PASS이므로 `complete.log` 작성 후 active task 디렉터리를 archive로 이동한다.
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
# Complete - m-edge-local-dev-config-runtime/01_config_runtime_env
|
||||
|
||||
## 완료 일시
|
||||
|
||||
2026-05-28
|
||||
|
||||
## 요약
|
||||
|
||||
Edge-local config discovery, env report, advertise host, bundle log default, and setup-safe log template work completed after 2 review loops; final verdict PASS.
|
||||
|
||||
## 루프 이력
|
||||
|
||||
| Plan | Review | Verdict | 메모 |
|
||||
|------|--------|---------|------|
|
||||
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | setup-installed config left `logging.path` empty, which could make the `iop` service user write under the binary directory. |
|
||||
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | PASS | setup and bundle config templates were split and verified with focused tests plus bundle smoke. |
|
||||
|
||||
## 구현/정리 내용
|
||||
|
||||
- `iop-edge` config discovery now prefers explicit `--config`, then bundle-local `edge.yaml`, then executable-adjacent `edge.yaml`, then repo dev fallback.
|
||||
- `iop-edge env` reports resolved config path, binary dir, log path, advertise host, node transport, artifact URL, OpenAI/A2A URLs, configured nodes, and offline connected-node state.
|
||||
- Edge config supports `server.advertise_host`, `bootstrap.artifact_base_url`, and `logging.path`.
|
||||
- Edge runtime can write logs to a configured file path while existing stderr logger callers remain unchanged.
|
||||
- setup-installed Edge config uses `/var/lib/iop/edge/logs/edge.log`, while bundle `config init` keeps `logging.path: ""` so local logs resolve under the binary directory.
|
||||
|
||||
## 최종 검증
|
||||
|
||||
- `go test -count=1 ./packages/hostsetup ./apps/edge/cmd/edge ./apps/edge/internal/bootstrap` - PASS; hostsetup, edge CLI, and edge bootstrap tests passed.
|
||||
- `tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge config check && ./iop-edge env)` - PASS; bundle config check succeeded and env reported `<tmp>/logs/edge.log`.
|
||||
- `go test -count=1 ./...` - PASS; all Go packages passed, including edge, node, config, hostsetup, and observability.
|
||||
|
||||
## 잔여 Nit
|
||||
|
||||
- 없음
|
||||
|
||||
## 후속 작업
|
||||
|
||||
- 없음
|
||||
|
|
@ -0,0 +1,230 @@
|
|||
<!-- task=m-edge-local-dev-config-runtime/01_config_runtime_env plan=0 tag=CONFIG -->
|
||||
|
||||
# Plan - CONFIG
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고 실제 출력과 구현 메모를 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. finalization은 code-review-skill 전용이다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 `iop-edge`는 기본 config 경로가 repo 상대 `configs/edge.yaml`에 고정되어 있어 배포 bundle 단독 실행 경로와 맞지 않는다. 사용자용 가이드는 `./iop-edge env`, 자동 advertise host, bundle-local logs를 전제로 하지만 CLI/config/runtime에는 아직 해당 계약이 없다. 이 subtask는 후속 Node register/bootstrap이 사용할 config path, address, log 위치의 기반을 먼저 만든다.
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
|
||||
- `agent-roadmap/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md`
|
||||
- `apps/edge/cmd/edge/main.go`
|
||||
- `apps/edge/cmd/edge/main_test.go`
|
||||
- `apps/edge/internal/bootstrap/runtime.go`
|
||||
- `apps/edge/internal/bootstrap/runtime_test.go`
|
||||
- `packages/config/config.go`
|
||||
- `packages/config/config_test.go`
|
||||
- `packages/hostsetup/templates.go`
|
||||
- `packages/hostsetup/setup.go`
|
||||
- `packages/hostsetup/setup_test.go`
|
||||
- `packages/observability/observability.go`
|
||||
- `configs/edge.yaml`
|
||||
- `docs/field-bootstrap-user-test.md`
|
||||
- `docs/field-bootstrap-work-guide.md`
|
||||
- `docs/deploy-dev.md`
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- Config discovery: existing CLI tests cover explicit `--config`, but no test covers default bundle `edge.yaml` discovery.
|
||||
- `env` report: no command or test exists.
|
||||
- advertise host: no config field, resolver, or empty-vs-explicit test exists.
|
||||
- log defaults: `observability.NewLogger` only writes stderr; no file logger or bundle-local default test exists.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- none. No rename/remove is planned.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
Split decision policy was evaluated before choosing plan files. Shared task group: `agent-task/m-edge-local-dev-config-runtime/`.
|
||||
- `01_config_runtime_env`: independent foundation for config discovery, env report, advertise host, and log defaults.
|
||||
- `02+01_node_register_bootstrap`: depends on `01_config_runtime_env` because bootstrap command rendering needs resolved advertise/artifact addresses and config path behavior.
|
||||
- `03+01,02_help_smoke_bundle_docs`: depends on both earlier subtasks because help, smoke, and docs must describe implemented command behavior.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
Do not implement Node register, bootstrap script generation, smoke execution, or docs rewrite here. Do not change Edge-Node transport protocol, OpenAI request behavior, Control Plane sync, or Node CLI UX. Keep `setup` default `/etc/iop/edge.yaml` behavior intact.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
build=`cloud-G07`, review=`cloud-G07`. This is terminal/CLI workflow work with config path contracts, stdout output, logging side effects, and full-cycle verification requirements.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] `iop-edge` default config resolution uses explicit `--config` first, then bundle-local `edge.yaml`, without breaking setup defaults.
|
||||
- [ ] `iop-edge env` prints resolved config path, binary dir, log path, advertise host, Node transport address, artifact/bootstrap URL, OpenAI-compatible URL, configured node summary, and connected node summary placeholder/offline state.
|
||||
- [ ] Empty advertise host is resolved from host interfaces and explicit advertise host wins over auto-detection.
|
||||
- [ ] Empty local/dev log path resolves to `<binary-dir>/logs/edge.log`, creates the log directory on `serve`, and explicit log path wins.
|
||||
- [ ] Verification covers `config-discovery`, `env-report`, `advertise-host`, and `log-defaults` roadmap checks with deterministic CLI/unit tests.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
This subtask has no predecessor. `02+01_node_register_bootstrap` and `03+01,02_help_smoke_bundle_docs` must wait for this subtask's `complete.log`.
|
||||
|
||||
### [CONFIG-1] Bundle Config Discovery
|
||||
|
||||
#### 문제
|
||||
|
||||
[apps/edge/cmd/edge/main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:34) fixes the persistent `--config` default to `configs/edge.yaml`, and [docs/field-bootstrap-user-test.md](/config/workspace/iop/docs/field-bootstrap-user-test.md:105) expects `./iop-edge config check` to work in a bundle directory.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// apps/edge/cmd/edge/main.go:34
|
||||
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "configs/edge.yaml", "config file path")
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Add an edge CLI config resolver in `apps/edge/cmd/edge/main.go`:
|
||||
|
||||
```go
|
||||
// apps/edge/cmd/edge/main.go:34
|
||||
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path")
|
||||
```
|
||||
|
||||
Use `cmd.Root().PersistentFlags().Lookup("config").Changed` or equivalent to distinguish explicit config. Candidate order: explicit `--config`, current working directory `edge.yaml`, executable directory `edge.yaml`, then repo-dev fallback `configs/edge.yaml` only if it exists. Return the resolved path and use it in `serve`, `console`, `config print`, `config check`, and `env`; keep `setup` default logic on `/etc/iop/edge.yaml`.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/cmd/edge/main.go`: add resolver helper and replace direct `config.LoadEdge(cfgFile)` calls.
|
||||
- [ ] `apps/edge/cmd/edge/main_test.go`: add temp bundle test where only `iop-edge` test process cwd plus `edge.yaml` exists and `config check` succeeds without `--config`.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
Write `TestConfigCheckUsesBundleEdgeYAMLByDefault` and `TestExplicitConfigWinsOverBundleDefault` in `apps/edge/cmd/edge/main_test.go`.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/cmd/edge
|
||||
```
|
||||
|
||||
Expected: pass; cache output is not acceptable because config discovery depends on temp directories.
|
||||
|
||||
### [CONFIG-2] Env Report And Advertise Host
|
||||
|
||||
#### 문제
|
||||
|
||||
[docs/field-bootstrap-user-test.md](/config/workspace/iop/docs/field-bootstrap-user-test.md:47) documents `./iop-edge env`, but [apps/edge/cmd/edge/main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:35) has no `env` command. [packages/config/config.go](/config/workspace/iop/packages/config/config.go:46) has only `server.listen`; there is no advertise host or artifact/bootstrap URL field.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// packages/config/config.go:46
|
||||
type EdgeServerConf struct {
|
||||
Listen string `mapstructure:"listen" yaml:"listen"`
|
||||
}
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Extend config minimally:
|
||||
|
||||
```go
|
||||
type EdgeServerConf struct {
|
||||
Listen string `mapstructure:"listen" yaml:"listen"`
|
||||
AdvertiseHost string `mapstructure:"advertise_host" yaml:"advertise_host"`
|
||||
}
|
||||
|
||||
type EdgeBootstrapConf struct {
|
||||
ArtifactBaseURL string `mapstructure:"artifact_base_url" yaml:"artifact_base_url"`
|
||||
}
|
||||
```
|
||||
|
||||
Add `Bootstrap EdgeBootstrapConf` to `EdgeConfig`. `env` should print stable `key: value` lines: config path, binary dir, log path, advertise host, node transport address, artifact/bootstrap URL, OpenAI-compatible URL, configured nodes, connected nodes. If no runtime is running, print connected nodes as `offline` or `0 (env command does not start edge)`. Auto advertise host should prefer explicit `server.advertise_host`, else non-loopback IPv4, else hostname, else `127.0.0.1`.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `packages/config/config.go`: add config fields and defaults.
|
||||
- [ ] `packages/config/config_test.go`: add empty/default and explicit advertise/artifact tests.
|
||||
- [ ] `apps/edge/cmd/edge/main.go`: add `env` command and deterministic env renderer helpers.
|
||||
- [ ] `apps/edge/cmd/edge/main_test.go`: add `env` output tests for explicit advertise host and artifact URL.
|
||||
- [ ] `configs/edge.yaml`: include new empty/field-like keys without putting field tokens in defaults.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
Write unit tests for config unmarshal/defaults and CLI env rendering. Avoid asserting the real machine IP; test explicit advertise host and structure of auto output.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./packages/config ./apps/edge/cmd/edge
|
||||
```
|
||||
|
||||
Expected: pass; cache output is not acceptable.
|
||||
|
||||
### [CONFIG-3] Bundle Log Defaults
|
||||
|
||||
#### 문제
|
||||
|
||||
[docs/field-bootstrap-user-test.md](/config/workspace/iop/docs/field-bootstrap-user-test.md:67) expects logs under `/opt/iop-dev/logs/`, but [packages/config/config.go](/config/workspace/iop/packages/config/config.go:121) has only `LoggingConf.Level/Pretty`, and [apps/edge/internal/bootstrap/runtime.go](/config/workspace/iop/apps/edge/internal/bootstrap/runtime.go:39) creates a stderr logger only.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// apps/edge/internal/bootstrap/runtime.go:39
|
||||
logger, err := observability.NewLogger(cfg.Logging.Level, cfg.Logging.Pretty)
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Add `LoggingConf.Path string` or `LoggingConf.File string` in `packages/config`. Resolve empty edge log path in the CLI before `bootstrap.Module(cfg)` to `<binary-dir>/logs/edge.log`; if explicit path is configured, use it. Extend observability with a file-output constructor or option without changing Control Plane/Node callers. `env` must print the effective log path.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `packages/config/config.go`: add log path field and tests.
|
||||
- [ ] `packages/observability/observability.go`: add file logger constructor or option while keeping existing `NewLogger` behavior.
|
||||
- [ ] `apps/edge/cmd/edge/main.go`: resolve effective log path for `serve`, `console`, and `env`.
|
||||
- [ ] `apps/edge/internal/bootstrap/runtime.go`: use the effective path when present.
|
||||
- [ ] `apps/edge/internal/bootstrap/runtime_test.go`: verify runtime accepts file logging without starting metrics.
|
||||
- [ ] `apps/edge/cmd/edge/main_test.go`: verify clean temp dir creates `<dir>/logs/edge.log` after starting a short-lived command or isolated logger helper.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
Write a focused test around the resolver/logger helper instead of a long-running full `serve` process where possible. Add one integration-style CLI test if helper coverage cannot prove file creation.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./packages/config ./packages/observability ./apps/edge/cmd/edge ./apps/edge/internal/bootstrap
|
||||
```
|
||||
|
||||
Expected: pass; cache output is not acceptable.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|---|---|
|
||||
| `apps/edge/cmd/edge/main.go` | CONFIG-1, CONFIG-2, CONFIG-3 |
|
||||
| `apps/edge/cmd/edge/main_test.go` | CONFIG-1, CONFIG-2, CONFIG-3 |
|
||||
| `packages/config/config.go` | CONFIG-2, CONFIG-3 |
|
||||
| `packages/config/config_test.go` | CONFIG-2, CONFIG-3 |
|
||||
| `packages/observability/observability.go` | CONFIG-3 |
|
||||
| `apps/edge/internal/bootstrap/runtime.go` | CONFIG-3 |
|
||||
| `apps/edge/internal/bootstrap/runtime_test.go` | CONFIG-3 |
|
||||
| `configs/edge.yaml` | CONFIG-2, CONFIG-3 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./packages/config ./packages/observability ./apps/edge/cmd/edge ./apps/edge/internal/bootstrap
|
||||
```
|
||||
|
||||
```bash
|
||||
tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge config check && ./iop-edge env)
|
||||
```
|
||||
|
||||
Expected: tests pass; temp bundle command prints `OK edge.yaml` or the resolved temp path and env output includes config path, binary dir, log path, advertise host, Node transport address, artifact/bootstrap URL, OpenAI-compatible URL, configured nodes, connected nodes.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
<!-- task=m-edge-local-dev-config-runtime/01_config_runtime_env plan=1 tag=REVIEW_CONFIG -->
|
||||
|
||||
# Plan - REVIEW_CONFIG
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고 실제 출력과 구현 메모를 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. finalization은 code-review-skill 전용이다.
|
||||
|
||||
## 배경
|
||||
|
||||
이전 리뷰는 bundle-local log default 자체는 통과했지만, setup이 생성하는 systemd/service config와 새 log default가 충돌할 수 있음을 발견했다. `iop-edge setup` 경로는 `User=iop` 서비스로 실행되므로 빈 `logging.path`가 `/usr/local/bin/logs/edge.log`로 해석되면 기본 설치가 시작 실패할 수 있다. 후속 작업은 setup용 config template과 bundle `config init` template 책임을 분리하는 좁은 수정이다.
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/plan_cloud_G07_0.log`
|
||||
- `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/code_review_cloud_G07_0.log`
|
||||
- `apps/edge/cmd/edge/main.go`
|
||||
- `apps/edge/cmd/edge/main_test.go`
|
||||
- `apps/edge/internal/bootstrap/runtime.go`
|
||||
- `apps/edge/internal/bootstrap/runtime_test.go`
|
||||
- `apps/edge/internal/opsconsole/console.go`
|
||||
- `packages/config/config.go`
|
||||
- `packages/config/config_test.go`
|
||||
- `packages/hostsetup/templates.go`
|
||||
- `packages/hostsetup/setup.go`
|
||||
- `packages/hostsetup/systemd.go`
|
||||
- `packages/hostsetup/setup_test.go`
|
||||
- `packages/observability/observability.go`
|
||||
- `packages/observability/observability_test.go`
|
||||
- `configs/edge.yaml`
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- Bundle `config init` smoke는 통과하지만, 출력 template이 새 config keys와 빈 bundle log path를 명시하는지 검증하지 않는다.
|
||||
- `setup --dry-run`은 `/etc/iop/edge.yaml` 기본은 검증하지만, config preview가 service-owned log path를 제공하는지 검증하지 않는다.
|
||||
- Hostsetup template 단위 테스트가 없어 setup config와 bundle config의 log-path 차이를 고정하지 못한다.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- none. Rename/remove 계획은 없다.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
Split decision policy를 평가했다. 문제는 `hostsetup` template과 `config init` 호출부 사이의 단일 책임 분리이며, 수정 파일 수는 몇 개지만 검증 목표가 하나라 같은 active subtask 안의 단일 follow-up plan이 더 안전하다. 기존 sibling subtask 의존성은 변경하지 않는다.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
Node register/bootstrap 구현, smoke command, docs rewrite, runtime transport, OpenAI/A2A behavior는 제외한다. `resolveEdgeLogPath`의 bundle-local 기본 자체는 유지하고, setup 경로에서만 명시적인 service-writable path가 config에 들어가게 한다.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
build=`cloud-G07`, review=`cloud-G07`. CLI/setup/systemd config contract와 stdout template 검증이 얽힌 terminal workflow follow-up이다.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] Setup-installed Edge config gets an explicit service-writable log path while `config init` keeps bundle-local empty log-path behavior.
|
||||
- [ ] Regression tests cover setup template/dry-run log path and bundle `config init` new-field/empty-log-path output.
|
||||
- [ ] `go test -count=1 ./packages/hostsetup ./apps/edge/cmd/edge ./apps/edge/internal/bootstrap` passes and bundle `config init`/`env` smoke still reports `<binary-dir>/logs/edge.log`.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
### [REVIEW_CONFIG-1] Setup-Safe Edge Log Path
|
||||
|
||||
#### 문제
|
||||
|
||||
`packages/hostsetup/templates.go:13`의 `edgeConfigTemplate`은 `logging.path`를 쓰지 않는다. 그런데 `apps/edge/cmd/edge/main.go:108`은 빈 log path를 `<binary-dir>/logs/edge.log`로 확정하고, setup unit은 `packages/hostsetup/systemd.go:17`의 `User=iop`로 `packages/hostsetup/systemd.go:19`의 binary path를 실행한다. 기본 설치 binary가 `/usr/local/bin/iop-edge`이면 service user가 `/usr/local/bin/logs`를 만들 수 없어 `serve`가 실패할 수 있다.
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
`packages/hostsetup`에 setup용 template과 bundle-init용 template을 분리한다.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// packages/hostsetup/templates.go:13
|
||||
logging:
|
||||
level: "info"
|
||||
pretty: false
|
||||
```
|
||||
|
||||
After:
|
||||
|
||||
```go
|
||||
// packages/hostsetup/templates.go
|
||||
const edgeConfigTemplate = `...
|
||||
logging:
|
||||
level: "info"
|
||||
pretty: false
|
||||
path: "/var/lib/iop/edge/logs/edge.log"
|
||||
...`
|
||||
|
||||
const edgeBundleConfigTemplate = `...
|
||||
logging:
|
||||
level: "info"
|
||||
pretty: false
|
||||
path: ""
|
||||
...`
|
||||
|
||||
func EdgeBundleConfigTemplate() string {
|
||||
return edgeBundleConfigTemplate
|
||||
}
|
||||
```
|
||||
|
||||
`apps/edge/cmd/edge/main.go:194`의 `config init`은 `hostsetup.EdgeSpec().ConfigTemplate` 대신 bundle template을 사용한다. 두 template 모두 `server.advertise_host`, `bootstrap.artifact_base_url`, `logging.path`를 명시해서 사용자가 새 설정 표면을 볼 수 있게 한다.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `packages/hostsetup/templates.go`: setup template에는 `/var/lib/iop/edge/logs/edge.log`, bundle template에는 empty `logging.path`를 명시하고 bundle template accessor를 추가한다.
|
||||
- [ ] `packages/hostsetup/setup_test.go`: `EdgeSpec().ConfigTemplate`이 service-writable log path와 새 config keys를 포함하는지 검증한다.
|
||||
- [ ] `apps/edge/cmd/edge/main.go`: `config init`이 bundle template을 사용하도록 바꾼다.
|
||||
- [ ] `apps/edge/cmd/edge/main_test.go`: `config init --stdout` 또는 output file이 새 keys와 empty `logging.path`를 포함하고 `/var/lib/iop/edge/logs/edge.log`를 포함하지 않는지 검증한다. 기존 setup dry-run 테스트에는 setup config preview의 service log path assertion을 추가한다.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
Write or update:
|
||||
|
||||
- `packages/hostsetup/setup_test.go`: `TestEdgeSpecConfigTemplateUsesServiceWritableLogPath`
|
||||
- `apps/edge/cmd/edge/main_test.go`: `TestConfigInitPrintsBundleTemplateWithEmptyLogPath`
|
||||
- `apps/edge/cmd/edge/main_test.go`: extend `TestSetupDryRunUsesEdgeDefaults` for `/var/lib/iop/edge/logs/edge.log`
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./packages/hostsetup ./apps/edge/cmd/edge ./apps/edge/internal/bootstrap
|
||||
```
|
||||
|
||||
Expected: pass; cache output is not acceptable.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|---|---|
|
||||
| `packages/hostsetup/templates.go` | REVIEW_CONFIG-1 |
|
||||
| `packages/hostsetup/setup_test.go` | REVIEW_CONFIG-1 |
|
||||
| `apps/edge/cmd/edge/main.go` | REVIEW_CONFIG-1 |
|
||||
| `apps/edge/cmd/edge/main_test.go` | REVIEW_CONFIG-1 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./packages/hostsetup ./apps/edge/cmd/edge ./apps/edge/internal/bootstrap
|
||||
```
|
||||
|
||||
```bash
|
||||
tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge config check && ./iop-edge env)
|
||||
```
|
||||
|
||||
Expected: tests pass; bundle smoke prints `OK edge.yaml`, `log_path: <tmp>/logs/edge.log`, and the setup template is not used for bundle `config init`.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
<!-- task=m-edge-local-dev-config-runtime/02+01_node_register_bootstrap plan=0 tag=BOOTSTRAP -->
|
||||
|
||||
# Code Review Reference - BOOTSTRAP
|
||||
|
||||
> **[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.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-05-28
|
||||
task=m-edge-local-dev-config-runtime/02+01_node_register_bootstrap, plan=0, tag=BOOTSTRAP
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G07.md` → `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [BOOTSTRAP-1] Config Check Uses Runtime Node Validation | [ ] |
|
||||
| [BOOTSTRAP-2] Node Register Upserts Edge YAML | [ ] |
|
||||
| [BOOTSTRAP-3] Bootstrap Command Rendering | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] Verify `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log` exists before implementation starts.
|
||||
- [ ] `iop-edge config check` validates `nodes[]` id/alias/token collisions and adapter/runtime errors through the same semantics used by runtime seed.
|
||||
- [ ] `iop-edge node register <node-id>` creates or updates one `nodes[]` record from flags/defaults without requiring manual YAML edits.
|
||||
- [ ] Register output ends with a single user-facing bootstrap command in `curl -fsSL <complete-url> | bash -s <token>` form and does not require named `IOP_*=` parameters.
|
||||
- [ ] Token generation/upsert behavior is deterministic in tests and does not leak full token in extra diagnostic output.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-edge-local-dev-config-runtime/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- Implementation must refuse or clearly validate duplicate node tokens, ids, aliases, and invalid runtime/adapter config.
|
||||
- Register must upsert by node id and avoid duplicate records.
|
||||
- Final user-facing bootstrap output must be a single completed command with token as positional value.
|
||||
- No default path should tell Node users to edit `node.yaml` or run `iop-node serve --config`.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
|
||||
### BOOTSTRAP-1 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node
|
||||
(output)
|
||||
```
|
||||
|
||||
### BOOTSTRAP-2 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/cmd/edge ./packages/config
|
||||
(output)
|
||||
```
|
||||
|
||||
### BOOTSTRAP-3 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/cmd/edge
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```text
|
||||
$ test -f agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log
|
||||
(output)
|
||||
```
|
||||
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config
|
||||
(output)
|
||||
```
|
||||
|
||||
```text
|
||||
$ tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 --target darwin-arm64 --artifact-base-url http://toki-labs.com:18080 && ./iop-edge config check)
|
||||
(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,229 @@
|
|||
<!-- task=m-edge-local-dev-config-runtime/02+01_node_register_bootstrap plan=0 tag=BOOTSTRAP -->
|
||||
|
||||
# Plan - BOOTSTRAP
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고 실제 출력과 구현 메모를 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. finalization은 code-review-skill 전용이다.
|
||||
|
||||
## 배경
|
||||
|
||||
현재 Edge config의 `nodes[]`는 런타임 등록 source of truth지만 사용자가 직접 YAML을 편집해야 한다. Milestone은 `iop-edge node register <node-id>`가 node record를 생성/갱신하고, 바로 실행 가능한 one-line bootstrap command를 출력하는 UX를 요구한다. 이 작업은 config/env 기반이 완성된 뒤 진행해야 한다.
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
|
||||
- `agent-roadmap/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md`
|
||||
- `apps/edge/cmd/edge/main.go`
|
||||
- `apps/edge/cmd/edge/main_test.go`
|
||||
- `apps/edge/internal/node/store.go`
|
||||
- `apps/edge/internal/node/store_test.go`
|
||||
- `apps/edge/internal/bootstrap/runtime.go`
|
||||
- `apps/edge/internal/bootstrap/runtime_test.go`
|
||||
- `packages/config/config.go`
|
||||
- `packages/config/config_test.go`
|
||||
- `configs/edge.yaml`
|
||||
- `bin/build/field-binaries.sh`
|
||||
- `docs/field-bootstrap-user-test.md`
|
||||
- `docs/field-bootstrap-work-guide.md`
|
||||
- `docs/deploy-dev.md`
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- `config check` loads YAML but does not exercise `edgenode.LoadFromConfig` from the CLI path.
|
||||
- `edgenode.LoadFromConfig` covers duplicate token/alias/id and empty token, but not adapter/runtime semantic validation.
|
||||
- No `node` or `node register` command exists.
|
||||
- Bootstrap scripts exist in `bin/build/field-binaries.sh`, but Edge CLI does not render the one-line command from config.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- none. No rename/remove is planned.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
Split decision policy was evaluated before choosing plan files. Shared task group: `agent-task/m-edge-local-dev-config-runtime/`.
|
||||
- `01_config_runtime_env` must finish first because this task needs resolved config path, advertise host, artifact URL, and Node transport address.
|
||||
- `02+01_node_register_bootstrap` adds config validation and mutation.
|
||||
- `03+01,02_help_smoke_bundle_docs` waits for this task because help and docs must describe the real register/bootstrap commands.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
Do not change the node transport handshake, bootstrap shell script internals, artifact build layout, Control Plane enrollment, or Node user-facing config commands here. The command may write an implementation detail config consumed by existing bootstrap scripts, but user-facing output must stay one-line and token-positional.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
build=`cloud-G07`, review=`cloud-G07`. This is CLI workflow, YAML mutation, token generation, bootstrap command rendering, and config validation with user-facing side effects.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] Verify `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log` exists before implementation starts.
|
||||
- [ ] `iop-edge config check` validates `nodes[]` id/alias/token collisions and adapter/runtime errors through the same semantics used by runtime seed.
|
||||
- [ ] `iop-edge node register <node-id>` creates or updates one `nodes[]` record from flags/defaults without requiring manual YAML edits.
|
||||
- [ ] Register output ends with a single user-facing bootstrap command in `curl -fsSL <complete-url> | bash -s <token>` form and does not require named `IOP_*=` parameters.
|
||||
- [ ] Token generation/upsert behavior is deterministic in tests and does not leak full token in extra diagnostic output.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
Directory dependency is `02+01_node_register_bootstrap`: wait for `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log`. Do not add hidden dependencies beyond that.
|
||||
|
||||
### [BOOTSTRAP-1] Config Check Uses Runtime Node Validation
|
||||
|
||||
#### 문제
|
||||
|
||||
[apps/edge/cmd/edge/main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:148) only calls `config.LoadEdge`, while [apps/edge/internal/node/store.go](/config/workspace/iop/apps/edge/internal/node/store.go:67) is where duplicate token/alias/id and empty token are rejected for runtime.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// apps/edge/cmd/edge/main.go:153
|
||||
if _, err := config.LoadEdge(cfgFile); err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
After loading the resolved config, call an edge CLI validator that delegates node seed validation to `edgenode.LoadFromConfig(cfg.Nodes)` and adds adapter/runtime checks. Keep validation in `apps/edge/cmd/edge` or `apps/edge/internal/node` rather than `packages/config` to avoid importing app internals into common packages.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/cmd/edge/main.go`: add `validateEdgeConfig` call in `config check`.
|
||||
- [ ] `apps/edge/cmd/edge/main_test.go`: add CLI tests for duplicate token/id/alias and invalid adapter/runtime flags.
|
||||
- [ ] `apps/edge/internal/node/store.go`: add semantic validation only if it belongs to runtime seed; otherwise keep CLI-only validation in command package.
|
||||
- [ ] `apps/edge/internal/node/store_test.go`: extend only if store behavior changes.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
Write `TestConfigCheckRejectsDuplicateNodeToken` and at least one adapter/runtime invalid config test.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node
|
||||
```
|
||||
|
||||
Expected: pass; cache output is not acceptable.
|
||||
|
||||
### [BOOTSTRAP-2] Node Register Upserts Edge YAML
|
||||
|
||||
#### 문제
|
||||
|
||||
[docs/field-bootstrap-user-test.md](/config/workspace/iop/docs/field-bootstrap-user-test.md:75) says Node definitions are made with `iop-edge node register`, but [apps/edge/cmd/edge/main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:35) has no `node` command.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// apps/edge/cmd/edge/main.go:35
|
||||
root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), setupCmd())
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Add `nodeCmd()` with `register` subcommand. Load resolved edge config and YAML, upsert by `id`, default alias to `id`, generate token if not provided, and support initial flags:
|
||||
|
||||
```text
|
||||
iop-edge node register <node-id>
|
||||
--alias <alias>
|
||||
--token <token>
|
||||
--adapter ollama|cli
|
||||
--ollama-base-url <url>
|
||||
--ollama-context-size <n>
|
||||
--runtime-concurrency <n>
|
||||
--workspace-root <path>
|
||||
```
|
||||
|
||||
Use `gopkg.in/yaml.v3` already present in `apps/edge/cmd/edge/main.go` for writing. Keep output concise: write/update line plus final bootstrap command.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/cmd/edge/main.go`: add node/register command, token generator injection for tests, YAML upsert helpers.
|
||||
- [ ] `apps/edge/cmd/edge/main_test.go`: test create, update, generated token, explicit token, adapter flags, and no manual `nodes[]` edit path.
|
||||
- [ ] `packages/config/config.go`: add fields only if BOOTSTRAP-2 needs config surface not already added by CONFIG-2.
|
||||
- [ ] `configs/edge.yaml`: do not add field tokens or host-specific generated values.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
Write temp-file CLI tests that run `config init --output`, then `node register`, then `config check`. Assert exactly one node record after update and that generated token is present in YAML and bootstrap output.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/cmd/edge ./packages/config
|
||||
```
|
||||
|
||||
Expected: pass; cache output is not acceptable.
|
||||
|
||||
### [BOOTSTRAP-3] Bootstrap Command Rendering
|
||||
|
||||
#### 문제
|
||||
|
||||
[bin/build/field-binaries.sh](/config/workspace/iop/bin/build/field-binaries.sh:83) generates target-specific bootstrap scripts, but [docs/field-bootstrap-user-test.md](/config/workspace/iop/docs/field-bootstrap-user-test.md:96) expects Edge CLI to output the complete one-line command.
|
||||
|
||||
Before:
|
||||
|
||||
```bash
|
||||
# docs/field-bootstrap-user-test.md:96
|
||||
curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s <generated-token>
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Render command from CONFIG-2 effective artifact base URL and default platform target. Provide flags on `node register`:
|
||||
|
||||
```text
|
||||
--target darwin-arm64
|
||||
--artifact-base-url <url>
|
||||
```
|
||||
|
||||
Default target can be current host `GOOS-GOARCH` for local dev, but tests should pass explicit `--target darwin-arm64`. The final visible command must be exactly one line with completed URL and token positional arg. Do not output `IOP_ARTIFACT_BASE_URL=`, `IOP_EDGE_ADDR=`, or `IOP_NODE_TOKEN=` in the default path.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/cmd/edge/main.go`: add bootstrap command renderer helper and register output.
|
||||
- [ ] `apps/edge/cmd/edge/main_test.go`: assert one-line command, complete URL, token positional arg, and absence of named env parameters.
|
||||
- [ ] `docs/field-bootstrap-work-guide.md`: no change in this subtask unless a field constant changes; defer broad docs to subtask 03.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
Write `TestNodeRegisterPrintsOneLineBootstrapCommand` and `TestNodeRegisterUsesConfiguredArtifactBaseURL`.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/cmd/edge
|
||||
```
|
||||
|
||||
Expected: pass; cache output is not acceptable.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|---|---|
|
||||
| `apps/edge/cmd/edge/main.go` | BOOTSTRAP-1, BOOTSTRAP-2, BOOTSTRAP-3 |
|
||||
| `apps/edge/cmd/edge/main_test.go` | BOOTSTRAP-1, BOOTSTRAP-2, BOOTSTRAP-3 |
|
||||
| `apps/edge/internal/node/store.go` | BOOTSTRAP-1 |
|
||||
| `apps/edge/internal/node/store_test.go` | BOOTSTRAP-1 |
|
||||
| `packages/config/config.go` | BOOTSTRAP-2 |
|
||||
| `packages/config/config_test.go` | BOOTSTRAP-2 |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
test -f agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log
|
||||
```
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config
|
||||
```
|
||||
|
||||
```bash
|
||||
tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 --target darwin-arm64 --artifact-base-url http://toki-labs.com:18080 && ./iop-edge config check)
|
||||
```
|
||||
|
||||
Expected: predecessor exists; tests pass; temp command creates/updates `edge.yaml`, prints one final `curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s <token>` command, and config check passes.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
<!-- task=m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs plan=0 tag=UX -->
|
||||
|
||||
# Code Review Reference - UX
|
||||
|
||||
> **[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.
|
||||
> Finalization (`코드리뷰 결과`, log rename, `complete.log`, archive moves, `코드리뷰 전용 체크리스트`) is review-agent-only, even after compaction/resume.
|
||||
> Follow the ownership table at the bottom of this file for which sections you own.
|
||||
|
||||
## 개요
|
||||
|
||||
date=2026-05-28
|
||||
task=m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs, plan=0, tag=UX
|
||||
|
||||
## 이 파일을 읽는 리뷰 에이전트에게
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 아래 종결 절차는 코드리뷰 에이전트 전용이다. 구현 에이전트는 이 섹션을 실행하지 않는다.
|
||||
|
||||
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
|
||||
리뷰 완료는 아래 순서까지 끝난 상태를 의미합니다.
|
||||
|
||||
1. 판정을 append한다.
|
||||
2. `CODE_REVIEW-cloud-G07.md` → `code_review_cloud_G07_N.log`, `PLAN-cloud-G07.md` → `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
3. PASS이면 `complete.log` 작성 후 active task 디렉터리를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs/`로 이동한다. WARN/FAIL이면 user-review gate를 확인한 뒤 다음 active plan/review 파일 또는 `USER_REVIEW.md`를 작성한다.
|
||||
4. PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
|
||||
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
|
||||
|
||||
---
|
||||
|
||||
## 구현 항목별 완료 여부
|
||||
|
||||
| 항목 | 완료 여부 |
|
||||
|------|---------|
|
||||
| [UX-1] Help Discovery | [ ] |
|
||||
| [UX-2] Nodes List And OpenAI Smoke | [ ] |
|
||||
| [UX-3] Docs And Clean Bundle Verification | [ ] |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] Verify `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log` and `agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/complete.log` exist before implementation starts.
|
||||
- [ ] `iop-edge help`, `iop-edge config --help`, `iop-edge node --help`, `iop-edge node register --help`, and `iop-edge smoke --help` expose the official flow without requiring docs.
|
||||
- [ ] `iop-edge nodes list` reads configured nodes and, when offline, clearly distinguishes configured nodes from connected runtime nodes.
|
||||
- [ ] `iop-edge smoke openai --model <model>` verifies OpenAI-compatible health/models/chat against the configured or provided URL and reports actionable failures.
|
||||
- [ ] Docs and README surfaces match implemented commands and do not present `bin/*`, dev deploy binaries, Node config edits, or `iop-node serve --config` as the official user path.
|
||||
- [ ] Clean temp bundle verification builds binaries, initializes config, registers a node, checks config/env/help, and performs at least the non-network parts deterministically; network/full-cycle blockers are explicitly recorded.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 코드리뷰 전용 체크리스트
|
||||
|
||||
> **[REVIEW AGENT ONLY]** 이 체크리스트는 코드리뷰 에이전트만 사용한다.
|
||||
> 구현 에이전트는 이 섹션을 수정하거나 체크하지 않는다.
|
||||
|
||||
- [ ] `코드리뷰 결과`에 `PASS`, `WARN`, `FAIL` 중 하나의 판정을 append한다.
|
||||
- [ ] 판정과 `차원별 평가`, Required/Suggested/Nit 분류가 서로 일치한다.
|
||||
- [ ] active `CODE_REVIEW-*-G??.md`를 `code_review_cloud_G07_N.log`로 아카이브한다.
|
||||
- [ ] active `PLAN-*-G??.md`를 `plan_cloud_G07_M.log`로 아카이브한다.
|
||||
- [ ] PASS이면 `agent-ops/skills/common/code-review/templates/complete-log-template.md` 기준으로 `complete.log`를 작성하고 active `.md` 파일을 남기지 않는다.
|
||||
- [ ] PASS이면 active task 디렉터리 `agent-task/m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs/`를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs/`로 이동하고 최종 archive 경로에서 이 체크리스트를 갱신한다.
|
||||
- [ ] PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 런타임이 읽을 완료 이벤트 메타데이터를 보고하고, roadmap 수정이나 `update-roadmap` 직접 호출을 하지 않는다.
|
||||
- [ ] PASS split 작업이면 이동 후 빈 active parent `agent-task/m-edge-local-dev-config-runtime/`를 제거하거나, 남은 sibling/file이 있어 유지했다고 확인한다.
|
||||
- [ ] WARN/FAIL이고 user-review gate가 트리거되지 않았으면 다음 active `PLAN-cloud-G07.md`와 `CODE_REVIEW-cloud-G07.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로 이동한다.
|
||||
|
||||
## 계획 대비 변경 사항
|
||||
|
||||
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
|
||||
|
||||
## 주요 설계 결정
|
||||
|
||||
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
|
||||
|
||||
## 리뷰어를 위한 체크포인트
|
||||
|
||||
- Help output must make the official flow discoverable without docs.
|
||||
- `nodes list` must not imply offline configured nodes are connected.
|
||||
- `smoke openai` tests must avoid real network in unit tests and use actionable errors for real runs.
|
||||
- Docs must keep Node config/manual `iop-node serve --config` out of the default user path.
|
||||
|
||||
## 검증 결과
|
||||
|
||||
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
|
||||
|
||||
필수 규칙:
|
||||
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
|
||||
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
|
||||
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
|
||||
|
||||
### UX-1 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/cmd/edge
|
||||
(output)
|
||||
```
|
||||
|
||||
### UX-2 중간 검증
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/cmd/edge
|
||||
(output)
|
||||
```
|
||||
|
||||
### UX-3 중간 검증
|
||||
```text
|
||||
$ rg --sort path "bin/edge.sh|bin/node.sh|dev deploy binary|iop-node serve --config" docs/field-bootstrap-user-test.md docs/deploy-dev.md apps/edge/README.md
|
||||
(output)
|
||||
```
|
||||
|
||||
### 최종 검증
|
||||
```text
|
||||
$ test -f agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log
|
||||
$ test -f agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/complete.log
|
||||
(output)
|
||||
```
|
||||
|
||||
```text
|
||||
$ go test -count=1 ./apps/edge/cmd/edge ./packages/config
|
||||
(output)
|
||||
```
|
||||
|
||||
```text
|
||||
$ tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && go build -o "$tmp/iop-node" ./apps/node/cmd/node && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge env && ./iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 --target darwin-arm64 --artifact-base-url http://toki-labs.com:18080 && ./iop-edge nodes list && ./iop-edge config check && ./iop-edge help && ./iop-edge config --help && ./iop-edge node --help && ./iop-edge node register --help && ./iop-edge smoke --help)
|
||||
(output)
|
||||
```
|
||||
|
||||
```text
|
||||
$ rg --sort path "bin/edge.sh|bin/node.sh|dev deploy binary|iop-node serve --config" docs/field-bootstrap-user-test.md docs/deploy-dev.md apps/edge/README.md
|
||||
(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,226 @@
|
|||
<!-- task=m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs plan=0 tag=UX -->
|
||||
|
||||
# Plan - UX
|
||||
|
||||
## 이 파일을 읽는 구현 에이전트에게
|
||||
|
||||
`CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션 작성은 필수다. 구현 후 검증을 실행하고 실제 출력과 구현 메모를 채운 뒤 active 파일을 그대로 두고 리뷰 준비를 보고한다. finalization은 code-review-skill 전용이다.
|
||||
|
||||
## 배경
|
||||
|
||||
After config/env and node register exist, the Milestone still needs the user-facing loop to be discoverable from `iop-edge help` and runnable from a clean bundle. Current docs already describe `iop-edge env`, `node register`, `nodes list`, and `smoke openai`, but the CLI does not yet expose all of that behavior. This subtask closes the official UX and verification layer without making `bin/*` or Node config commands the default user path.
|
||||
|
||||
## 분석 결과
|
||||
|
||||
### 읽은 파일
|
||||
|
||||
- `agent-roadmap/current.md`
|
||||
- `agent-roadmap/phase/control-plane-portal-ops/PHASE.md`
|
||||
- `agent-roadmap/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md`
|
||||
- `apps/edge/cmd/edge/main.go`
|
||||
- `apps/edge/cmd/edge/main_test.go`
|
||||
- `apps/edge/README.md`
|
||||
- `apps/node/cmd/node/main.go`
|
||||
- `apps/node/cmd/node/main_test.go`
|
||||
- `packages/config/config.go`
|
||||
- `packages/config/config_test.go`
|
||||
- `bin/build/field-binaries.sh`
|
||||
- `Makefile`
|
||||
- `docs/field-bootstrap-user-test.md`
|
||||
- `docs/field-bootstrap-work-guide.md`
|
||||
- `docs/deploy-dev.md`
|
||||
|
||||
### 테스트 커버리지 공백
|
||||
|
||||
- Root/config/setup help has basic command presence tests, but no test asserts official user-flow text.
|
||||
- `nodes list` and `smoke openai` commands do not exist.
|
||||
- Existing `make test-e2e` is an auxiliary repo smoke and does not verify clean bundle UX.
|
||||
- Docs contain future commands that are not yet synchronized with actual CLI output.
|
||||
|
||||
### 심볼 참조
|
||||
|
||||
- none. No rename/remove is planned.
|
||||
|
||||
### 분할 판단
|
||||
|
||||
Split decision policy was evaluated before choosing plan files. Shared task group: `agent-task/m-edge-local-dev-config-runtime/`.
|
||||
- `01_config_runtime_env` provides env/config/log/address behavior.
|
||||
- `02+01_node_register_bootstrap` provides register/bootstrap behavior.
|
||||
- `03+01,02_help_smoke_bundle_docs` depends on both so help, smoke, docs, and bundle verification describe implemented behavior rather than placeholders.
|
||||
|
||||
### 범위 결정 근거
|
||||
|
||||
Do not add Control Plane enrollment, production signing, mTLS, credential rotation, or a second dev deploy binary. Do not make `bin/edge.sh`, `bin/node.sh`, or `iop-node serve --config` official user documentation. Keep `make test-e2e` as auxiliary verification only.
|
||||
|
||||
### 빌드 등급
|
||||
|
||||
build=`cloud-G07`, review=`cloud-G07`. This is terminal UX, help output, smoke command, docs, and clean-bundle verification work.
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
- [ ] Verify `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log` and `agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/complete.log` exist before implementation starts.
|
||||
- [ ] `iop-edge help`, `iop-edge config --help`, `iop-edge node --help`, `iop-edge node register --help`, and `iop-edge smoke --help` expose the official flow without requiring docs.
|
||||
- [ ] `iop-edge nodes list` reads configured nodes and, when offline, clearly distinguishes configured nodes from connected runtime nodes.
|
||||
- [ ] `iop-edge smoke openai --model <model>` verifies OpenAI-compatible health/models/chat against the configured or provided URL and reports actionable failures.
|
||||
- [ ] Docs and README surfaces match implemented commands and do not present `bin/*`, dev deploy binaries, Node config edits, or `iop-node serve --config` as the official user path.
|
||||
- [ ] Clean temp bundle verification builds binaries, initializes config, registers a node, checks config/env/help, and performs at least the non-network parts deterministically; network/full-cycle blockers are explicitly recorded.
|
||||
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
|
||||
|
||||
## 의존 관계 및 구현 순서
|
||||
|
||||
Directory dependency is `03+01,02_help_smoke_bundle_docs`: wait for both `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log` and `agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/complete.log`. Do not add hidden dependencies beyond the directory name.
|
||||
|
||||
### [UX-1] Help Discovery
|
||||
|
||||
#### 문제
|
||||
|
||||
[docs/field-bootstrap-user-test.md](/config/workspace/iop/docs/field-bootstrap-user-test.md:31) expects users to discover a sequence from `iop-edge`, but [apps/edge/cmd/edge/main.go](/config/workspace/iop/apps/edge/cmd/edge/main.go:29) root command has only a short description and no flow-level help.
|
||||
|
||||
Before:
|
||||
|
||||
```go
|
||||
// apps/edge/cmd/edge/main.go:29
|
||||
root := &cobra.Command{
|
||||
Use: "edge",
|
||||
Short: "IOP Edge — execution group controller",
|
||||
}
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Add concise `Long`/`Example` text to root and relevant subcommands. Help must show this official order: `config init`, `env`, `node register`, `config check`, `serve`, `nodes list`, `smoke openai`. Include required/optional flag descriptions for `node register` and `smoke openai`.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/cmd/edge/main.go`: add help text to root/config/node/register/smoke commands.
|
||||
- [ ] `apps/edge/cmd/edge/main_test.go`: add help output assertions for official flow and absence of `bin/edge.sh`, `bin/node.sh`, and `dev-deploy`.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
Write `TestHelpDiscoversEdgeLocalDevFlow` and focused subcommand help tests.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/cmd/edge
|
||||
```
|
||||
|
||||
Expected: pass; cache output is not acceptable.
|
||||
|
||||
### [UX-2] Nodes List And OpenAI Smoke
|
||||
|
||||
#### 문제
|
||||
|
||||
[docs/field-bootstrap-user-test.md](/config/workspace/iop/docs/field-bootstrap-user-test.md:146) documents `./iop-edge nodes list`, and [docs/field-bootstrap-user-test.md](/config/workspace/iop/docs/field-bootstrap-user-test.md:152) documents `./iop-edge smoke openai --model gemma4:26b`, but there are no commands for either.
|
||||
|
||||
Before:
|
||||
|
||||
```text
|
||||
./iop-edge nodes list
|
||||
./iop-edge smoke openai --model gemma4:26b
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Add:
|
||||
|
||||
```text
|
||||
iop-edge nodes list
|
||||
iop-edge smoke openai --model <model> [--base-url <url>] [--prompt <text>] [--timeout <duration>]
|
||||
```
|
||||
|
||||
`nodes list` should load config and print configured nodes; connected runtime count can be `offline` unless a later native API exists. `smoke openai` should call `/healthz`, `/v1/models`, and non-streaming `/v1/chat/completions` using `net/http`. If config OpenAI listen is `0.0.0.0:8080`, local smoke should use `127.0.0.1:8080` unless `--base-url` is set. Use exact exit statuses via returned errors.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `apps/edge/cmd/edge/main.go`: add `nodes` and `smoke` command groups.
|
||||
- [ ] `apps/edge/cmd/edge/main_test.go`: test `nodes list` output from temp config and `smoke openai` against `httptest.Server`.
|
||||
- [ ] `packages/config/config.go`: no change unless prior subtasks added URL fields that need reuse.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
Write tests with `httptest.Server` for successful health/models/chat and failure on empty content or non-200. Avoid real network in unit tests.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/cmd/edge
|
||||
```
|
||||
|
||||
Expected: pass; cache output is not acceptable.
|
||||
|
||||
### [UX-3] Docs And Clean Bundle Verification
|
||||
|
||||
#### 문제
|
||||
|
||||
[docs/deploy-dev.md](/config/workspace/iop/docs/deploy-dev.md:104) still says dev initially uses repo `configs/edge.yaml`, while this Milestone moves source of truth to bundle-local `edge.yaml`. [docs/deploy-dev.md](/config/workspace/iop/docs/deploy-dev.md:558) lists implemented CLI commands but will be stale once this Milestone lands.
|
||||
|
||||
Before:
|
||||
|
||||
```text
|
||||
// docs/deploy-dev.md:561
|
||||
iop-edge serve
|
||||
iop-edge console
|
||||
iop-edge setup
|
||||
iop-edge config print
|
||||
iop-edge config check
|
||||
iop-edge version
|
||||
```
|
||||
|
||||
#### 해결 방법
|
||||
|
||||
Update docs to match actual CLI after UX-1/UX-2. Keep `docs/field-bootstrap-user-test.md` as the user-facing flow, `docs/field-bootstrap-work-guide.md` as worker/runbook details, and `docs/deploy-dev.md` as deployment reference. Add clean bundle verification commands to the review output, not a new official script, unless Makefile already has a suitable target and the team prefers a target.
|
||||
|
||||
#### 수정 파일 및 체크리스트
|
||||
|
||||
- [ ] `docs/field-bootstrap-user-test.md`: align commands, outputs, and troubleshooting with implemented CLI.
|
||||
- [ ] `docs/deploy-dev.md`: replace repo `configs/edge.yaml` default language with bundle-local `edge.yaml` flow and update command list.
|
||||
- [ ] `apps/edge/README.md`: update Edge CLI section.
|
||||
- [ ] `Makefile`: leave unchanged unless a tiny deterministic helper target is clearly better than repeating command lines.
|
||||
|
||||
#### 테스트 작성
|
||||
|
||||
Docs-only assertions should be `rg --sort path` checks in final verification. Do not add brittle line-number tests for docs.
|
||||
|
||||
#### 중간 검증
|
||||
|
||||
```bash
|
||||
rg --sort path "bin/edge.sh|bin/node.sh|dev deploy binary|iop-node serve --config" docs/field-bootstrap-user-test.md docs/deploy-dev.md apps/edge/README.md
|
||||
```
|
||||
|
||||
Expected: either no output, or only explicit "not official/default path" warnings.
|
||||
|
||||
## 수정 파일 요약
|
||||
|
||||
| 파일 | 항목 |
|
||||
|---|---|
|
||||
| `apps/edge/cmd/edge/main.go` | UX-1, UX-2 |
|
||||
| `apps/edge/cmd/edge/main_test.go` | UX-1, UX-2 |
|
||||
| `docs/field-bootstrap-user-test.md` | UX-3 |
|
||||
| `docs/deploy-dev.md` | UX-3 |
|
||||
| `docs/field-bootstrap-work-guide.md` | UX-3 |
|
||||
| `apps/edge/README.md` | UX-3 |
|
||||
| `Makefile` | UX-3, only if a deterministic helper target is chosen |
|
||||
|
||||
## 최종 검증
|
||||
|
||||
```bash
|
||||
test -f agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log
|
||||
test -f agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/complete.log
|
||||
```
|
||||
|
||||
```bash
|
||||
go test -count=1 ./apps/edge/cmd/edge ./packages/config
|
||||
```
|
||||
|
||||
```bash
|
||||
tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && go build -o "$tmp/iop-node" ./apps/node/cmd/node && (cd "$tmp" && ./iop-edge config init --force && ./iop-edge env && ./iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 --target darwin-arm64 --artifact-base-url http://toki-labs.com:18080 && ./iop-edge nodes list && ./iop-edge config check && ./iop-edge help && ./iop-edge config --help && ./iop-edge node --help && ./iop-edge node register --help && ./iop-edge smoke --help)
|
||||
```
|
||||
|
||||
```bash
|
||||
rg --sort path "bin/edge.sh|bin/node.sh|dev deploy binary|iop-node serve --config" docs/field-bootstrap-user-test.md docs/deploy-dev.md apps/edge/README.md
|
||||
```
|
||||
|
||||
Expected: predecessor files exist; tests pass; clean bundle command completes non-network commands; help output shows official flow; docs search shows no default-user-path references except explicit negative warnings.
|
||||
|
||||
모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.
|
||||
|
|
@ -2,8 +2,14 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/fx"
|
||||
|
|
@ -29,20 +35,86 @@ func rootCmd() *cobra.Command {
|
|||
Use: "edge",
|
||||
Short: "IOP Edge — execution group controller",
|
||||
}
|
||||
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "configs/edge.yaml", "config file path")
|
||||
root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), setupCmd())
|
||||
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (defaults to bundle-local edge.yaml)")
|
||||
root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), envCmd(), setupCmd())
|
||||
return root
|
||||
}
|
||||
|
||||
// resolveConfigPath returns the edge config path to load. Candidate order:
|
||||
// 1. explicit --config flag
|
||||
// 2. ./edge.yaml in the current working directory
|
||||
// 3. <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 cfgFile, nil
|
||||
}
|
||||
if _, err := os.Stat("edge.yaml"); err == nil {
|
||||
return "edge.yaml", nil
|
||||
}
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
candidate := filepath.Join(filepath.Dir(exe), "edge.yaml")
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
return candidate, nil
|
||||
}
|
||||
}
|
||||
if _, err := os.Stat("configs/edge.yaml"); err == nil {
|
||||
return "configs/edge.yaml", nil
|
||||
}
|
||||
return "", errors.New("no edge.yaml found: pass --config or run beside a bundle edge.yaml")
|
||||
}
|
||||
|
||||
func isConfigExplicit(cmd *cobra.Command) bool {
|
||||
flag := cmd.Root().PersistentFlags().Lookup("config")
|
||||
return flag != nil && flag.Changed
|
||||
}
|
||||
|
||||
// resolveEdgeLogPath returns the effective edge log path. If explicit is set
|
||||
// it wins. Otherwise it falls back to <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
|
||||
}
|
||||
|
||||
// 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 "."
|
||||
}
|
||||
|
||||
func serveCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "serve",
|
||||
Short: "Start the edge server",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
cfg, err := config.LoadEdge(cfgFile)
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, _, err := loadEdgeConfig(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
return err
|
||||
}
|
||||
logPath, err := resolveEdgeLogPath(cfg.Logging.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dir := filepath.Dir(logPath); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir log dir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
cfg.Logging.Path = logPath
|
||||
fx.New(bootstrap.Module(cfg)).Run()
|
||||
return nil
|
||||
},
|
||||
|
|
@ -53,16 +125,38 @@ func consoleCmd() *cobra.Command {
|
|||
return &cobra.Command{
|
||||
Use: "console",
|
||||
Short: "Start the edge server with an interactive node console",
|
||||
RunE: func(_ *cobra.Command, _ []string) error {
|
||||
cfg, err := config.LoadEdge(cfgFile)
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, _, err := loadEdgeConfig(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
return err
|
||||
}
|
||||
logPath, err := resolveEdgeLogPath(cfg.Logging.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if dir := filepath.Dir(logPath); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir log dir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
cfg.Logging.Path = logPath
|
||||
return runConsole(context.Background(), cfg, os.Stdin, os.Stdout)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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 versionCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "version",
|
||||
|
|
@ -77,9 +171,53 @@ func versionCmd() *cobra.Command {
|
|||
func configCmd() *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Inspect edge config",
|
||||
Short: "Create, inspect, and validate edge config",
|
||||
}
|
||||
c.AddCommand(configPrintCmd(), configCheckCmd())
|
||||
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
|
||||
}
|
||||
|
||||
|
|
@ -88,9 +226,9 @@ func configPrintCmd() *cobra.Command {
|
|||
Use: "print",
|
||||
Short: "Print effective edge config as YAML",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, err := config.LoadEdge(cfgFile)
|
||||
cfg, _, err := loadEdgeConfig(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
return err
|
||||
}
|
||||
enc := yaml.NewEncoder(cmd.OutOrStdout())
|
||||
defer enc.Close()
|
||||
|
|
@ -104,15 +242,157 @@ func configCheckCmd() *cobra.Command {
|
|||
Use: "check",
|
||||
Short: "Validate edge config can be loaded",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
if _, err := config.LoadEdge(cfgFile); err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
_, path, err := loadEdgeConfig(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "OK %s\n", cfgFile)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "OK %s\n", path)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// envCmd prints the resolved environment for the bundled edge binary so the
|
||||
// user can verify config discovery, advertise host, log path, transport
|
||||
// address, artifact/bootstrap URL, and OpenAI-compatible URL without starting
|
||||
// edge. It does not bind any port or connect to nodes.
|
||||
func envCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "env",
|
||||
Short: "Print resolved edge environment without starting edge",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cfg, path, err := loadEdgeConfig(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
logPath, err := resolveEdgeLogPath(cfg.Logging.Path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
renderEnv(cmd.OutOrStdout(), envReport{
|
||||
ConfigPath: path,
|
||||
BinaryDir: binaryDir(),
|
||||
LogPath: logPath,
|
||||
Advertise: resolveAdvertiseHost(cfg.Server.AdvertiseHost),
|
||||
Server: cfg.Server,
|
||||
Bootstrap: cfg.Bootstrap,
|
||||
OpenAI: cfg.OpenAI,
|
||||
A2A: cfg.A2A,
|
||||
Nodes: cfg.Nodes,
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type advertise struct {
|
||||
Host string
|
||||
Source string // "explicit" or "auto"
|
||||
}
|
||||
|
||||
func resolveAdvertiseHost(explicit string) advertise {
|
||||
if explicit != "" {
|
||||
return advertise{Host: explicit, Source: "explicit"}
|
||||
}
|
||||
if ip := nonLoopbackIPv4(); ip != "" {
|
||||
return advertise{Host: ip, Source: "auto"}
|
||||
}
|
||||
if hn, err := os.Hostname(); err == nil && hn != "" {
|
||||
return advertise{Host: hn, Source: "auto"}
|
||||
}
|
||||
return advertise{Host: "127.0.0.1", Source: "auto"}
|
||||
}
|
||||
|
||||
func nonLoopbackIPv4() string {
|
||||
addrs, err := net.InterfaceAddrs()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, a := range addrs {
|
||||
ipnet, ok := a.(*net.IPNet)
|
||||
if !ok || ipnet.IP.IsLoopback() {
|
||||
continue
|
||||
}
|
||||
ip4 := ipnet.IP.To4()
|
||||
if ip4 == nil {
|
||||
continue
|
||||
}
|
||||
return ip4.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// addressFor combines an advertise host with the port from a listen string
|
||||
// (e.g. "0.0.0.0:9090" -> "<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)
|
||||
}
|
||||
|
||||
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) {
|
||||
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))
|
||||
if r.Bootstrap.ArtifactBaseURL != "" {
|
||||
fmt.Fprintf(w, "artifact_base_url: %s\n", r.Bootstrap.ArtifactBaseURL)
|
||||
} else {
|
||||
fmt.Fprintln(w, "artifact_base_url: (unset)")
|
||||
}
|
||||
if r.OpenAI.Enabled {
|
||||
fmt.Fprintf(w, "openai_url: http://%s/v1\n", addressFor(r.Advertise.Host, r.OpenAI.Listen))
|
||||
} else {
|
||||
fmt.Fprintln(w, "openai_url: (disabled)")
|
||||
}
|
||||
if r.A2A.Enabled {
|
||||
path := r.A2A.Path
|
||||
if path == "" {
|
||||
path = "/"
|
||||
}
|
||||
fmt.Fprintf(w, "a2a_url: http://%s%s\n", addressFor(r.Advertise.Host, r.A2A.Listen), path)
|
||||
} else {
|
||||
fmt.Fprintln(w, "a2a_url: (disabled)")
|
||||
}
|
||||
fmt.Fprintf(w, "configured_nodes: %s\n", summarizeNodes(r.Nodes))
|
||||
fmt.Fprintln(w, "connected_nodes: offline (env does not start edge)")
|
||||
}
|
||||
|
||||
func summarizeNodes(nodes []config.NodeDefinition) string {
|
||||
if len(nodes) == 0 {
|
||||
return "0 (none)"
|
||||
}
|
||||
labels := make([]string, 0, len(nodes))
|
||||
for _, n := range nodes {
|
||||
label := n.ID
|
||||
if label == "" {
|
||||
label = n.Alias
|
||||
}
|
||||
if label == "" {
|
||||
label = "(unnamed)"
|
||||
}
|
||||
labels = append(labels, label)
|
||||
}
|
||||
sort.Strings(labels)
|
||||
return fmt.Sprintf("%d (%s)", len(nodes), strings.Join(labels, ", "))
|
||||
}
|
||||
|
||||
func setupCmd() *cobra.Command {
|
||||
var opts hostsetup.SetupOptions
|
||||
c := &cobra.Command{
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
|
@ -16,6 +17,7 @@ func TestRootCmdIncludesOperationalCommands(t *testing.T) {
|
|||
"console": false,
|
||||
"version": false,
|
||||
"config": false,
|
||||
"env": false,
|
||||
"setup": false,
|
||||
}
|
||||
for _, c := range root.Commands() {
|
||||
|
|
@ -34,6 +36,7 @@ func TestRootCmdIncludesOperationalCommands(t *testing.T) {
|
|||
t.Fatalf("find config: %v", err)
|
||||
}
|
||||
subs := map[string]bool{"print": false, "check": false}
|
||||
subs["init"] = false
|
||||
for _, c := range cfg.Commands() {
|
||||
if _, ok := subs[c.Name()]; ok {
|
||||
subs[c.Name()] = true
|
||||
|
|
@ -75,6 +78,99 @@ func TestConfigCheckCmdLoadsEdgeConfig(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestConfigInitWritesTemplateThatChecks(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
outPath := filepath.Join(dir, "edge.yaml")
|
||||
|
||||
root := rootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"config", "init", "--output", outPath})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute init: %v\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "wrote "+outPath) {
|
||||
t.Fatalf("expected written path in output, got %q", out.String())
|
||||
}
|
||||
|
||||
root = rootCmd()
|
||||
out.Reset()
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"config", "check", "--config", outPath})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute check: %v\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "OK "+outPath) {
|
||||
t.Fatalf("expected config check OK for generated file, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitPrintsTemplateToStdout(t *testing.T) {
|
||||
root := rootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"config", "init", "--stdout"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v\n%s", err, out.String())
|
||||
}
|
||||
for _, want := range []string{"edge:", "server:", "nodes: []"} {
|
||||
if !strings.Contains(out.String(), want) {
|
||||
t.Fatalf("template output missing %q:\n%s", want, out.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitPrintsBundleTemplateWithEmptyLogPath(t *testing.T) {
|
||||
root := rootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"config", "init", "--stdout"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v\n%s", err, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{
|
||||
"advertise_host:",
|
||||
"bootstrap:",
|
||||
"artifact_base_url:",
|
||||
"path: \"\"",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("bundle init output missing %q\n---\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "/var/lib/iop/edge/logs/edge.log") {
|
||||
t.Errorf("bundle init leaked service log path:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigInitRefusesOverwriteWithoutForce(t *testing.T) {
|
||||
outPath := filepath.Join(t.TempDir(), "edge.yaml")
|
||||
if err := os.WriteFile(outPath, []byte("preserved: true\n"), 0o600); err != nil {
|
||||
t.Fatalf("write existing: %v", err)
|
||||
}
|
||||
|
||||
root := rootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"config", "init", "--output", outPath})
|
||||
if err := root.Execute(); err == nil {
|
||||
t.Fatalf("expected overwrite refusal, output:\n%s", out.String())
|
||||
}
|
||||
got, err := os.ReadFile(outPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read existing: %v", err)
|
||||
}
|
||||
if string(got) != "preserved: true\n" {
|
||||
t.Fatalf("existing file was overwritten: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupDryRunUsesEdgeDefaults(t *testing.T) {
|
||||
cfgFile = "configs/edge.yaml" // reset to root default
|
||||
root := rootCmd()
|
||||
|
|
@ -93,6 +189,7 @@ func TestSetupDryRunUsesEdgeDefaults(t *testing.T) {
|
|||
"/usr/local/bin/iop-edge serve --config /etc/iop/edge.yaml",
|
||||
"--- unit preview ---",
|
||||
"--- config preview ---",
|
||||
"path: \"/var/lib/iop/edge/logs/edge.log\"",
|
||||
} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Errorf("dry-run output missing %q\n---\n%s", want, s)
|
||||
|
|
@ -118,3 +215,201 @@ func TestSetupDryRunAcceptsExplicitConfig(t *testing.T) {
|
|||
t.Errorf("explicit config not used; output:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
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 TestConfigCheckUsesBundleEdgeYAMLByDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeMinimalEdgeYAML(t, filepath.Join(dir, "edge.yaml"))
|
||||
t.Chdir(dir)
|
||||
|
||||
root := rootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"config", "check"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "OK edge.yaml") {
|
||||
t.Fatalf("expected OK edge.yaml (cwd bundle), got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitConfigWinsOverBundleDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeMinimalEdgeYAML(t, filepath.Join(dir, "edge.yaml"))
|
||||
t.Chdir(dir)
|
||||
|
||||
otherDir := t.TempDir()
|
||||
other := filepath.Join(otherDir, "edge.yaml")
|
||||
writeMinimalEdgeYAML(t, other)
|
||||
|
||||
root := rootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"config", "check", "--config", other})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v\n%s", err, out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "OK "+other) {
|
||||
t.Fatalf("expected OK %s, got %q", other, out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigDiscoveryReportsMissingWhenNoBundleFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Chdir(dir)
|
||||
|
||||
root := rootCmd()
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs([]string{"config", "check"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected error when no edge.yaml found, got output: %s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no edge.yaml found") {
|
||||
t.Fatalf("expected discovery error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvCmdPrintsResolvedReportWithExplicitAdvertiseHost(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "edge.yaml")
|
||||
yaml := `server:
|
||||
listen: "0.0.0.0:9090"
|
||||
advertise_host: "edge.example.test"
|
||||
bootstrap:
|
||||
artifact_base_url: "http://edge.example.test:18080"
|
||||
openai:
|
||||
enabled: true
|
||||
listen: "0.0.0.0:8080"
|
||||
nodes:
|
||||
- id: "node-a"
|
||||
alias: "a"
|
||||
token: "t-a"
|
||||
- id: "node-b"
|
||||
alias: "b"
|
||||
token: "t-b"
|
||||
`
|
||||
if err := os.WriteFile(cfgPath, []byte(yaml), 0o600); err != nil {
|
||||
t.Fatalf("write yaml: %v", err)
|
||||
}
|
||||
|
||||
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()
|
||||
wants := []string{
|
||||
"config: " + cfgPath,
|
||||
"binary_dir: ",
|
||||
"log_path: ",
|
||||
"advertise_host: edge.example.test (explicit)",
|
||||
"node_transport: edge.example.test:9090",
|
||||
"artifact_base_url: http://edge.example.test:18080",
|
||||
"openai_url: http://edge.example.test:8080/v1",
|
||||
"configured_nodes: 2 (node-a, node-b)",
|
||||
"connected_nodes: offline",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(got, w) {
|
||||
t.Errorf("env output missing %q\n---\n%s", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
if !strings.Contains(got, "artifact_base_url: (unset)") {
|
||||
t.Errorf("expected artifact_base_url (unset), got:\n%s", 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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ type Runtime struct {
|
|||
}
|
||||
|
||||
func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
|
||||
logger, err := observability.NewLogger(cfg.Logging.Level, cfg.Logging.Pretty)
|
||||
logger, err := observability.NewLoggerWithFile(cfg.Logging.Level, cfg.Logging.Pretty, cfg.Logging.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import (
|
|||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -120,6 +123,36 @@ func TestRuntimeKeepsListenersAfterStartupContextCancel(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestNewRuntimeWritesLogsToConfiguredPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "logs", "edge.log")
|
||||
|
||||
cfg := newTestConfig()
|
||||
cfg.Logging.Level = "info"
|
||||
cfg.Logging.Path = logPath
|
||||
rt, err := NewRuntime(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRuntime: %v", err)
|
||||
}
|
||||
rt.Logger.Info("runtime-log-entry")
|
||||
_ = rt.Logger.Sync()
|
||||
|
||||
info, err := os.Stat(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat log path: %v", err)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatalf("log file empty: %s", logPath)
|
||||
}
|
||||
contents, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read log file: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(contents), "runtime-log-entry") {
|
||||
t.Fatalf("expected runtime-log-entry in log file; contents=%q", string(contents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRuntimeRejectsDuplicateToken(t *testing.T) {
|
||||
cfg := &config.EdgeConfig{
|
||||
Server: config.EdgeServerConf{Listen: "127.0.0.1:0"},
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ edge:
|
|||
|
||||
server:
|
||||
listen: "0.0.0.0:9090"
|
||||
# advertise_host is the host other components (Node bootstrap, OpenAI client)
|
||||
# use to reach this edge. Empty means resolve automatically from interfaces.
|
||||
advertise_host: ""
|
||||
|
||||
# bootstrap is the artifact/bootstrap channel iop-edge can render in
|
||||
# bootstrap commands and `env` output. Leave artifact_base_url empty until a
|
||||
# field channel is provisioned.
|
||||
bootstrap:
|
||||
artifact_base_url: ""
|
||||
|
||||
tls:
|
||||
enabled: false
|
||||
|
|
@ -11,6 +20,9 @@ tls:
|
|||
logging:
|
||||
level: "info"
|
||||
pretty: true
|
||||
# path is the edge log file. Empty falls back to <binary-dir>/logs/edge.log
|
||||
# for the bundled dev binary; explicit values win.
|
||||
path: ""
|
||||
|
||||
metrics:
|
||||
port: 9092
|
||||
|
|
|
|||
|
|
@ -11,15 +11,16 @@ type NodeConfig struct {
|
|||
}
|
||||
|
||||
type EdgeConfig struct {
|
||||
Edge EdgeInfo `mapstructure:"edge" yaml:"edge"`
|
||||
Server EdgeServerConf `mapstructure:"server" yaml:"server"`
|
||||
OpenAI EdgeOpenAIConf `mapstructure:"openai" yaml:"openai"`
|
||||
A2A EdgeA2AConf `mapstructure:"a2a" yaml:"a2a"`
|
||||
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
|
||||
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
|
||||
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
|
||||
Console EdgeConsoleConf `mapstructure:"console" yaml:"console"`
|
||||
Nodes []NodeDefinition `mapstructure:"nodes" yaml:"nodes"`
|
||||
Edge EdgeInfo `mapstructure:"edge" yaml:"edge"`
|
||||
Server EdgeServerConf `mapstructure:"server" yaml:"server"`
|
||||
Bootstrap EdgeBootstrapConf `mapstructure:"bootstrap" yaml:"bootstrap"`
|
||||
OpenAI EdgeOpenAIConf `mapstructure:"openai" yaml:"openai"`
|
||||
A2A EdgeA2AConf `mapstructure:"a2a" yaml:"a2a"`
|
||||
TLS TLSConf `mapstructure:"tls" yaml:"tls"`
|
||||
Logging LoggingConf `mapstructure:"logging" yaml:"logging"`
|
||||
Metrics MetricsConf `mapstructure:"metrics" yaml:"metrics"`
|
||||
Console EdgeConsoleConf `mapstructure:"console" yaml:"console"`
|
||||
Nodes []NodeDefinition `mapstructure:"nodes" yaml:"nodes"`
|
||||
}
|
||||
|
||||
// EdgeInfo carries this edge instance's stable identity for loading and logging.
|
||||
|
|
@ -44,7 +45,12 @@ type NodeInfo struct {
|
|||
}
|
||||
|
||||
type EdgeServerConf struct {
|
||||
Listen string `mapstructure:"listen" yaml:"listen"`
|
||||
Listen string `mapstructure:"listen" yaml:"listen"`
|
||||
AdvertiseHost string `mapstructure:"advertise_host" yaml:"advertise_host"`
|
||||
}
|
||||
|
||||
type EdgeBootstrapConf struct {
|
||||
ArtifactBaseURL string `mapstructure:"artifact_base_url" yaml:"artifact_base_url"`
|
||||
}
|
||||
|
||||
type EdgeOpenAIConf struct {
|
||||
|
|
@ -121,6 +127,7 @@ type SQLiteConf struct {
|
|||
type LoggingConf struct {
|
||||
Level string `mapstructure:"level" yaml:"level"`
|
||||
Pretty bool `mapstructure:"pretty" yaml:"pretty"`
|
||||
Path string `mapstructure:"path" yaml:"path"`
|
||||
}
|
||||
|
||||
type MetricsConf struct {
|
||||
|
|
|
|||
|
|
@ -672,6 +672,56 @@ nodes:
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadEdge_AdvertiseHostAndArtifactDefaultsEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "edge.yaml")
|
||||
if err := os.WriteFile(f, []byte("server:\n listen: \"0.0.0.0:9090\"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write yaml: %v", err)
|
||||
}
|
||||
cfg, err := config.LoadEdge(f)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.Server.AdvertiseHost != "" {
|
||||
t.Errorf("expected empty server.advertise_host by default, got %q", cfg.Server.AdvertiseHost)
|
||||
}
|
||||
if cfg.Bootstrap.ArtifactBaseURL != "" {
|
||||
t.Errorf("expected empty bootstrap.artifact_base_url by default, got %q", cfg.Bootstrap.ArtifactBaseURL)
|
||||
}
|
||||
if cfg.Logging.Path != "" {
|
||||
t.Errorf("expected empty logging.path by default, got %q", cfg.Logging.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEdge_AdvertiseHostAndArtifactExplicit(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "edge.yaml")
|
||||
yaml := `server:
|
||||
listen: "0.0.0.0:9090"
|
||||
advertise_host: "edge.example.test"
|
||||
bootstrap:
|
||||
artifact_base_url: "http://edge.example.test:18080"
|
||||
logging:
|
||||
path: "/var/log/iop/edge.log"
|
||||
`
|
||||
if err := os.WriteFile(f, []byte(yaml), 0o600); err != nil {
|
||||
t.Fatalf("write yaml: %v", err)
|
||||
}
|
||||
cfg, err := config.LoadEdge(f)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if cfg.Server.AdvertiseHost != "edge.example.test" {
|
||||
t.Errorf("server.advertise_host=%q", cfg.Server.AdvertiseHost)
|
||||
}
|
||||
if cfg.Bootstrap.ArtifactBaseURL != "http://edge.example.test:18080" {
|
||||
t.Errorf("bootstrap.artifact_base_url=%q", cfg.Bootstrap.ArtifactBaseURL)
|
||||
}
|
||||
if cfg.Logging.Path != "/var/log/iop/edge.log" {
|
||||
t.Errorf("logging.path=%q", cfg.Logging.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEdge_A2ADefaults(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "edge.yaml")
|
||||
|
|
|
|||
|
|
@ -322,6 +322,39 @@ func TestRunRejectsUnsafeDataDir(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestEdgeSpecConfigTemplateUsesServiceWritableLogPath(t *testing.T) {
|
||||
tmpl := EdgeSpec().ConfigTemplate
|
||||
wants := []string{
|
||||
"path: \"/var/lib/iop/edge/logs/edge.log\"",
|
||||
"advertise_host:",
|
||||
"bootstrap:",
|
||||
"artifact_base_url:",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(tmpl, w) {
|
||||
t.Errorf("EdgeSpec().ConfigTemplate missing %q\n---\n%s", w, tmpl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEdgeBundleConfigTemplateKeepsEmptyLogPath(t *testing.T) {
|
||||
tmpl := EdgeBundleConfigTemplate()
|
||||
wants := []string{
|
||||
"path: \"\"",
|
||||
"advertise_host:",
|
||||
"bootstrap:",
|
||||
"artifact_base_url:",
|
||||
}
|
||||
for _, w := range wants {
|
||||
if !strings.Contains(tmpl, w) {
|
||||
t.Errorf("EdgeBundleConfigTemplate missing %q\n---\n%s", w, tmpl)
|
||||
}
|
||||
}
|
||||
if strings.Contains(tmpl, "/var/lib/iop/edge/logs/edge.log") {
|
||||
t.Errorf("bundle template leaked service log path:\n%s", tmpl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderUnit(t *testing.T) {
|
||||
spec := AppSpec{Name: "iop-edge", Description: "IOP Edge"}
|
||||
opts := SetupOptions{
|
||||
|
|
|
|||
|
|
@ -1,11 +1,20 @@
|
|||
package hostsetup
|
||||
|
||||
// edgeConfigTemplate is the setup-installed edge config. systemd runs the edge
|
||||
// binary as the `iop` service user, so this template must pin a
|
||||
// service-writable log path. `/var/lib/iop/edge` is the default data dir
|
||||
// created and chowned to `iop:iop` during setup; the runtime creates the
|
||||
// `logs` subdirectory under it.
|
||||
const edgeConfigTemplate = `edge:
|
||||
id: "edge-local"
|
||||
name: "Local Edge"
|
||||
|
||||
server:
|
||||
listen: "0.0.0.0:9090"
|
||||
advertise_host: ""
|
||||
|
||||
bootstrap:
|
||||
artifact_base_url: ""
|
||||
|
||||
tls:
|
||||
enabled: false
|
||||
|
|
@ -13,6 +22,36 @@ tls:
|
|||
logging:
|
||||
level: "info"
|
||||
pretty: false
|
||||
path: "/var/lib/iop/edge/logs/edge.log"
|
||||
|
||||
metrics:
|
||||
port: 9092
|
||||
|
||||
nodes: []
|
||||
`
|
||||
|
||||
// edgeBundleConfigTemplate is the local bundle edge config emitted by
|
||||
// `iop-edge config init`. The empty `logging.path` lets the running edge
|
||||
// binary resolve logs to `<binary-dir>/logs/edge.log`, which matches the
|
||||
// bundle-local UX where the user owns the binary directory.
|
||||
const edgeBundleConfigTemplate = `edge:
|
||||
id: "edge-local"
|
||||
name: "Local Edge"
|
||||
|
||||
server:
|
||||
listen: "0.0.0.0:9090"
|
||||
advertise_host: ""
|
||||
|
||||
bootstrap:
|
||||
artifact_base_url: ""
|
||||
|
||||
tls:
|
||||
enabled: false
|
||||
|
||||
logging:
|
||||
level: "info"
|
||||
pretty: false
|
||||
path: ""
|
||||
|
||||
metrics:
|
||||
port: 9092
|
||||
|
|
@ -43,6 +82,13 @@ func EdgeSpec() AppSpec {
|
|||
}
|
||||
}
|
||||
|
||||
// EdgeBundleConfigTemplate returns the bundle-local edge config template used
|
||||
// by `iop-edge config init`. It keeps `logging.path` empty so the local
|
||||
// running binary resolves logs under its own directory.
|
||||
func EdgeBundleConfigTemplate() string {
|
||||
return edgeBundleConfigTemplate
|
||||
}
|
||||
|
||||
func NodeSpec() AppSpec {
|
||||
return AppSpec{
|
||||
Name: "iop-node",
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
)
|
||||
|
||||
// NewLogger creates a zap production logger at the given level.
|
||||
// NewLogger creates a zap production logger at the given level that writes to stderr.
|
||||
func NewLogger(level string, pretty bool) (*zap.Logger, error) {
|
||||
lvl, err := zapcore.ParseLevel(level)
|
||||
if err != nil {
|
||||
|
|
@ -31,6 +32,42 @@ func NewLogger(level string, pretty bool) (*zap.Logger, error) {
|
|||
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)), nil
|
||||
}
|
||||
|
||||
// NewLoggerWithFile creates a zap production logger at the given level. If path
|
||||
// is empty it behaves exactly like NewLogger. When path is set, the log file's
|
||||
// parent directory is created and log entries are written to that file
|
||||
// instead of stderr.
|
||||
func NewLoggerWithFile(level string, pretty bool, path string) (*zap.Logger, error) {
|
||||
if path == "" {
|
||||
return NewLogger(level, pretty)
|
||||
}
|
||||
|
||||
lvl, err := zapcore.ParseLevel(level)
|
||||
if err != nil {
|
||||
lvl = zapcore.InfoLevel
|
||||
}
|
||||
|
||||
if dir := filepath.Dir(path); dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("mkdir log dir %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
file, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open log file %s: %w", path, err)
|
||||
}
|
||||
|
||||
cfg := zap.NewProductionConfig()
|
||||
cfg.Level = zap.NewAtomicLevelAt(lvl)
|
||||
|
||||
var sink zapcore.WriteSyncer = zapcore.Lock(zapcore.AddSync(file))
|
||||
if pretty {
|
||||
sink = &prettyJSONWriteSyncer{out: sink}
|
||||
}
|
||||
encoder := zapcore.NewJSONEncoder(cfg.EncoderConfig)
|
||||
core := zapcore.NewCore(encoder, sink, cfg.Level)
|
||||
return zap.New(core, zap.AddCaller(), zap.AddStacktrace(zapcore.ErrorLevel)), nil
|
||||
}
|
||||
|
||||
type prettyJSONWriteSyncer struct {
|
||||
out zapcore.WriteSyncer
|
||||
}
|
||||
|
|
|
|||
78
packages/observability/observability_test.go
Normal file
78
packages/observability/observability_test.go
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
package observability_test
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"iop/packages/observability"
|
||||
)
|
||||
|
||||
func TestNewLoggerWithFile_EmptyPathFallsBackToStderr(t *testing.T) {
|
||||
logger, err := observability.NewLoggerWithFile("info", false, "")
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithFile: %v", err)
|
||||
}
|
||||
if logger == nil {
|
||||
t.Fatal("logger is nil")
|
||||
}
|
||||
_ = logger.Sync()
|
||||
}
|
||||
|
||||
func TestNewLoggerWithFile_CreatesParentDirAndFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "logs", "edge.log")
|
||||
|
||||
logger, err := observability.NewLoggerWithFile("info", false, logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("NewLoggerWithFile: %v", err)
|
||||
}
|
||||
logger.Info("hello-from-test")
|
||||
_ = logger.Sync()
|
||||
|
||||
info, err := os.Stat(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("stat log file: %v", err)
|
||||
}
|
||||
if info.IsDir() {
|
||||
t.Fatalf("log path is a directory: %s", logPath)
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
t.Fatalf("log file is empty: %s", logPath)
|
||||
}
|
||||
contents, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read log file: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(contents), "hello-from-test") {
|
||||
t.Fatalf("log file missing entry; contents=%q", string(contents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewLoggerWithFile_AppendsToExistingFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "edge.log")
|
||||
|
||||
first, err := observability.NewLoggerWithFile("info", false, logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("first NewLoggerWithFile: %v", err)
|
||||
}
|
||||
first.Info("entry-one")
|
||||
_ = first.Sync()
|
||||
|
||||
second, err := observability.NewLoggerWithFile("info", false, logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("second NewLoggerWithFile: %v", err)
|
||||
}
|
||||
second.Info("entry-two")
|
||||
_ = second.Sync()
|
||||
|
||||
contents, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read log file: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(contents), "entry-one") || !strings.Contains(string(contents), "entry-two") {
|
||||
t.Fatalf("expected both entries in log file; contents=%q", string(contents))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue