From 5bb8a4bc8525ad37deaeab11fc1331e05b6e3f88 Mon Sep 17 00:00:00 2001 From: toki Date: Thu, 28 May 2026 05:44:43 +0900 Subject: [PATCH] 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 --- .../code_review_cloud_G07_0.log | 208 ++++++++++++ .../code_review_cloud_G07_1.log | 185 +++++++++++ .../01_config_runtime_env/complete.log | 38 +++ .../plan_cloud_G07_0.log | 230 +++++++++++++ .../plan_cloud_G07_1.log | 151 +++++++++ .../CODE_REVIEW-cloud-G07.md | 128 ++++++++ .../PLAN-cloud-G07.md | 229 +++++++++++++ .../CODE_REVIEW-cloud-G07.md | 135 ++++++++ .../PLAN-cloud-G07.md | 226 +++++++++++++ apps/edge/cmd/edge/main.go | 310 +++++++++++++++++- apps/edge/cmd/edge/main_test.go | 295 +++++++++++++++++ apps/edge/internal/bootstrap/runtime.go | 2 +- apps/edge/internal/bootstrap/runtime_test.go | 33 ++ configs/edge.yaml | 12 + packages/config/config.go | 27 +- packages/config/config_test.go | 50 +++ packages/hostsetup/setup_test.go | 33 ++ packages/hostsetup/templates.go | 46 +++ packages/observability/observability.go | 39 ++- packages/observability/observability_test.go | 78 +++++ 20 files changed, 2428 insertions(+), 27 deletions(-) create mode 100644 agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/code_review_cloud_G07_0.log create mode 100644 agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/code_review_cloud_G07_1.log create mode 100644 agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log create mode 100644 agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/plan_cloud_G07_0.log create mode 100644 agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/plan_cloud_G07_1.log create mode 100644 agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/CODE_REVIEW-cloud-G07.md create mode 100644 agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/PLAN-cloud-G07.md create mode 100644 agent-task/m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs/CODE_REVIEW-cloud-G07.md create mode 100644 agent-task/m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs/PLAN-cloud-G07.md create mode 100644 packages/observability/observability_test.go diff --git a/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/code_review_cloud_G07_0.log b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/code_review_cloud_G07_0.log new file mode 100644 index 0000000..0485022 --- /dev/null +++ b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/code_review_cloud_G07_0.log @@ -0,0 +1,208 @@ + + +# 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 `/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) → `/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을 그대로 반환해 잘못된 값이라도 가시화한다. +- 로그 파일 기본값은 `/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`는 `/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`는 빈 값을 `/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`를 작성한다. diff --git a/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/code_review_cloud_G07_1.log b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/code_review_cloud_G07_1.log new file mode 100644 index 0000000..bc0c9c3 --- /dev/null +++ b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/code_review_cloud_G07_1.log @@ -0,0 +1,185 @@ + + +# 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 `/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`가 `/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 `/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로 이동한다. diff --git a/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log new file mode 100644 index 0000000..de14dd7 --- /dev/null +++ b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log @@ -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 `/logs/edge.log`. +- `go test -count=1 ./...` - PASS; all Go packages passed, including edge, node, config, hostsetup, and observability. + +## 잔여 Nit + +- 없음 + +## 후속 작업 + +- 없음 diff --git a/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/plan_cloud_G07_0.log b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/plan_cloud_G07_0.log new file mode 100644 index 0000000..17df0f6 --- /dev/null +++ b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/plan_cloud_G07_0.log @@ -0,0 +1,230 @@ + + +# 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 `/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 `/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 `/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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/plan_cloud_G07_1.log b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/plan_cloud_G07_1.log new file mode 100644 index 0000000..c942ce8 --- /dev/null +++ b/agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/plan_cloud_G07_1.log @@ -0,0 +1,151 @@ + + +# 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 `/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를 `/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: /logs/edge.log`, and the setup template is not used for bundle `config init`. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/CODE_REVIEW-cloud-G07.md b/agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..ae7743e --- /dev/null +++ b/agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,128 @@ + + +# 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 ` 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 | bash -s ` 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. diff --git a/agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/PLAN-cloud-G07.md b/agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/PLAN-cloud-G07.md new file mode 100644 index 0000000..1c4428a --- /dev/null +++ b/agent-task/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/PLAN-cloud-G07.md @@ -0,0 +1,229 @@ + + +# 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 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 ` 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 | bash -s ` 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 + --alias + --token + --adapter ollama|cli + --ollama-base-url + --ollama-context-size + --runtime-concurrency + --workspace-root +``` + +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 +``` + +#### 해결 방법 + +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 +``` + +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 ` command, and config check passes. + +모든 코드 변경 완료 후 반드시 `CODE_REVIEW-*-G??.md`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/agent-task/m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs/CODE_REVIEW-cloud-G07.md b/agent-task/m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs/CODE_REVIEW-cloud-G07.md new file mode 100644 index 0000000..1a20d8c --- /dev/null +++ b/agent-task/m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs/CODE_REVIEW-cloud-G07.md @@ -0,0 +1,135 @@ + + +# 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 ` 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. diff --git a/agent-task/m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs/PLAN-cloud-G07.md b/agent-task/m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs/PLAN-cloud-G07.md new file mode 100644 index 0000000..a56dc39 --- /dev/null +++ b/agent-task/m-edge-local-dev-config-runtime/03+01,02_help_smoke_bundle_docs/PLAN-cloud-G07.md @@ -0,0 +1,226 @@ + + +# 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 ` 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 [--base-url ] [--prompt ] [--timeout ] +``` + +`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`의 구현 에이전트 소유 섹션을 채운다. 이 파일 작성이 구현의 마지막 단계다. diff --git a/apps/edge/cmd/edge/main.go b/apps/edge/cmd/edge/main.go index abe3235..19c6690 100644 --- a/apps/edge/cmd/edge/main.go +++ b/apps/edge/cmd/edge/main.go @@ -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. /edge.yaml beside the executable +// 4. configs/edge.yaml repo-dev fallback (only if it exists) +// +// setup uses its own /etc/iop/edge.yaml default and does not call this helper. +func resolveConfigPath(cmd *cobra.Command) (string, error) { + if isConfigExplicit(cmd) { + return cfgFile, nil + } + if _, err := os.Stat("edge.yaml"); err == nil { + return "edge.yaml", nil + } + if exe, err := os.Executable(); err == nil { + candidate := filepath.Join(filepath.Dir(exe), "edge.yaml") + if _, err := os.Stat(candidate); err == nil { + return candidate, nil + } + } + if _, err := os.Stat("configs/edge.yaml"); err == nil { + return "configs/edge.yaml", nil + } + return "", errors.New("no edge.yaml found: pass --config or run beside a bundle edge.yaml") +} + +func isConfigExplicit(cmd *cobra.Command) bool { + flag := cmd.Root().PersistentFlags().Lookup("config") + return flag != nil && flag.Changed +} + +// resolveEdgeLogPath returns the effective edge log path. If explicit is set +// it wins. Otherwise it falls back to /logs/edge.log. +func resolveEdgeLogPath(explicit string) (string, error) { + if explicit != "" { + return explicit, nil + } + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resolve binary: %w", err) + } + return filepath.Join(filepath.Dir(exe), "logs", "edge.log"), nil +} + +// 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" -> ":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{ diff --git a/apps/edge/cmd/edge/main_test.go b/apps/edge/cmd/edge/main_test.go index 9592827..f57e48f 100644 --- a/apps/edge/cmd/edge/main_test.go +++ b/apps/edge/cmd/edge/main_test.go @@ -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) + } +} diff --git a/apps/edge/internal/bootstrap/runtime.go b/apps/edge/internal/bootstrap/runtime.go index 300859b..ec0ac41 100644 --- a/apps/edge/internal/bootstrap/runtime.go +++ b/apps/edge/internal/bootstrap/runtime.go @@ -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 } diff --git a/apps/edge/internal/bootstrap/runtime_test.go b/apps/edge/internal/bootstrap/runtime_test.go index c080cb8..92a88ac 100644 --- a/apps/edge/internal/bootstrap/runtime_test.go +++ b/apps/edge/internal/bootstrap/runtime_test.go @@ -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"}, diff --git a/configs/edge.yaml b/configs/edge.yaml index 56df98a..c9fbed9 100644 --- a/configs/edge.yaml +++ b/configs/edge.yaml @@ -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 /logs/edge.log + # for the bundled dev binary; explicit values win. + path: "" metrics: port: 9092 diff --git a/packages/config/config.go b/packages/config/config.go index b68d7dc..c69df7d 100644 --- a/packages/config/config.go +++ b/packages/config/config.go @@ -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 { diff --git a/packages/config/config_test.go b/packages/config/config_test.go index b74f32e..e46b909 100644 --- a/packages/config/config_test.go +++ b/packages/config/config_test.go @@ -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") diff --git a/packages/hostsetup/setup_test.go b/packages/hostsetup/setup_test.go index cba944c..84b7ef4 100644 --- a/packages/hostsetup/setup_test.go +++ b/packages/hostsetup/setup_test.go @@ -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{ diff --git a/packages/hostsetup/templates.go b/packages/hostsetup/templates.go index abe3fe7..438a0bf 100644 --- a/packages/hostsetup/templates.go +++ b/packages/hostsetup/templates.go @@ -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 `/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", diff --git a/packages/observability/observability.go b/packages/observability/observability.go index a9e84ce..ed0871c 100644 --- a/packages/observability/observability.go +++ b/packages/observability/observability.go @@ -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 } diff --git a/packages/observability/observability_test.go b/packages/observability/observability_test.go new file mode 100644 index 0000000..2aba7b4 --- /dev/null +++ b/packages/observability/observability_test.go @@ -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)) + } +}