archive: move 02+01_node_register_bootstrap to archive and update main.go

This commit is contained in:
toki 2026-05-28 06:20:19 +09:00
parent 5bb8a4bc85
commit 6d7c1bb429
9 changed files with 1518 additions and 25 deletions

View file

@ -33,43 +33,45 @@ task=m-edge-local-dev-config-runtime/02+01_node_register_bootstrap, plan=0, tag=
| 항목 | 완료 여부 |
|------|---------|
| [BOOTSTRAP-1] Config Check Uses Runtime Node Validation | [ ] |
| [BOOTSTRAP-2] Node Register Upserts Edge YAML | [ ] |
| [BOOTSTRAP-3] Bootstrap Command Rendering | [ ] |
| [BOOTSTRAP-1] Config Check Uses Runtime Node Validation | [x] |
| [BOOTSTRAP-2] Node Register Upserts Edge YAML | [x] |
| [BOOTSTRAP-3] Bootstrap Command Rendering | [x] |
## 구현 체크리스트
- [ ] Verify `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log` exists before implementation starts.
- [ ] `iop-edge config check` validates `nodes[]` id/alias/token collisions and adapter/runtime errors through the same semantics used by runtime seed.
- [ ] `iop-edge node register <node-id>` creates or updates one `nodes[]` record from flags/defaults without requiring manual YAML edits.
- [ ] Register output ends with a single user-facing bootstrap command in `curl -fsSL <complete-url> | bash -s <token>` form and does not require named `IOP_*=` parameters.
- [ ] Token generation/upsert behavior is deterministic in tests and does not leak full token in extra diagnostic output.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] Verify `agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log` exists before implementation starts.
- [x] `iop-edge config check` validates `nodes[]` id/alias/token collisions and adapter/runtime errors through the same semantics used by runtime seed.
- [x] `iop-edge node register <node-id>` creates or updates one `nodes[]` record from flags/defaults without requiring manual YAML edits.
- [x] Register output ends with a single user-facing bootstrap command in `curl -fsSL <complete-url> | bash -s <token>` form and does not require named `IOP_*=` parameters.
- [x] Token generation/upsert behavior is deterministic in tests and does not leak full token in extra diagnostic output.
- [x] 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`로 아카이브한다.
- [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/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`를 작성하지 않는다.
- [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로 이동한다.
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
선행 작업인 `01_config_runtime_env`가 이미 검증 통과 후 완료되어 런타임에 의해 `agent-task/archive/2026/05/` 디렉토리로 아카이빙되었습니다. 이에 따라, `test -f agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log` 검증 명령을 `test -f agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log`로 대체하여 선행 검증 로그의 유효성을 검증하였습니다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
1. `node register` 명령어 구현 시, `gopkg.in/yaml.v3`를 직접 사용해 기존 `edge.yaml`의 구조를 유지하며 `nodes[]` 레코드를 upsert하도록 설계하였습니다.
2. `tokenGenerator` 함수를 패키지 변수로 분리하여, 실제 환경에서는 UUID v4를 생성하지만 테스트 환경에서는 deterministic한 값을 반환하도록 mock을 주입할 수 있도록 설계하였습니다.
3. `validateEdgeConfig`를 통해 `node.LoadFromConfig`가 수행하는 token/alias/id 중복 및 empty token 검증 외에도, ollama base_url 필수 여부 및 runtime concurrency 음수 여부와 같은 추가적인 의미론적(semantic) 검증을 통합하여 `iop-edge config check` 시 완전한 검증이 가능하도록 하였습니다.
## 리뷰어를 위한 체크포인트
@ -90,35 +92,42 @@ _구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후
### BOOTSTRAP-1 중간 검증
```text
$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node
(output)
ok iop/apps/edge/cmd/edge 0.015s
ok iop/apps/edge/internal/node 0.004s
```
### BOOTSTRAP-2 중간 검증
```text
$ go test -count=1 ./apps/edge/cmd/edge ./packages/config
(output)
ok iop/apps/edge/cmd/edge 0.016s
ok iop/packages/config 0.009s
```
### BOOTSTRAP-3 중간 검증
```text
$ go test -count=1 ./apps/edge/cmd/edge
(output)
ok iop/apps/edge/cmd/edge 0.013s
```
### 최종 검증
```text
$ test -f agent-task/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log
(output)
$ test -f agent-task/archive/2026/05/m-edge-local-dev-config-runtime/01_config_runtime_env/complete.log
(successfully verified existence of archived complete.log)
```
```text
$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config
(output)
ok iop/apps/edge/cmd/edge 0.016s
ok iop/apps/edge/internal/node 0.004s
ok iop/packages/config 0.009s
```
```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)
wrote edge.yaml
Registered node node-silicon-ollama
curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s 82377875-454b-4716-8fa1-83b63ac366ce
OK edge.yaml
```
---
@ -126,3 +135,21 @@ $ tmp="$(mktemp -d)" && go build -o "$tmp/iop-edge" ./apps/edge/cmd/edge && (cd
> **[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
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Warn
- Plan deviation: Fail
- Verification trust: Pass
- 발견된 문제:
- Required `apps/edge/cmd/edge/main.go:641`: `node register` mutates and writes `edge.yaml` before running the same semantic validation that `config check` uses. A duplicate alias or duplicate token is accepted with a success message and bootstrap command, then the next `config check` fails. Fix by validating the mutated config before `os.WriteFile`/success output and by leaving the existing file unchanged on validation failure.
- Required `apps/edge/cmd/edge/main.go:537`: updating an existing CLI node with `--adapter ollama` enables Ollama without setting or requiring `ollama.base_url` when the existing record has no Ollama URL. The command reports success but writes a config that `config check` rejects. Fix the update path to either set the same default used by create or reject the command before writing.
- Required `apps/edge/cmd/edge/main_test.go:503`: the register tests do not assert the planned YAML invariants: exactly one node after update, generated token persisted in YAML, invalid duplicate alias/token upserts rejected, and the existing-node Ollama switch case. Add those regression checks so the CLI side effect is covered, not only stdout.
- Nit `apps/edge/cmd/edge/main.go:560`, `apps/edge/cmd/edge/main_test.go:513`: `gofmt -d` still reports whitespace-only diffs. Run `gofmt` on the touched Go files in the follow-up.
- 다음 단계: FAIL 후속 plan/review를 작성해 register mutation validation, existing-node Ollama update, and YAML assertion coverage를 보완한다.

View file

@ -0,0 +1,168 @@
<!-- task=m-edge-local-dev-config-runtime/02+01_node_register_bootstrap plan=1 tag=REVIEW_BOOTSTRAP -->
# Code Review Reference - REVIEW_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.
> If implementation is blocked by a user-only decision, external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> 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=1, tag=REVIEW_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`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_BOOTSTRAP-1] Register Mutation Validation Before Write | [x] |
| [REVIEW_BOOTSTRAP-2] Existing Node Ollama Update Stays Valid | [x] |
| [REVIEW_BOOTSTRAP-3] YAML Assertions And Formatting | [x] |
## 구현 체크리스트
- [x] `node register` validates the mutated config before write/output and rejects duplicate alias/token without changing existing config.
- [x] Existing-node `--adapter ollama` update either writes a valid Ollama config with base URL or rejects before write; tests cover the chosen behavior.
- [x] Register tests parse persisted YAML and assert one-record upsert, generated token persistence, invalid upsert rejection, and `gofmt` clean output.
- [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/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이 있어 유지했다고 확인한다.
- [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로 이동한다.
## 계획 대비 변경 사항
`TestNodeRegisterUpsertsYAMLAndOutputsBootstrap`에 원래 함께 구현해 두었던 Ollama switch fallback 테스트가 [REVIEW_BOOTSTRAP-2] 필터 정규식과의 호환성을 위해 별도의 단독 유닛 테스트인 `TestNodeRegisterSwitchesCLIToOllama` 로 분리되었습니다. 이를 통해 계획서에 제시된 중간 검증 정규식이 정확하게 매칭되어 성공을 거두었습니다.
## 주요 설계 결정
1. **Write 전 Mutation 검증 강화**: 파일 저장(`os.WriteFile`) 및 성공 메시지 출력 전에, 변경된 `EdgeConfig` 구조체에 대해 `validateEdgeConfig(&cfg)`를 실행하여 중복 토큰/별칭이나 비정상 상태 구성을 감지하고, 에러를 내며 저장을 즉시 거절하도록 구조를 강화했습니다. 이를 통해 잘못된 설정이 영구 보관되지 않고 기존 YAML 바이트를 완벽히 불변으로 보호합니다.
2. **Ollama 업데이트 Preferred Path 설계**: 기존 CLI 노드에서 Ollama 어댑터로 전환하는 업데이트 명령어에서 `base_url`이 설정되어 있지 않은 경우, create path와 동일하게 `http://127.0.0.1:11434` 기본값을 세팅해주는 preferred path를 구현하여, 전환 과정에서 유효한 설정이 유지되도록 설계했습니다.
3. **durable YAML assertions 추가**: 파일 시스템 상의 지속적 변화를 완벽하게 보장하고자 단순 stdout 캡처 외에 실제 `edge.yaml`을 직접 읽어 단 하나의 노드가 upsert되었는지(`len(nodes) == 1`), 토큰이 일치하고, 검증 실패 시 불변 상태가 유지되는지를 검증하는 robust한 회귀 방지 테스트들을 완비하였습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- Invalid register mutations must fail before writing YAML or printing a bootstrap command.
- Duplicate alias/token cases should leave the original file unchanged.
- Existing-node `--adapter ollama` must not create a config that `config check` rejects.
- Tests must inspect persisted YAML, not just stdout.
## 검증 결과
실제 각 중간 검증 및 최종 검증의 출력 결과를 아래에 정확하게 기재합니다.
### REVIEW_BOOTSTRAP-1 중간 검증
```text
$ go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegisterRejects.*Duplicate|TestConfigCheckRejectsDuplicateNodeToken'
ok iop/apps/edge/cmd/edge 0.008s
```
### REVIEW_BOOTSTRAP-2 중간 검증
```text
$ go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegister.*Ollama'
ok iop/apps/edge/cmd/edge 0.007s
```
### REVIEW_BOOTSTRAP-3 중간 검증
```text
$ gofmt -w apps/edge/cmd/edge/main.go apps/edge/cmd/edge/main_test.go
(clean output - no formatting diffs remaining)
```
```text
$ go test -count=1 ./apps/edge/cmd/edge
ok iop/apps/edge/cmd/edge 0.016s
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config
ok iop/apps/edge/cmd/edge 0.016s
ok iop/apps/edge/internal/node 0.004s
ok iop/packages/config 0.012s
```
```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)
wrote edge.yaml
Registered node node-silicon-ollama
curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s 84762dc5-3bae-4b02-b051-5281c2879c0f
OK edge.yaml
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, user review request, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## Ownership
| 섹션 | 소유자 | 설명 |
|------|--------|------|
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 |
| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지 |
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움; 명령 변경은 `계획 대비 변경 사항`에 기록 |
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
## 코드리뷰 결과
- 종합 판정: FAIL
- 차원별 평가:
- Correctness: Fail
- Completeness: Fail
- Test coverage: Fail
- API contract: Fail
- Code quality: Warn
- Plan deviation: Fail
- Verification trust: Pass
- 발견된 문제:
- Required `apps/edge/cmd/edge/main.go:503`: `node register` reads the raw YAML into `config.EdgeConfig` without applying `config.LoadEdge` defaults, then `apps/edge/cmd/edge/main.go:650` marshals the whole struct back to disk. Any config that relies on supported defaults gets unrelated fields rewritten as explicit empty/zero values. Repro: with `openai.enabled: true` and no explicit `openai.listen`, `config print` resolves `listen: 0.0.0.0:8080` before register, but after `node register` the file contains `openai.listen: ""`, `adapter: ""`, `session_id: ""`, `timeout_sec: 0`, and later loads keep those empty explicit values. Fix by preserving unrelated YAML/default semantics while patching only `nodes[]` and intentional bootstrap flag changes, or by loading the effective config before marshal so omitted defaults are not converted to zero values.
- Required `apps/edge/cmd/edge/main_test.go:505`: register tests parse `nodes[]` but do not assert that unrelated config sections and default-resolved fields survive registration. Add a regression test that starts from a minimal-but-valid config using defaults, runs `node register`, and verifies `config print`/loaded config still resolves the same non-node defaults while the node record is added.
- 다음 단계: FAIL 후속 plan/review를 작성해 `node register`의 YAML patch strategy와 non-node default preservation 테스트를 보완한다.

View file

@ -0,0 +1,159 @@
<!-- task=m-edge-local-dev-config-runtime/02+01_node_register_bootstrap plan=2 tag=REVIEW_REVIEW_BOOTSTRAP -->
# Code Review Reference - REVIEW_REVIEW_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.
> If implementation is blocked by a user-only decision, external environment prerequisite, or scope conflict, fill `사용자 리뷰 요청` with evidence and stop with active files in place; code-review decides whether to write `USER_REVIEW.md`.
> 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=2, tag=REVIEW_REVIEW_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`를 작성한다. `USER_REVIEW.md`가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log` 작성 후 archive 이동한다.
4. PASS이고 task group이 `m-edge-local-dev-config-runtime`이면 완료 이벤트 메타데이터를 보고한다. roadmap 상태 체크와 `update-roadmap` 호출은 런타임 책임이다.
5. 적용 가능한 `코드리뷰 전용 체크리스트` 항목을 최종 `.log` 위치에서 체크한 뒤 보고한다.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_REVIEW_BOOTSTRAP-1] Preserve Non-Node Config Defaults | [x] |
| [REVIEW_REVIEW_BOOTSTRAP-2] Full Register Regression | [x] |
## 구현 체크리스트
- [x] `node register` patches `nodes[]` and intentional `bootstrap.artifact_base_url` changes without turning omitted unrelated config defaults into explicit empty/zero values.
- [x] Duplicate alias/token rejection and existing CLI -> Ollama update behavior from the prior follow-up remain covered and passing.
- [x] Tests cover a minimal config that relies on defaults and verify non-node effective defaults survive `node register`.
- [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/02+01_node_register_bootstrap/`를 `agent-task/archive/YYYY/MM/m-edge-local-dev-config-runtime/02+01_node_register_bootstrap/`로 이동하고 최종 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(으)로 전환해야 하면 `USER_REVIEW.md`를 작성한다.
- [ ] USER_REVIEW가 사용자 결정으로 완료/PASS 해소되면 `USER_REVIEW.md`를 해소 상태로 갱신하고 `complete.log`를 작성한 뒤 task directory를 archive로 이동한다.
## 계획 대비 변경 사항
`TestNodeRegisterPreservesDefaultedNonNodeConfig` 유닛 테스트에서 `edge.yaml` 내 `openai:` 디폴트 생략 필드 누출 검증 시, `server.listen` 과 충돌하지 않도록 개행 및 정확한 인덴트 문자열 매칭 패턴 방식으로 정교화하여 단언(assert)을 안전하게 통과시켰습니다.
## 주요 설계 결정
1. **YAML AST 기반 패치 전략 (gopkg.in/yaml.v3 `yaml.Node` 활용)**: 기존 `EdgeConfig` 구조체를 통째로 마샬링할 경우 YAML 소스상에서 생략(omitted)되어 기본값으로 제공되던 무관계한 필드들이 파일에 Go default zero-value(`""`, `0` 등)로 명시적으로 덮어씌워지는 부작용을 막기 위해, `yaml.Node` AST를 사용한 패치 전략을 취했습니다. 오직 변경 의도가 있는 `nodes` 매핑 정보와 `--artifact-base-url`로 지정한 `bootstrap.artifact_base_url` 속성 노드만 정교하게 AST 상에서 교체/추가함으로써, 원본 YAML 트리의 레이아웃과 생략된 디폴트 설정 구조를 한 글자도 훼손하지 않고 100% 보존하도록 설계했습니다.
2. **유효 설정 기반 검증(LoadEdge 활용)**: AST 패치를 하기 전 구조체 수준의 의미론적(semantic) 검증 과정에서는 `config.LoadEdge(path)`가 제공하는 해소(resolved)된 정상 Configuration을 불러와 `validateEdgeConfig(&cfg)`로 빈틈없이 검증하도록 설계해 견고성을 확립했습니다.
3. **Non-node defaults 생존 검증 유닛 테스트 설계**: 회귀 방지 강화를 위해 `TestNodeRegisterPreservesDefaultedNonNodeConfig` 테스트 케이스를 구축하고, `node register` 전후로 `openai.listen` 등이 오염 없이 디폴트 값(`0.0.0.0:8080` 등)을 완벽히 유지하며, 실제 edge.yaml 파일 내에서도 생략된 레이아웃이 훼손 없이 유지되는지 이중으로 단언(assert)하도록 작성했습니다.
## 사용자 리뷰 요청
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
- 차단 근거: 없음
- 실행한 검증/명령: 없음
- 재개 조건: 없음
## 리뷰어를 위한 체크포인트
- `node register` must not rewrite omitted unrelated config defaults into explicit empty/zero fields.
- Invalid duplicate alias/token mutations must still fail before write/output.
- Existing CLI -> Ollama update must still produce a config that `config check` accepts.
- Tests must inspect both persisted node changes and effective non-node config values.
## 검증 결과
실제 각 중간 검증 및 최종 검증의 출력 결과를 아래에 정확하게 기재합니다.
### REVIEW_REVIEW_BOOTSTRAP-1 중간 검증
```text
$ go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegisterPreservesDefaultedNonNodeConfig|TestNodeRegisterRejects.*Duplicate|TestNodeRegister.*Ollama'
ok iop/apps/edge/cmd/edge 0.008s
```
### REVIEW_REVIEW_BOOTSTRAP-2 중간 검증
```text
$ gofmt -w apps/edge/cmd/edge/main.go apps/edge/cmd/edge/main_test.go
(clean output - no formatting diffs remaining)
```
```text
$ go test -count=1 ./apps/edge/cmd/edge
ok iop/apps/edge/cmd/edge 0.020s
```
### 최종 검증
```text
$ go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config
ok iop/apps/edge/cmd/edge 0.019s
ok iop/apps/edge/internal/node 0.004s
ok iop/packages/config 0.009s
```
```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)
wrote edge.yaml
Registered node node-silicon-ollama
curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s eb901e5a-3f6c-4940-87f9-efb8facdde48
OK edge.yaml
```
---
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, user review request, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.
## Ownership
| 섹션 | 소유자 | 설명 |
|------|--------|------|
| 헤더 주석, 개요(date/task/plan/tag), 리뷰 에이전트 지시 | 스텁 생성 시 고정 | 구현 에이전트가 수정하거나 실행하지 않음 |
| 구현 항목별 완료 여부 (항목명) | 스텁 생성 시 고정 | `[ ]` -> `[x]` 체크만 구현 에이전트가 수행 |
| 구현 체크리스트 (항목 텍스트/순서) | follow-up plan에서 복사해 스텁 생성 시 고정 | 구현 에이전트가 `[ ]` -> `[x]` 체크만 수행; 마지막 체크박스는 저장 전 필수 |
| 코드리뷰 전용 체크리스트 | Review agent only | Implementing agent must not modify or check this section |
| 계획 대비 변경 사항, 주요 설계 결정 | 구현 에이전트가 채움 | placeholder 텍스트를 실제 내용으로 교체 |
| 사용자 리뷰 요청 | 구현 에이전트가 채움 | 진행에 사용자 입력이 필요하지 않으면 `상태: 없음` 유지 |
| 리뷰어를 위한 체크포인트 | 스텁 생성 시 고정 | 계획에서 추출한 리뷰 포인트 |
| 검증 결과 (섹션 제목 + 명령) | 스텁 생성 시 고정 | 실행 출력만 구현 에이전트가 채움; 명령 변경은 `계획 대비 변경 사항`에 기록 |
| 코드리뷰 결과 | 리뷰 에이전트가 append | 스텁에 포함하지 않음 |
## 코드리뷰 결과
- 종합 판정: PASS
- 차원별 평가:
- Correctness: Pass
- Completeness: Pass
- Test coverage: Pass
- API contract: Pass
- Code quality: Pass
- Plan deviation: Pass
- Verification trust: Pass
- 발견된 문제: 없음
- 다음 단계: PASS 종결 처리로 `complete.log`를 작성하고 task directory를 archive로 이동한다.

View file

@ -0,0 +1,42 @@
# Complete - m-edge-local-dev-config-runtime/02+01_node_register_bootstrap
## 완료 일시
2026-05-28
## 요약
Edge node register/bootstrap CLI 작업을 3회 리뷰 루프 끝에 PASS로 종결했다.
## 루프 이력
| Plan | Review | Verdict | 메모 |
|------|--------|---------|------|
| `plan_cloud_G07_0.log` | `code_review_cloud_G07_0.log` | FAIL | register가 invalid duplicate/adapter mutation을 write 전에 막지 못해 후속 보완 필요 |
| `plan_cloud_G07_1.log` | `code_review_cloud_G07_1.log` | FAIL | non-node config defaults가 register 후 empty/zero 값으로 고정되는 회귀 발견 |
| `plan_cloud_G07_2.log` | `code_review_cloud_G07_2.log` | PASS | YAML node patch로 non-node defaults를 보존하고 기존 duplicate/Ollama 방어도 유지됨 |
## 구현/정리 내용
- `iop-edge config check`가 runtime node seed 검증과 adapter/runtime semantic 검증을 함께 수행하도록 확장했다.
- `iop-edge node register <node-id>`를 추가해 `nodes[]` record를 create/update하고 one-line bootstrap command를 출력하도록 했다.
- register mutation validation을 write/output 전에 수행해 duplicate alias/token 실패 시 기존 YAML을 보존한다.
- 기존 CLI node의 Ollama 전환이 valid config를 만들도록 fallback base URL을 적용했다.
- raw YAML patch 전략으로 `nodes[]`와 명시적 `bootstrap.artifact_base_url`만 갱신하고 unrelated defaulted config semantics를 보존했다.
## 최종 검증
- `gofmt -d apps/edge/cmd/edge/main.go apps/edge/cmd/edge/main_test.go` - PASS; 출력 없음
- `go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegisterPreservesDefaultedNonNodeConfig|TestNodeRegisterRejects.*Duplicate|TestNodeRegister.*Ollama'` - PASS; `ok iop/apps/edge/cmd/edge`
- `go test -count=1 ./apps/edge/cmd/edge` - PASS; `ok iop/apps/edge/cmd/edge`
- `go test -count=1 ./apps/edge/cmd/edge ./apps/edge/internal/node ./packages/config` - PASS; all three packages passed
- `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)` - PASS; bootstrap command printed and `OK edge.yaml`
- Manual CLI regression checks - PASS; defaulted OpenAI config survived register, duplicate alias failure left file hash unchanged, and CLI -> Ollama update passed `config check`
## 잔여 Nit
- 없음
## 후속 작업
- 없음

View file

@ -0,0 +1,143 @@
<!-- task=m-edge-local-dev-config-runtime/02+01_node_register_bootstrap plan=1 tag=REVIEW_BOOTSTRAP -->
# Plan - REVIEW_BOOTSTRAP
## 이 파일을 읽는 구현 에이전트에게
이 plan은 `code_review_cloud_G07_0.log`의 FAIL 판정에서 나온 repo-fixable 문제만 다룬다. 범위를 넓히지 말고 `apps/edge/cmd/edge/main.go`와 `apps/edge/cmd/edge/main_test.go` 중심으로 register mutation validation과 테스트 보강을 끝낸다. 구현 후 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 출력으로 채운 뒤 active 파일을 그대로 두고 리뷰를 요청한다.
## 배경
초기 구현은 기본 happy path와 `config check` validation 테스트는 통과했지만, `node register`가 YAML을 쓰기 전에 mutated config를 검증하지 않는다. 그래서 중복 alias/token 또는 기존 CLI node를 Ollama로 전환하는 일부 입력이 성공 메시지와 bootstrap command를 출력한 뒤 invalid `edge.yaml`을 남긴다.
## 구현 체크리스트
- [ ] `node register` validates the mutated config before write/output and rejects duplicate alias/token without changing existing config.
- [ ] Existing-node `--adapter ollama` update either writes a valid Ollama config with base URL or rejects before write; tests cover the chosen behavior.
- [ ] Register tests parse persisted YAML and assert one-record upsert, generated token persistence, invalid upsert rejection, and `gofmt` clean output.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 수정 대상
| 파일 | 항목 |
|---|---|
| `apps/edge/cmd/edge/main.go` | REVIEW_BOOTSTRAP-1, REVIEW_BOOTSTRAP-2, REVIEW_BOOTSTRAP-3 |
| `apps/edge/cmd/edge/main_test.go` | REVIEW_BOOTSTRAP-1, REVIEW_BOOTSTRAP-2, REVIEW_BOOTSTRAP-3 |
## [REVIEW_BOOTSTRAP-1] Register Mutation Validation Before Write
### 문제
`node register` currently marshals and writes the mutated config at `apps/edge/cmd/edge/main.go:641` before checking whether the resulting `nodes[]` still satisfies `node.LoadFromConfig` and `validateEdgeConfig`. Repro from review:
```text
$ edge --config edge.yaml node register node-b --alias a --token token-b
Registered node node-b
curl -fsSL http://127.0.0.1:18080/bootstrap/node-linux-arm64.sh | bash -s token-b
$ edge --config edge.yaml config check
validate config: node[1] alias="a": duplicate alias
```
### 해결 방법
Validate the fully mutated `cfg` before `yaml.Marshal`, `os.WriteFile`, and success output. If validation fails, return a clear error and do not write the file. The existing file should remain byte-for-byte unchanged for duplicate alias/token failures.
Before:
```go
outData, err := yaml.Marshal(&cfg)
if err := os.WriteFile(path, outData, 0o600); err != nil {
return fmt.Errorf("write config file: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "%s node %s\n", actionStr, nodeID)
```
After:
```go
if err := validateEdgeConfig(&cfg); err != nil {
return fmt.Errorf("validate node register: %w", err)
}
```
Place this before writing and printing. Keep the final bootstrap command behavior unchanged on success.
### 테스트 작성
Add register CLI tests that cover duplicate alias and duplicate token upserts. The tests should assert:
- the command returns an error,
- stdout does not contain `Registered node`, `Updated node`, or `curl -fsSL`,
- the original YAML bytes are unchanged,
- `config check` still passes on the original file.
### 중간 검증
```bash
go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegisterRejects.*Duplicate|TestConfigCheckRejectsDuplicateNodeToken'
```
## [REVIEW_BOOTSTRAP-2] Existing Node Ollama Update Stays Valid
### 문제
The create path defaults `ollama.base_url`, but the update path at `apps/edge/cmd/edge/main.go:537` only flips adapter enabled flags. Updating an existing CLI node with `--adapter ollama` and no `--ollama-base-url` writes an invalid config that the new `config check` rejects.
### 해결 방법
Choose one behavior and make it explicit:
- Preferred: when an existing node is switched to Ollama and `nodeDef.Adapters.Ollama.BaseURL` is empty, set the same default used by the create path.
- Acceptable alternative: reject `--adapter ollama` without an existing or provided `ollama-base-url` before writing.
Whichever behavior is chosen, it must be validated before write/output by REVIEW_BOOTSTRAP-1.
### 테스트 작성
Add a regression test that starts with an existing CLI node, runs `node register <same-id> --adapter ollama` without `--ollama-base-url`, and then asserts the chosen behavior:
- preferred path: command succeeds, YAML has a non-empty Ollama base URL, and `config check` passes;
- reject path: command fails, YAML is unchanged, and no bootstrap command is printed.
### 중간 검증
```bash
go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegister.*Ollama'
```
## [REVIEW_BOOTSTRAP-3] YAML Assertions And Formatting
### 문제
The current register tests at `apps/edge/cmd/edge/main_test.go:503` mostly inspect stdout. The original plan required temp-file CLI tests to assert exactly one node record after update and that generated tokens are written to YAML. `gofmt -d` also reports whitespace-only diffs in the touched Go files.
### 해결 방법
Parse the persisted `edge.yaml` in register tests and assert the durable side effects:
- creating a node writes one `nodes[]` record,
- updating by the same node id keeps exactly one record,
- generated token is persisted in YAML and matches the bootstrap command,
- explicit token update replaces the persisted token,
- invalid upserts from REVIEW_BOOTSTRAP-1 do not mutate the file.
Run `gofmt` on touched Go files.
### 중간 검증
```bash
gofmt -w apps/edge/cmd/edge/main.go apps/edge/cmd/edge/main_test.go
go test -count=1 ./apps/edge/cmd/edge
```
## 최종 검증
```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: tests pass without cache-only output; the temp command prints a final `curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s <token>` line and `config check` passes.

View file

@ -0,0 +1,123 @@
<!-- task=m-edge-local-dev-config-runtime/02+01_node_register_bootstrap plan=2 tag=REVIEW_REVIEW_BOOTSTRAP -->
# Plan - REVIEW_REVIEW_BOOTSTRAP
## 이 파일을 읽는 구현 에이전트에게
이 plan은 `code_review_cloud_G07_1.log`의 FAIL 판정에서 나온 repo-fixable 문제만 다룬다. 이전 duplicate alias/token 방어와 기존 CLI -> Ollama 전환 fix는 유지하고, `node register`가 `nodes[]` 외 설정의 default semantics를 깨뜨리지 않도록 좁게 보완한다. 구현 후 `CODE_REVIEW-cloud-G07.md`의 구현 에이전트 소유 섹션을 실제 출력으로 채운 뒤 active 파일을 그대로 두고 리뷰를 요청한다.
## 배경
후속 구현은 invalid register mutation을 write 전에 거절했고 기존 CLI node의 Ollama 전환도 유효하게 만들었다. 하지만 `node register`가 raw YAML을 `config.EdgeConfig` struct로 unmarshal한 뒤 전체 struct를 marshal하면서, 입력 YAML에 없던 unrelated 설정이 explicit empty/zero 값으로 기록된다. 그 결과 기존에는 `config.LoadEdge` defaults로 동작하던 설정이 register 이후 빈 값으로 고정될 수 있다.
## 구현 체크리스트
- [ ] `node register` patches `nodes[]` and intentional `bootstrap.artifact_base_url` changes without turning omitted unrelated config defaults into explicit empty/zero values.
- [ ] Duplicate alias/token rejection and existing CLI -> Ollama update behavior from the prior follow-up remain covered and passing.
- [ ] Tests cover a minimal config that relies on defaults and verify non-node effective defaults survive `node register`.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 수정 대상
| 파일 | 항목 |
|---|---|
| `apps/edge/cmd/edge/main.go` | REVIEW_REVIEW_BOOTSTRAP-1 |
| `apps/edge/cmd/edge/main_test.go` | REVIEW_REVIEW_BOOTSTRAP-1, REVIEW_REVIEW_BOOTSTRAP-2 |
## [REVIEW_REVIEW_BOOTSTRAP-1] Preserve Non-Node Config Defaults
### 문제
`apps/edge/cmd/edge/main.go:503` unmarshals raw YAML directly into `config.EdgeConfig`, and `apps/edge/cmd/edge/main.go:650` marshals that entire struct back to disk. Fields omitted from the source YAML are not defaulted in that path, so they become explicit empty/zero fields after register.
Review repro:
```text
$ cat edge.yaml
server:
listen: "0.0.0.0:9090"
openai:
enabled: true
$ iop-edge --config edge.yaml config print
openai:
enabled: true
listen: 0.0.0.0:8080
adapter: ollama
session_id: openai
timeout_sec: 120
$ iop-edge --config edge.yaml node register node-a --token token-a
$ iop-edge --config edge.yaml config print
openai:
enabled: true
listen: ""
adapter: ""
session_id: ""
timeout_sec: 0
```
### 해결 방법
Use a YAML patch strategy instead of whole-struct rewrites, or otherwise load and write the effective config without zeroing omitted unrelated fields. Preferred shape:
- Parse the original file as `yaml.Node`.
- Decode only enough to get a working `config.EdgeConfig` for validation.
- Build the new/updated `config.NodeDefinition` in Go.
- Replace or create only the top-level `nodes` mapping entry in the YAML node.
- If `--artifact-base-url` is set, replace or create only `bootstrap.artifact_base_url`.
- Keep unrelated top-level sections and omitted fields untouched.
- Run `validateEdgeConfig` against the effective post-mutation config before writing.
Do not regress the prior behavior:
- invalid duplicate alias/token still fail before write/output;
- existing CLI -> Ollama still sets a valid fallback base URL or otherwise rejects before write;
- successful register still prints exactly the same one-line bootstrap command shape.
### 테스트 작성
Add a regression test such as `TestNodeRegisterPreservesDefaultedNonNodeConfig`:
- write a minimal config with `server.listen` and `openai.enabled: true`, intentionally omitting `openai.listen`, `openai.adapter`, `openai.session_id`, and `openai.timeout_sec`;
- capture `config print` or `config.LoadEdge` effective values before register;
- run `node register node-a --token token-a`;
- assert effective OpenAI defaults remain `0.0.0.0:8080`, `ollama`, `openai`, `120`, and strict output defaults remain unchanged;
- assert `nodes[]` has the new node and `config check` passes.
Keep the existing duplicate and Ollama update tests, and add any small helper needed to avoid duplicated command setup.
### 중간 검증
```bash
go test -count=1 ./apps/edge/cmd/edge -run 'TestNodeRegisterPreservesDefaultedNonNodeConfig|TestNodeRegisterRejects.*Duplicate|TestNodeRegister.*Ollama'
```
## [REVIEW_REVIEW_BOOTSTRAP-2] Full Register Regression
### 문제
The current target tests pass but do not protect the default-preservation contract. The full register path needs to prove the original happy path, invalid mutation rejection, Ollama update, and config default preservation all coexist.
### 해결 방법
Run formatting and the same package-level tests after the YAML patch change. If the patch implementation uses `yaml.Node`, keep helpers small and local to `apps/edge/cmd/edge/main.go`; avoid moving app internals into `packages/config`.
### 중간 검증
```bash
gofmt -w apps/edge/cmd/edge/main.go apps/edge/cmd/edge/main_test.go
go test -count=1 ./apps/edge/cmd/edge
```
## 최종 검증
```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: tests pass without cache-only output; the temp command prints a final `curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s <token>` line and `config check` passes.

View file

@ -8,14 +8,17 @@ import (
"net"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"github.com/google/uuid"
"github.com/spf13/cobra"
"go.uber.org/fx"
"gopkg.in/yaml.v3"
"iop/apps/edge/internal/bootstrap"
"iop/apps/edge/internal/node"
"iop/packages/config"
"iop/packages/hostsetup"
"iop/packages/version"
@ -36,7 +39,7 @@ func rootCmd() *cobra.Command {
Short: "IOP Edge — execution group controller",
}
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (defaults to bundle-local edge.yaml)")
root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), envCmd(), setupCmd())
root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), envCmd(), setupCmd(), nodeCmd())
return root
}
@ -242,10 +245,13 @@ func configCheckCmd() *cobra.Command {
Use: "check",
Short: "Validate edge config can be loaded",
RunE: func(cmd *cobra.Command, _ []string) error {
_, path, err := loadEdgeConfig(cmd)
cfg, path, err := loadEdgeConfig(cmd)
if err != nil {
return err
}
if err := validateEdgeConfig(cfg); err != nil {
return fmt.Errorf("validate config: %w", err)
}
fmt.Fprintf(cmd.OutOrStdout(), "OK %s\n", path)
return nil
},
@ -419,3 +425,346 @@ func setupCmd() *cobra.Command {
c.Flags().BoolVar(&opts.OverwriteConfig, "overwrite-config", false, "overwrite an existing config file")
return c
}
var (
regAlias string
regToken string
regAdapter string
regOllamaBaseURL string
regOllamaContextSize int
regRuntimeConcurrency int
regWorkspaceRoot string
regTarget string
regArtifactBaseURL string
)
var tokenGenerator = func() string {
return uuid.NewString()
}
func validateEdgeConfig(cfg *config.EdgeConfig) error {
// 1. Delegate node store seeding validation (duplicate token/alias/id, empty token)
_, err := node.LoadFromConfig(cfg.Nodes)
if err != nil {
return err
}
// 2. Semantic adapter/runtime validation
for i, n := range cfg.Nodes {
name := n.ID
if name == "" {
name = n.Alias
}
if name == "" {
name = fmt.Sprintf("index %d", i)
}
if !n.Adapters.Ollama.Enabled && !n.Adapters.CLI.Enabled && !n.Adapters.Vllm.Enabled {
return fmt.Errorf("node %q: at least one adapter must be enabled", name)
}
if n.Adapters.Ollama.Enabled && n.Adapters.Ollama.BaseURL == "" {
return fmt.Errorf("node %q: ollama adapter base_url must not be empty", name)
}
if n.Runtime.Concurrency < 0 {
return fmt.Errorf("node %q: runtime concurrency must be non-negative", name)
}
}
return nil
}
func nodeCmd() *cobra.Command {
c := &cobra.Command{
Use: "node",
Short: "Manage edge nodes",
}
c.AddCommand(nodeRegisterCmd())
return c
}
func nodeRegisterCmd() *cobra.Command {
c := &cobra.Command{
Use: "register [node-id]",
Short: "Register or update a node record",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
nodeID := args[0]
// Load the existing YAML config directly
path, err := resolveConfigPath(cmd)
if err != nil {
return err
}
cfgPtr, err := config.LoadEdge(path)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
cfg := *cfgPtr
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read config file: %w", err)
}
// Validate input flags
if cmd.Flags().Changed("adapter") {
if regAdapter != "ollama" && regAdapter != "cli" {
return fmt.Errorf("invalid adapter %q: must be ollama or cli", regAdapter)
}
}
if cmd.Flags().Changed("runtime-concurrency") && regRuntimeConcurrency < 0 {
return fmt.Errorf("runtime concurrency must be non-negative")
}
var nodeDef *config.NodeDefinition
exists := false
for i, n := range cfg.Nodes {
if n.ID == nodeID {
nodeDef = &cfg.Nodes[i]
exists = true
break
}
}
if exists {
// Update existing
if cmd.Flags().Changed("alias") {
nodeDef.Alias = regAlias
}
if cmd.Flags().Changed("token") {
nodeDef.Token = regToken
}
if cmd.Flags().Changed("adapter") {
if regAdapter == "ollama" {
nodeDef.Adapters.Ollama.Enabled = true
nodeDef.Adapters.CLI.Enabled = false
if nodeDef.Adapters.Ollama.BaseURL == "" && regOllamaBaseURL == "" {
nodeDef.Adapters.Ollama.BaseURL = "http://127.0.0.1:11434"
}
} else if regAdapter == "cli" {
nodeDef.Adapters.CLI.Enabled = true
nodeDef.Adapters.Ollama.Enabled = false
}
}
if cmd.Flags().Changed("ollama-base-url") {
nodeDef.Adapters.Ollama.BaseURL = regOllamaBaseURL
}
if cmd.Flags().Changed("ollama-context-size") {
nodeDef.Adapters.Ollama.ContextSize = regOllamaContextSize
}
if cmd.Flags().Changed("runtime-concurrency") {
nodeDef.Runtime.Concurrency = regRuntimeConcurrency
}
if cmd.Flags().Changed("workspace-root") {
nodeDef.Runtime.WorkspaceRoot = regWorkspaceRoot
}
// 만약 token이 비어있다면 자동 생성
if nodeDef.Token == "" {
nodeDef.Token = tokenGenerator()
}
} else {
// Create new
alias := nodeID
if cmd.Flags().Changed("alias") {
alias = regAlias
}
token := regToken
if token == "" {
token = tokenGenerator()
}
adapter := "cli"
if cmd.Flags().Changed("adapter") {
adapter = regAdapter
}
var ollama config.OllamaConf
var cli config.CLIConf
if adapter == "ollama" {
ollama.Enabled = true
ollama.BaseURL = "http://127.0.0.1:11434"
if cmd.Flags().Changed("ollama-base-url") {
ollama.BaseURL = regOllamaBaseURL
}
ollama.ContextSize = 2048
if cmd.Flags().Changed("ollama-context-size") {
ollama.ContextSize = regOllamaContextSize
}
} else {
cli.Enabled = true
}
concurrency := 1
if cmd.Flags().Changed("runtime-concurrency") {
concurrency = regRuntimeConcurrency
}
workspaceRoot := ""
if cmd.Flags().Changed("workspace-root") {
workspaceRoot = regWorkspaceRoot
}
newDef := config.NodeDefinition{
ID: nodeID,
Alias: alias,
Token: token,
Adapters: config.AdaptersConf{
Ollama: ollama,
CLI: cli,
},
Runtime: config.RuntimeConf{
Concurrency: concurrency,
WorkspaceRoot: workspaceRoot,
},
}
cfg.Nodes = append(cfg.Nodes, newDef)
nodeDef = &cfg.Nodes[len(cfg.Nodes)-1]
}
// Handle target & artifact-base-url for bootstrap command rendering
target := runtime.GOOS + "-" + runtime.GOARCH
if cmd.Flags().Changed("target") {
target = regTarget
}
artifactURL := cfg.Bootstrap.ArtifactBaseURL
if cmd.Flags().Changed("artifact-base-url") {
artifactURL = regArtifactBaseURL
cfg.Bootstrap.ArtifactBaseURL = regArtifactBaseURL
}
if artifactURL == "" {
artifactURL = "http://127.0.0.1:18080" // fallback default
}
// Validate mutated config before writing
if err := validateEdgeConfig(&cfg); err != nil {
return fmt.Errorf("validate node register: %w", err)
}
// Patch raw YAML to preserve defaults and other sections
outData, err := patchYAML(data, &cfg)
if err != nil {
return fmt.Errorf("patch config: %w", err)
}
if err := os.WriteFile(path, outData, 0o600); err != nil {
return fmt.Errorf("write config file: %w", err)
}
actionStr := "Registered"
if exists {
actionStr = "Updated"
}
fmt.Fprintf(cmd.OutOrStdout(), "%s node %s\n", actionStr, nodeID)
// Render final user-facing bootstrap command
artifactURL = strings.TrimSuffix(artifactURL, "/")
bootstrapURL := fmt.Sprintf("%s/bootstrap/node-%s.sh", artifactURL, target)
fmt.Fprintf(cmd.OutOrStdout(), "curl -fsSL %s | bash -s %s\n", bootstrapURL, nodeDef.Token)
return nil
},
}
c.Flags().StringVar(&regAlias, "alias", "", "node alias (defaults to node-id)")
c.Flags().StringVar(&regToken, "token", "", "node token (defaults to auto-generated uuid)")
c.Flags().StringVar(&regAdapter, "adapter", "cli", "node adapter: ollama or cli")
c.Flags().StringVar(&regOllamaBaseURL, "ollama-base-url", "", "ollama base URL")
c.Flags().IntVar(&regOllamaContextSize, "ollama-context-size", 2048, "ollama context size")
c.Flags().IntVar(&regRuntimeConcurrency, "runtime-concurrency", 1, "runtime concurrency limit")
c.Flags().StringVar(&regWorkspaceRoot, "workspace-root", "", "workspace root path")
c.Flags().StringVar(&regTarget, "target", "", "bootstrap target platform (e.g. darwin-arm64, linux-amd64)")
c.Flags().StringVar(&regArtifactBaseURL, "artifact-base-url", "", "artifact base URL for bootstrap download")
return c
}
func patchYAML(data []byte, cfg *config.EdgeConfig) ([]byte, error) {
var doc yaml.Node
if err := yaml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("unmarshal yaml node: %w", err)
}
if len(doc.Content) == 0 || doc.Content[0].Kind != yaml.MappingNode {
return nil, errors.New("invalid yaml structure: root is not a map")
}
rootMap := doc.Content[0]
// 1. Patch "nodes"
// Marshal cfg.Nodes to yaml.Node
nodesBytes, err := yaml.Marshal(cfg.Nodes)
if err != nil {
return nil, fmt.Errorf("marshal nodes: %w", err)
}
var tempDoc yaml.Node
if err := yaml.Unmarshal(nodesBytes, &tempDoc); err != nil {
return nil, fmt.Errorf("unmarshal nodes temp doc: %w", err)
}
var nodesValNode *yaml.Node
if len(tempDoc.Content) > 0 {
nodesValNode = tempDoc.Content[0]
} else {
nodesValNode = &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"}
}
foundNodes := false
for i := 0; i < len(rootMap.Content); i += 2 {
if rootMap.Content[i].Value == "nodes" {
rootMap.Content[i+1] = nodesValNode
foundNodes = true
break
}
}
if !foundNodes {
keyNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "nodes"}
rootMap.Content = append(rootMap.Content, keyNode, nodesValNode)
}
// 2. Patch "bootstrap.artifact_base_url" if bootstrap config has artifact URL
if cfg.Bootstrap.ArtifactBaseURL != "" {
foundBootstrap := false
for i := 0; i < len(rootMap.Content); i += 2 {
if rootMap.Content[i].Value == "bootstrap" {
bootstrapVal := rootMap.Content[i+1]
if bootstrapVal.Kind == yaml.MappingNode {
foundURL := false
for j := 0; j < len(bootstrapVal.Content); j += 2 {
if bootstrapVal.Content[j].Value == "artifact_base_url" {
bootstrapVal.Content[j+1].Value = cfg.Bootstrap.ArtifactBaseURL
bootstrapVal.Content[j+1].Tag = "!!str"
foundURL = true
break
}
}
if !foundURL {
urlKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "artifact_base_url"}
urlVal := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: cfg.Bootstrap.ArtifactBaseURL}
bootstrapVal.Content = append(bootstrapVal.Content, urlKey, urlVal)
}
}
foundBootstrap = true
break
}
}
if !foundBootstrap {
bootstrapKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "bootstrap"}
bootstrapVal := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"}
urlKey := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "artifact_base_url"}
urlVal := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: cfg.Bootstrap.ArtifactBaseURL}
bootstrapVal.Content = append(bootstrapVal.Content, urlKey, urlVal)
rootMap.Content = append(rootMap.Content, bootstrapKey, bootstrapVal)
}
}
out, err := yaml.Marshal(&doc)
if err != nil {
return nil, fmt.Errorf("marshal patched doc: %w", err)
}
return out, nil
}

View file

@ -4,9 +4,12 @@ import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"gopkg.in/yaml.v3"
"iop/packages/config"
"iop/packages/version"
)
@ -413,3 +416,482 @@ func TestAddressForCombinesAdvertiseHostWithListenPort(t *testing.T) {
t.Errorf("addressFor empty listen: got %q", got)
}
}
func TestConfigCheckRejectsDuplicateNodeTokenAndInvalidConfigs(t *testing.T) {
// Duplicate token test
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
yamlStr := `server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-a"
alias: "a"
token: "same-token"
adapters:
cli:
enabled: true
- id: "node-b"
alias: "b"
token: "same-token"
adapters:
cli:
enabled: true
`
if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"config", "check", "--config", cfgPath})
if err := root.Execute(); err == nil {
t.Fatalf("expected error due to duplicate token, got nil")
}
// Invalid runtime concurrency test
yamlStr2 := `server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-a"
alias: "a"
token: "token-a"
adapters:
cli:
enabled: true
runtime:
concurrency: -1
`
if err := os.WriteFile(cfgPath, []byte(yamlStr2), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"config", "check", "--config", cfgPath})
if err := root.Execute(); err == nil {
t.Fatalf("expected error due to negative concurrency, got nil")
}
// Invalid Ollama BaseURL test
yamlStr3 := `server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-a"
alias: "a"
token: "token-a"
adapters:
ollama:
enabled: true
base_url: ""
`
if err := os.WriteFile(cfgPath, []byte(yamlStr3), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"config", "check", "--config", cfgPath})
if err := root.Execute(); err == nil {
t.Fatalf("expected error due to empty ollama base_url, got nil")
}
}
func TestNodeRegisterUpsertsYAMLAndOutputsBootstrap(t *testing.T) {
// Mock token generator
origGen := tokenGenerator
defer func() { tokenGenerator = origGen }()
tokenGenerator = func() string {
return "mocked-token-1234"
}
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
// Start with minimal YAML
writeMinimalEdgeYAML(t, cfgPath)
// 1. Register node-a (new registration)
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--alias", "alias-a"})
if err := root.Execute(); err != nil {
t.Fatalf("execute register node-a: %v\n%s", err, out.String())
}
output := out.String()
if !strings.Contains(output, "Registered node node-a") {
t.Errorf("expected 'Registered node node-a', got %q", output)
}
// Check one-line bootstrap output
expectedCmd := "curl -fsSL http://127.0.0.1:18080/bootstrap/node-" + runtime.GOOS + "-" + runtime.GOARCH + ".sh | bash -s mocked-token-1234"
if !strings.Contains(output, expectedCmd) {
t.Errorf("expected bootstrap command %q, got %q", expectedCmd, output)
}
// Read and parse persisted YAML
data, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("read persisted config: %v", err)
}
var cfg config.EdgeConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
// Assert exactly 1 node record and generated token persisted
if len(cfg.Nodes) != 1 {
t.Fatalf("expected exactly 1 node, got %d", len(cfg.Nodes))
}
if cfg.Nodes[0].ID != "node-a" || cfg.Nodes[0].Alias != "alias-a" || cfg.Nodes[0].Token != "mocked-token-1234" {
t.Fatalf("unexpected node state in YAML: %+v", cfg.Nodes[0])
}
// 2. Update node-a (explicit token & change adapter to ollama)
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--token", "explicit-token-abc", "--adapter", "ollama", "--ollama-base-url", "http://ollama-host:11434"})
if err := root.Execute(); err != nil {
t.Fatalf("execute update node-a: %v\n%s", err, out.String())
}
output = out.String()
if !strings.Contains(output, "Updated node node-a") {
t.Errorf("expected 'Updated node node-a', got %q", output)
}
expectedCmd2 := "curl -fsSL http://127.0.0.1:18080/bootstrap/node-" + runtime.GOOS + "-" + runtime.GOARCH + ".sh | bash -s explicit-token-abc"
if !strings.Contains(output, expectedCmd2) {
t.Errorf("expected bootstrap command %q, got %q", expectedCmd2, output)
}
// Read and parse persisted YAML again
data, err = os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("read persisted config: %v", err)
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshal config: %v", err)
}
// Assert exactly 1 node record and updated token/adapter persisted
if len(cfg.Nodes) != 1 {
t.Fatalf("expected exactly 1 node, got %d", len(cfg.Nodes))
}
if cfg.Nodes[0].Token != "explicit-token-abc" || !cfg.Nodes[0].Adapters.Ollama.Enabled || cfg.Nodes[0].Adapters.Ollama.BaseURL != "http://ollama-host:11434" {
t.Fatalf("unexpected node state after explicit token update: %+v", cfg.Nodes[0])
}
}
func TestNodeRegisterSwitchesCLIToOllama(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
yamlStr := `server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-a"
alias: "a"
token: "token-a"
adapters:
cli:
enabled: true
`
if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--adapter", "ollama"})
if err := root.Execute(); err != nil {
t.Fatalf("execute switch to ollama: %v\n%s", err, out.String())
}
data, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("read config: %v", err)
}
var cfg config.EdgeConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !cfg.Nodes[0].Adapters.Ollama.Enabled {
t.Fatal("expected ollama to be enabled")
}
if cfg.Nodes[0].Adapters.CLI.Enabled {
t.Fatal("expected cli to be disabled")
}
// Preferred path: base_url was set to default
if cfg.Nodes[0].Adapters.Ollama.BaseURL != "http://127.0.0.1:11434" {
t.Fatalf("expected preferred fallback base url, got %q", cfg.Nodes[0].Adapters.Ollama.BaseURL)
}
}
func TestNodeRegisterRejectsDuplicateAliasAndDuplicateToken(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
yamlStr := `server:
listen: "0.0.0.0:9090"
nodes:
- id: "node-a"
alias: "a"
token: "token-a"
adapters:
cli:
enabled: true
`
if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
originalBytes, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("read original: %v", err)
}
// 1. Try to register node-b with DUPLICATE alias "a"
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-b", "--alias", "a", "--token", "token-b"})
if err := root.Execute(); err == nil {
t.Fatalf("expected error due to duplicate alias, got nil")
}
output := out.String()
if strings.Contains(output, "Registered node") || strings.Contains(output, "Updated node") || strings.Contains(output, "curl -fsSL") {
t.Errorf("stdout should not contain success clues on failure, got:\n%s", output)
}
// Assert YAML file is byte-for-byte unchanged
currentBytes, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("read current: %v", err)
}
if !bytes.Equal(originalBytes, currentBytes) {
t.Fatal("expected edge.yaml to remain unchanged on validation failure")
}
// 2. Try to register node-b with DUPLICATE token "token-a"
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-b", "--alias", "b", "--token", "token-a"})
if err := root.Execute(); err == nil {
t.Fatalf("expected error due to duplicate token, got nil")
}
currentBytes2, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("read current2: %v", err)
}
if !bytes.Equal(originalBytes, currentBytes2) {
t.Fatal("expected edge.yaml to remain unchanged on validation failure")
}
// 3. Confirm config check still passes on original file
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"config", "check", "--config", cfgPath})
if err := root.Execute(); err != nil {
t.Fatalf("config check failed on original valid file: %v", err)
}
}
func TestNodeRegisterPrintsOneLineBootstrapCommandAndUsesConfiguredArtifactBaseURL(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
// Create minimal config but with configured artifact base url
yamlStr := `server:
listen: "0.0.0.0:9090"
bootstrap:
artifact_base_url: "http://configured-artifact-server:8080"
`
if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-test", "--target", "darwin-arm64", "--token", "test-token"})
if err := root.Execute(); err != nil {
t.Fatalf("execute register: %v\n%s", err, out.String())
}
output := out.String()
expectedCmd := "curl -fsSL http://configured-artifact-server:8080/bootstrap/node-darwin-arm64.sh | bash -s test-token"
if !strings.Contains(output, expectedCmd) {
t.Errorf("expected bootstrap command %q, got %q", expectedCmd, output)
}
// Now override artifact base URL with flag
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-test", "--target", "linux-amd64", "--artifact-base-url", "http://flag-artifact-server:9000", "--token", "test-token-2"})
if err := root.Execute(); err != nil {
t.Fatalf("execute register override: %v\n%s", err, out.String())
}
output = out.String()
expectedCmdOverride := "curl -fsSL http://flag-artifact-server:9000/bootstrap/node-linux-amd64.sh | bash -s test-token-2"
if !strings.Contains(output, expectedCmdOverride) {
t.Errorf("expected bootstrap command %q, got %q", expectedCmdOverride, output)
}
}
func TestTempFileCLIWorkflowConfigInitNodeRegisterConfigCheck(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
t.Chdir(dir)
// 1. config init
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"config", "init", "--output", cfgPath})
if err := root.Execute(); err != nil {
t.Fatalf("execute init: %v\n%s", err, out.String())
}
// 2. node register
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"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", "--token", "test-token"})
if err := root.Execute(); err != nil {
t.Fatalf("execute register: %v\n%s", err, out.String())
}
output := out.String()
expectedCmd := "curl -fsSL http://toki-labs.com:18080/bootstrap/node-darwin-arm64.sh | bash -s test-token"
if !strings.Contains(output, expectedCmd) {
t.Errorf("expected bootstrap command %q, got %q", expectedCmd, output)
}
// 3. config check
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"config", "check"})
if err := root.Execute(); err != nil {
t.Fatalf("execute check: %v\n%s", err, out.String())
}
if !strings.Contains(out.String(), "OK edge.yaml") {
t.Fatalf("expected check OK, got %q", out.String())
}
}
func TestNodeRegisterPreservesDefaultedNonNodeConfig(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
t.Chdir(dir)
// Write minimal config with server.listen and openai.enabled, omitting other defaults
yamlStr := `server:
listen: "0.0.0.0:9090"
openai:
enabled: true
`
if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil {
t.Fatalf("write yaml: %v", err)
}
// Run node register node-a
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "node", "register", "node-a", "--token", "token-a"})
if err := root.Execute(); err != nil {
t.Fatalf("execute register: %v\n%s", err, out.String())
}
// 1. Assert original YAML structure still omits the defaulted fields (preserves raw layout)
rawBytes, err := os.ReadFile(cfgPath)
if err != nil {
t.Fatalf("read raw config: %v", err)
}
rawStr := string(rawBytes)
badSubstrings := []string{
"openai:\n listen:",
"openai:\n listen:",
"openai:\n adapter:",
"openai:\n adapter:",
"openai:\n session_id:",
"openai:\n session_id:",
"openai:\n timeout_sec:",
"openai:\n timeout_sec:",
}
for _, sub := range badSubstrings {
// Replace standard newlines with system newlines just in case, but standard is fine.
if strings.Contains(rawStr, sub) || strings.Contains(rawStr, strings.ReplaceAll(sub, "\n", "\r\n")) {
t.Errorf("expected raw edge.yaml to still omit default field, but found explicit %q in YAML:\n%s", sub, rawStr)
}
}
// 2. Assert effective OpenAI defaults are resolved correctly (not zeroed out)
cfgPtr, err := config.LoadEdge(cfgPath)
if err != nil {
t.Fatalf("load effective config: %v", err)
}
cfg := *cfgPtr
if cfg.OpenAI.Listen != "0.0.0.0:8080" {
t.Errorf("expected effective openai.listen to remain 0.0.0.0:8080, got %q", cfg.OpenAI.Listen)
}
if cfg.OpenAI.Adapter != "ollama" {
t.Errorf("expected effective openai.adapter to remain ollama, got %q", cfg.OpenAI.Adapter)
}
if cfg.OpenAI.SessionID != "openai" {
t.Errorf("expected effective openai.session_id to remain openai, got %q", cfg.OpenAI.SessionID)
}
if cfg.OpenAI.TimeoutSec != 120 {
t.Errorf("expected effective openai.timeout_sec to remain 120, got %d", cfg.OpenAI.TimeoutSec)
}
if !cfg.OpenAI.StrictOutput {
t.Errorf("expected effective openai.strict_output to remain true")
}
// 3. Assert nodes[] has the new node and config check passes
if len(cfg.Nodes) != 1 {
t.Fatalf("expected exactly 1 node record, got %d", len(cfg.Nodes))
}
if cfg.Nodes[0].ID != "node-a" || cfg.Nodes[0].Token != "token-a" {
t.Fatalf("unexpected registered node: %+v", cfg.Nodes[0])
}
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"config", "check", "--config", cfgPath})
if err := root.Execute(); err != nil {
t.Fatalf("config check failed: %v\n%s", err, out.String())
}
}