From 62c28d7f1cb32b0a1dc44c61f0254ec0401eb46d Mon Sep 17 00:00:00 2001 From: toki Date: Thu, 28 May 2026 09:27:34 +0900 Subject: [PATCH] update roadmap and edge app files --- .../edge-local-dev-config-runtime.md | 22 ++-- agent-roadmap/current.md | 4 +- .../phase/control-plane-portal-ops/PHASE.md | 4 +- apps/edge/cmd/edge/main.go | 75 ++++++++++--- apps/edge/cmd/edge/main_test.go | 100 +++++++++++++++--- 5 files changed, 159 insertions(+), 46 deletions(-) rename agent-roadmap/{ => archive}/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md (92%) diff --git a/agent-roadmap/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md b/agent-roadmap/archive/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md similarity index 92% rename from agent-roadmap/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md rename to agent-roadmap/archive/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md index e6a9f3b..61593a2 100644 --- a/agent-roadmap/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md +++ b/agent-roadmap/archive/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md @@ -14,7 +14,7 @@ Edge/Node 바이너리는 repo checkout 위치에 묶이지 않고, 바이너리 ## 상태 -[진행중] +[완료] ## 승격 조건 @@ -48,24 +48,26 @@ Control Plane이 붙기 전까지 Edge 바이너리와 `edge.yaml`만으로 개 - [x] [config-discovery] `iop-edge`는 `--config`가 없을 때 repo 상대 `configs/edge.yaml`에 의존하지 않고, 바이너리와 같은 폴더의 `edge.yaml` 같은 배포용 기본 config 후보를 우선 사용할 수 있다. 검증: 임시 폴더에 `iop-edge`와 `edge.yaml`만 둔 상태에서 `iop-edge config check`와 `iop-edge serve`가 동작한다. - [x] [config-template] `iop-edge`는 배포용 `edge.yaml` 템플릿을 stdout 또는 바이너리 옆 파일로 생성하는 command를 제공한다. 검증: 빈 임시 폴더에서 `iop-edge`만으로 템플릿을 생성하고, 생성된 파일이 `iop-edge config check`를 통과한다. - [x] [env-report] `iop-edge`는 현재 사용할 config path, binary dir, listen 주소, advertise host 판별 결과, Node transport 주소, artifact/bootstrap 주소, OpenAI-compatible 주소, configured/connected node 요약을 출력할 수 있다. 검증: `iop-edge` 단일 command 출력만 보고 사용자가 Cline 주소와 Node bootstrap 주소를 알 수 있다. -- [ ] [advertise-host] `edge.yaml`의 advertise host 기본값은 빈 값이며, 비어 있으면 host 기준 IP/host를 자동 판별하고, 값이 있으면 자동 판별보다 설정값을 우선한다. 검증: 빈 값/명시 값 각각에서 bootstrap command와 environment 출력의 host가 기대대로 달라진다. +- [x] [advertise-host] `edge.yaml`의 advertise host 기본값은 빈 값이며, 비어 있으면 host 기준 IP/host를 자동 판별하고, 값이 있으면 자동 판별보다 설정값을 우선한다. 검증: 빈 값/명시 값 각각에서 bootstrap command와 environment 출력의 host가 기대대로 달라진다. - [x] [log-defaults] `iop-edge`는 로컬/dev bundle에서 로그 경로가 비어 있으면 바이너리 폴더 아래의 log 디렉터리를 자동 사용하고, `env`/help 출력에 실제 로그 위치를 보여준다. 검증: clean temp dir에서 실행하면 `/logs/` 아래에 edge 로그가 남고, 설정값이 있으면 명시 로그 경로를 따른다. - [x] [edge-source] `edge.yaml`은 Control Plane 없는 개발 단계의 source of truth로서 `nodes[]`에 Node id, alias, token, adapter, runtime 설정을 담는다. 검증: `iop-edge config check`가 `nodes[]` token/id/alias 충돌과 adapter/runtime 설정 오류를 잡는다. - [x] [node-register] `iop-edge node register `는 Node 정의와 Node별 adapter/runtime 설정을 flag/default로 받아 `edge.yaml`의 `nodes[]`에 생성 또는 갱신하고, 실행 직후 해당 Node host에서 사용할 완성된 bootstrap 명령을 출력한다. 검증: 빈 템플릿에서 `iop-edge node register node-silicon-ollama` 실행 후 `edge.yaml`에 node record가 생기고 출력에는 사용자가 실행할 한 줄 bootstrap만 남는다. -- [ ] [help-discovery] `iop-edge help`와 관련 하위 command help는 사용자가 문서를 열지 않아도 `config init`, `config check`, environment 출력, Node register/bootstrap 출력, `serve`, smoke test의 순서와 필수/선택 인자를 알 수 있게 설명한다. 검증: `iop-edge help`, `iop-edge config --help`, `iop-edge node --help`, `iop-edge node register --help`, `iop-edge smoke --help` 출력에 이 Milestone의 공식 사용자 경로가 드러난다. +- [x] [help-discovery] `iop-edge help`와 관련 하위 command help는 사용자가 문서를 열지 않아도 `config init`, `config check`, environment 출력, Node register/bootstrap 출력, `serve`, smoke test의 순서와 필수/선택 인자를 알 수 있게 설명한다. 검증: `iop-edge help`, `iop-edge config --help`, `iop-edge node --help`, `iop-edge node register --help`, `iop-edge smoke --help` 출력에 이 Milestone의 공식 사용자 경로가 드러난다. - [x] [edge-commands] Node 등록 관리, bootstrap command 발급, local/dev setup, 실행 전 config 확인, smoke test 진입점은 공식 사용자 경로에서 `iop-edge` command로 제공된다. 검증: 문서와 help output에서 `bin/*`, 별도 dev deploy 바이너리, 모델용 skill이 공식 경로로 등장하지 않는다. - [x] [node-hidden-config] Node 사용자 UX는 Edge가 제시한 bootstrap 한 줄로 끝나며 `node.yaml` 생성, 편집, 확인 또는 `iop-node serve --config ...` 직접 실행을 요구하지 않는다. 검증: 사용자용 dev/field 가이드에 node config 파일 조작 단계가 없다. -- [ ] [bundle-run] Edge/Node dev bundle은 repo checkout 위치와 무관하게 바이너리 옆 config/artifact만으로 실행 가능하다. 검증: clean temp dir 또는 개발 서버 배포 디렉터리에서 Edge 실행, Node bootstrap, Node registration, `/v1/models` smoke가 통과한다. +- [x] [bundle-run] Edge/Node dev bundle은 repo checkout 위치와 무관하게 바이너리 옆 config/artifact만으로 실행 가능하다. 검증: clean temp dir 또는 개발 서버 배포 디렉터리에서 Edge 실행, Node bootstrap, Node registration, `/v1/models` smoke가 통과한다. ## 완료 리뷰 -- 상태: 없음 -- 요청일: 없음 -- 완료 근거: `config-discovery`, `config-template`, `env-report`, `log-defaults`, `edge-source`, `node-register`, `edge-commands`, `node-hidden-config`는 코드/문서/테스트 근거가 확인되었다. 남은 기능 Task와 Task 안의 검증이 아직 모두 충족되지는 않았다. +- 상태: 승인됨 +- 요청일: 2026-05-28 +- 완료 근거: 모든 기능 Task와 Task 안의 검증이 충족되었다. +- 완료 근거: `go test ./...`가 통과했고, `apps/edge/cmd/edge` 테스트가 advertise host 기반 bootstrap URL, 하위 help discovery, config/node/smoke 흐름을 검증한다. +- 완료 근거: clean temp bundle에서 field binary build, Edge serve, artifact HTTP, Node bootstrap registration, `/v1/models` 확인이 통과했다. - 리뷰 필요: - - [ ] 사용자가 완료 결과를 확인했다 - - [ ] archive 이동을 승인했다 -- 리뷰 코멘트: `advertise-host`, `help-discovery`, `bundle-run` 검증이 남아 있어 완료 리뷰 요청 전이다. + - [x] 사용자가 완료 결과를 확인했다 + - [x] archive 이동을 승인했다 +- 리뷰 코멘트: 2026-05-28 사용자 요청으로 완료 처리하고 archive로 이동했다. ## 범위 제외 diff --git a/agent-roadmap/current.md b/agent-roadmap/current.md index 0edbc71..e6cd2f4 100644 --- a/agent-roadmap/current.md +++ b/agent-roadmap/current.md @@ -7,9 +7,9 @@ ## 활성 Milestone -- [진행중] Edge-local Dev Config와 바이너리 중심 실행 루프 +- [계획] Client Integration Clone Adoption - Phase: `agent-roadmap/phase/control-plane-portal-ops/PHASE.md` - - 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md` + - 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/client-integration-clone-adoption.md` ## 선택 규칙 diff --git a/agent-roadmap/phase/control-plane-portal-ops/PHASE.md b/agent-roadmap/phase/control-plane-portal-ops/PHASE.md index 2ff74d5..7ce65ef 100644 --- a/agent-roadmap/phase/control-plane-portal-ops/PHASE.md +++ b/agent-roadmap/phase/control-plane-portal-ops/PHASE.md @@ -17,8 +17,8 @@ - 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/flutter-first-portal-migration.md` - 요약: Next.js `apps/web` scaffold를 제거하고 `apps/portal` Flutter 앱, Flutter Web compose 배포 흐름, Portal-Control Plane proto-socket WS hello 기반을 세웠다. -- [진행중] Edge-local Dev Config와 바이너리 중심 실행 루프 - - 경로: `agent-roadmap/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md` +- [완료] Edge-local Dev Config와 바이너리 중심 실행 루프 + - 경로: `agent-roadmap/archive/phase/control-plane-portal-ops/milestones/edge-local-dev-config-runtime.md` - 요약: Control Plane이 없는 동안 `edge.yaml`을 테스트용 source of truth로 삼고, config 템플릿 생성, effective environment 출력, Node register/bootstrap 출력, setup, smoke test를 `iop-edge` command 중심으로 모은다. - [계획] Client Integration Clone Adoption diff --git a/apps/edge/cmd/edge/main.go b/apps/edge/cmd/edge/main.go index 3a5715e..430c258 100644 --- a/apps/edge/cmd/edge/main.go +++ b/apps/edge/cmd/edge/main.go @@ -197,6 +197,18 @@ func configCmd() *cobra.Command { c := &cobra.Command{ Use: "config", Short: "Create, inspect, and validate edge config", + Long: `Create, inspect, and validate the bundle-local edge.yaml configuration. + +Typical local flow: + 1. iop-edge config init + 2. iop-edge env + 3. iop-edge node register + 4. iop-edge config check + 5. iop-edge serve`, + Example: ` iop-edge config init + iop-edge env + iop-edge node register node-silicon-ollama --adapter ollama + iop-edge config check`, } c.AddCommand(configInitCmd(), configPrintCmd(), configCheckCmd()) return c @@ -361,6 +373,14 @@ func addressFor(host, listen string) string { return net.JoinHostPort(host, port) } +func defaultArtifactBaseURL(host string) string { + return "http://" + addressFor(host, "0.0.0.0:18080") +} + +func bootstrapScriptURL(baseURL, target string) string { + return strings.TrimSuffix(baseURL, "/") + "/bootstrap/node-" + target + ".sh" +} + type envReport struct { ConfigPath string BinaryDir string @@ -374,16 +394,20 @@ type envReport struct { } func renderEnv(w io.Writer, r envReport) { + artifactURL := strings.TrimSuffix(r.Bootstrap.ArtifactBaseURL, "/") + artifactSource := "configured" + if artifactURL == "" { + artifactURL = defaultArtifactBaseURL(r.Advertise.Host) + artifactSource = "derived" + } + fmt.Fprintf(w, "config: %s\n", r.ConfigPath) fmt.Fprintf(w, "binary_dir: %s\n", r.BinaryDir) fmt.Fprintf(w, "log_path: %s\n", r.LogPath) fmt.Fprintf(w, "advertise_host: %s (%s)\n", r.Advertise.Host, r.Advertise.Source) fmt.Fprintf(w, "node_transport: %s\n", addressFor(r.Advertise.Host, r.Server.Listen)) - if r.Bootstrap.ArtifactBaseURL != "" { - fmt.Fprintf(w, "artifact_base_url: %s\n", r.Bootstrap.ArtifactBaseURL) - } else { - fmt.Fprintln(w, "artifact_base_url: (unset)") - } + fmt.Fprintf(w, "artifact_base_url: %s (%s)\n", artifactURL, artifactSource) + fmt.Fprintf(w, "node_bootstrap: %s\n", bootstrapScriptURL(artifactURL, runtime.GOOS+"-"+runtime.GOARCH)) if r.OpenAI.Enabled { fmt.Fprintf(w, "openai_url: http://%s/v1\n", addressFor(r.Advertise.Host, r.OpenAI.Listen)) } else { @@ -498,6 +522,14 @@ func nodeCmd() *cobra.Command { c := &cobra.Command{ Use: "node", Short: "Manage edge nodes", + Long: `Manage node records stored in edge.yaml. + +Use node register to create or update the Node definition and print the +one-line bootstrap command for the target host. After registration, run +iop-edge config check and then start iop-edge serve.`, + Example: ` iop-edge node register node-silicon-ollama --adapter ollama + iop-edge config check + iop-edge serve`, } c.AddCommand(nodeRegisterCmd()) return c @@ -507,7 +539,16 @@ func nodeRegisterCmd() *cobra.Command { c := &cobra.Command{ Use: "register [node-id]", Short: "Register or update a node record", - Args: cobra.ExactArgs(1), + Long: `Register or update a node record in edge.yaml and print a one-line bootstrap command. + +The command writes the node id, alias, token, adapter, and runtime settings into +edge.yaml. The printed bootstrap command is the user-facing command to run on +the Node host; the user should not create node.yaml or run iop-node serve +directly in the default flow.`, + Example: ` iop-edge node register node-silicon-ollama --adapter ollama --ollama-base-url http://127.0.0.1:11434 + iop-edge node register node-linux-cli --adapter cli --target linux-amd64 + iop-edge config check`, + Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { nodeID := args[0] @@ -661,7 +702,7 @@ func nodeRegisterCmd() *cobra.Command { cfg.Bootstrap.ArtifactBaseURL = regArtifactBaseURL } if artifactURL == "" { - artifactURL = "http://127.0.0.1:18080" // fallback default + artifactURL = defaultArtifactBaseURL(resolveAdvertiseHost(cfg.Server.AdvertiseHost).Host) } // Validate mutated config before writing @@ -678,15 +719,8 @@ func nodeRegisterCmd() *cobra.Command { 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) + bootstrapURL := bootstrapScriptURL(artifactURL, target) fmt.Fprintf(cmd.OutOrStdout(), "curl -fsSL %s | bash -s %s\n", bootstrapURL, nodeDef.Token) return nil @@ -840,7 +874,15 @@ func smokeCmd() *cobra.Command { c := &cobra.Command{ Use: "smoke", Short: "Run verification and smoke tests", - Long: `Run diagnostic smoke tests to verify the connectivity and functionality of IOP Edge components.`, + Long: `Run diagnostic smoke tests to verify the connectivity and functionality of IOP Edge components. + +Typical local flow: + 1. iop-edge config init + 2. iop-edge node register + 3. iop-edge serve + 4. iop-edge smoke openai --model `, + Example: ` iop-edge smoke openai --model gemma4:26b + iop-edge smoke openai --model gemma4:26b --base-url http://127.0.0.1:8080`, } c.AddCommand(smokeOpenAICmd()) return c @@ -1064,4 +1106,3 @@ If --base-url is not provided, it auto-discovers the OpenAI URL from the effecti return c } - diff --git a/apps/edge/cmd/edge/main_test.go b/apps/edge/cmd/edge/main_test.go index 4815164..18d7b85 100644 --- a/apps/edge/cmd/edge/main_test.go +++ b/apps/edge/cmd/edge/main_test.go @@ -357,8 +357,13 @@ func TestEnvCmdMarksAutoAdvertiseHostAndOfflineStateForEmptyConfig(t *testing.T) 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) + expectedArtifact := "artifact_base_url: " + defaultArtifactBaseURL(resolveAdvertiseHost("").Host) + " (derived)" + if !strings.Contains(got, expectedArtifact) { + t.Errorf("expected derived artifact_base_url %q, got:\n%s", expectedArtifact, got) + } + expectedBootstrap := "node_bootstrap: " + bootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host), runtime.GOOS+"-"+runtime.GOARCH) + if !strings.Contains(got, expectedBootstrap) { + t.Errorf("expected node_bootstrap %q, got:\n%s", expectedBootstrap, got) } if !strings.Contains(got, "openai_url: (disabled)") { t.Errorf("expected openai_url (disabled), got:\n%s", got) @@ -529,13 +534,10 @@ func TestNodeRegisterUpsertsYAMLAndOutputsBootstrap(t *testing.T) { } 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) + expectedCmd := "curl -fsSL " + bootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host), runtime.GOOS+"-"+runtime.GOARCH) + " | bash -s mocked-token-1234" + if strings.TrimSpace(output) != expectedCmd { + t.Errorf("expected only bootstrap command %q, got %q", expectedCmd, output) } // Read and parse persisted YAML @@ -567,12 +569,9 @@ func TestNodeRegisterUpsertsYAMLAndOutputsBootstrap(t *testing.T) { } 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) + expectedCmd2 := "curl -fsSL " + bootstrapScriptURL(defaultArtifactBaseURL(resolveAdvertiseHost("").Host), runtime.GOOS+"-"+runtime.GOARCH) + " | bash -s explicit-token-abc" + if strings.TrimSpace(output) != expectedCmd2 { + t.Errorf("expected only bootstrap command %q, got %q", expectedCmd2, output) } // Read and parse persisted YAML again @@ -763,6 +762,35 @@ bootstrap: } } +func TestNodeRegisterUsesAdvertiseHostForDerivedArtifactBaseURL(t *testing.T) { + dir := t.TempDir() + cfgPath := filepath.Join(dir, "edge.yaml") + + yamlStr := `server: + listen: "0.0.0.0:9090" + advertise_host: "edge.example.test" +bootstrap: + artifact_base_url: "" +` + 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", "linux-amd64", "--token", "test-token"}) + if err := root.Execute(); err != nil { + t.Fatalf("execute register: %v\n%s", err, out.String()) + } + + expectedCmd := "curl -fsSL http://edge.example.test:18080/bootstrap/node-linux-amd64.sh | bash -s test-token" + if !strings.Contains(out.String(), expectedCmd) { + t.Errorf("expected bootstrap command %q, got %q", expectedCmd, out.String()) + } +} + func TestTempFileCLIWorkflowConfigInitNodeRegisterConfigCheck(t *testing.T) { dir := t.TempDir() cfgPath := filepath.Join(dir, "edge.yaml") @@ -931,6 +959,49 @@ func TestHelpDiscoversEdgeLocalDevFlow(t *testing.T) { t.Errorf("root help contains legacy path reference: %q", unwanted) } } + + helpCases := []struct { + name string + args []string + want []string + }{ + { + name: "config", + args: []string{"config", "--help"}, + want: []string{"config init", "iop-edge env", "node register", "config check", "serve"}, + }, + { + name: "node", + args: []string{"node", "--help"}, + want: []string{"node register", "edge.yaml", "one-line bootstrap command", "config check"}, + }, + { + name: "node register", + args: []string{"node", "register", "--help"}, + want: []string{"one-line bootstrap command", "node.yaml", "iop-node serve", "--adapter", "--target"}, + }, + { + name: "smoke", + args: []string{"smoke", "--help"}, + want: []string{"smoke openai", "config init", "node register", "serve"}, + }, + } + for _, tc := range helpCases { + root := rootCmd() + out.Reset() + root.SetOut(&out) + root.SetErr(&out) + root.SetArgs(tc.args) + if err := root.Execute(); err != nil { + t.Fatalf("%s help execute failed: %v", tc.name, err) + } + got := out.String() + for _, w := range tc.want { + if !strings.Contains(got, w) { + t.Errorf("%s help output missing %q\n---\n%s", tc.name, w, got) + } + } + } } func TestNodesListCommand(t *testing.T) { @@ -1103,4 +1174,3 @@ func TestSmokeOpenAICommandFailure(t *testing.T) { t.Errorf("unexpected error message: %v", err3) } } -