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:
toki 2026-05-28 05:44:43 +09:00
parent 7d3e24bb08
commit 5bb8a4bc85
20 changed files with 2428 additions and 27 deletions

View file

@ -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`를 작성한다.

View file

@ -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로 이동한다.

View file

@ -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
- 없음
## 후속 작업
- 없음

View file

@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -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.

View file

@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -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.

View file

@ -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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다.

View file

@ -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{

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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"},

View file

@ -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

View file

@ -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 {

View file

@ -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")

View file

@ -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{

View file

@ -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",

View file

@ -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
}

View 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))
}
}