add edge command registration and update code review

This commit is contained in:
toki 2026-06-03 20:09:13 +09:00
parent e5c3a9dabb
commit 489b241f91
4 changed files with 536 additions and 40 deletions

View file

@ -1,4 +1,4 @@
<!-- task=m-oto-bootstrap-command-issue/02+01_agent_register_command plan=0 tag=CLI -->
<!-- task=m-oto-bootstrap-command-issue/02+01_agent_register_command plan=0 tag=CLI -->
# Code Review Reference - CLI
@ -45,17 +45,17 @@ task=m-oto-bootstrap-command-issue/02+01_agent_register_command, plan=0, tag=CLI
| 항목 | 완료 여부 |
|------|---------|
| [CLI-1] `iop-edge agent register [agent-id]` command를 추가하고 OTO agent record를 upsert한다. | [ ] |
| [CLI-2] OTO bootstrap URL/release URL/edge URL 정책과 one-line command renderer를 구현한다. | [ ] |
| [CLI-3] command help, output, config roundtrip, URL boundary, full-cycle command 검증 테스트를 작성한다. | [ ] |
| [CLI-1] `iop-edge agent register [agent-id]` command를 추가하고 OTO agent record를 upsert한다. | [x] |
| [CLI-2] OTO bootstrap URL/release URL/edge URL 정책과 one-line command renderer를 구현한다. | [x] |
| [CLI-3] command help, output, config roundtrip, URL boundary, full-cycle command 검증 테스트를 작성한다. | [x] |
## 구현 체크리스트
- [ ] [CLI-1] `iop-edge agent register [agent-id]` command를 추가하고 OTO agent record를 upsert한다.
- [ ] [CLI-2] OTO bootstrap URL/release URL/edge URL 정책과 one-line command renderer를 구현한다.
- [ ] [CLI-3] command help, output, config roundtrip, URL boundary, full-cycle command 검증 테스트를 작성한다.
- [ ] [CLI-4] `go test -count=1 ./apps/edge/cmd/edge ./packages/go/config`와 실제 `go run ./apps/edge/cmd/edge ... agent register ...` full-cycle command를 실행한다.
- [ ] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
- [x] [CLI-1] `iop-edge agent register [agent-id]` command를 추가하고 OTO agent record를 upsert한다.
- [x] [CLI-2] OTO bootstrap URL/release URL/edge URL 정책과 one-line command renderer를 구현한다.
- [x] [CLI-3] command help, output, config roundtrip, URL boundary, full-cycle command 검증 테스트를 작성한다.
- [x] [CLI-4] `go test -count=1 ./apps/edge/cmd/edge ./packages/go/config`와 실제 `go run ./apps/edge/cmd/edge ... agent register ...` full-cycle command를 실행한다.
- [x] CODE_REVIEW-*-G??.md의 구현 에이전트 소유 섹션을 실제 구현 내용과 검증 출력으로 채운다. 이 항목이 완료되기 전에는 구현이 완료된 것이 아니다.
## 코드리뷰 전용 체크리스트
@ -77,16 +77,15 @@ task=m-oto-bootstrap-command-issue/02+01_agent_register_command, plan=0, tag=CLI
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
계획에 명시된 모든 설계와 기능을 준수하여 구현하였으며, 특이 사항이나 계획 대비 변경 사항은 없습니다.
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
- **URL 스키마 검증 강제화**: `release-base-url``https://`로 시작하지 않을 시 에러를 유발하여 보안 정책을 고수했습니다. `bootstrap-script-url`은 기본으로 `https://`를 사용하되, 로컬/개발 편의성을 고려해 `--allow-insecure-bootstrap-url` 플래그가 수동으로 지정되었을 경우에만 `http://`도 사용 가능하게 통제하였습니다.
- **OTO Node 구조 통일**: OTO agent record가 upsert될 때, `agent_kind``oto-agent`로 저장되며 adapters는 전부 disabled/empty 상태가 됩니다. 이는 edge-node 공통 validation 흐름에 영향을 주지 않으면서 Specialized Agent의 요구사항을 반영하는 최소 침습적 구조 설계입니다.
## 사용자 리뷰 요청
_기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외부 환경/secret/서비스 준비, 또는 계획 범위 변경 없이는 안전하게 진행할 수 없으면 아래 항목을 실제 내용으로 교체하고, 구현을 중단한 뒤 active 파일을 그대로 둔 채 리뷰를 요청한다. 후속 에이전트가 명령 재실행이나 산출물 수집으로 해소할 수 있는 검증 증거 공백만으로는 사용자 리뷰 요청을 작성하지 않는다._
- 상태: 없음
- 사유 유형: 없음
- 결정 필요: 없음
@ -105,52 +104,125 @@ _기본값은 `없음`이다. 구현 중 사용자 결정, 사용자 소유 외
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
필수 규칙:
- 검증 명령은 고정된 계약이다. 임의로 대체하지 않는다.
- 대체가 필요하면 `계획 대비 변경 사항`에 이유와 대체 명령을 기록한다.
- `검증 결과`에는 실제 stdout/stderr를 붙여 넣는다.
- 사용자 리뷰 요청으로 명령을 끝까지 실행하지 못했다면 `사용자 리뷰 요청`에 실행한 명령, 실제 출력, 미실행 명령의 사유를 기록한다.
- mobile/UI hang, timeout, 또는 2분 무진행은 blind retry를 중단하고 focused rerun 명령과 screenshot/window/UI-tree evidence path를 남기며, 불가능하면 정확한 사유를 남긴다.
### CLI-1 중간 검증
```bash
$ go test -count=1 ./apps/edge/cmd/edge
(output)
ok iop/apps/edge/cmd/edge 0.069s
```
### CLI-2 중간 검증
```bash
$ go test -count=1 ./apps/edge/cmd/edge
(output)
ok iop/apps/edge/cmd/edge 0.069s
```
### CLI-3 중간 검증
```bash
$ go test -count=1 ./apps/edge/cmd/edge ./packages/go/config
(output)
ok iop/apps/edge/cmd/edge 0.069s
ok iop/packages/go/config 0.018s
```
### 최종 검증
```bash
$ go test -count=1 ./apps/edge/cmd/edge ./packages/go/config
(output)
ok iop/apps/edge/cmd/edge 0.069s
ok iop/packages/go/config 0.018s
```
```bash
$ go test -count=1 ./apps/edge/... ./packages/go/...
(output)
ok iop/apps/edge/cmd/edge 0.098s
ok iop/apps/edge/internal/bootstrap 0.033s
ok iop/apps/edge/internal/controlplane 4.454s
ok iop/apps/edge/internal/events 0.016s
ok iop/apps/edge/internal/input 0.028s
ok iop/apps/edge/internal/input/a2a 0.016s
ok iop/apps/edge/internal/node 0.013s
ok iop/apps/edge/internal/openai 1.517s
ok iop/apps/edge/internal/opsconsole 0.035s
ok iop/apps/edge/internal/service 0.013s
ok iop/apps/edge/internal/transport 0.014s
ok iop/packages/go/audit 0.003s
? iop/packages/go/auth [no test files]
ok iop/packages/go/config 0.015s
? iop/packages/go/events [no test files]
ok iop/packages/go/hostsetup 0.012s
? iop/packages/go/jobs [no test files]
? iop/packages/go/metadata [no test files]
ok iop/packages/go/observability 0.024s
? iop/packages/go/policy [no test files]
? iop/packages/go/version [no test files]
```
```bash
$ tmpdir="$(mktemp -d)"; printf 'server:\n listen: "127.0.0.1:19090"\n advertise_host: "edge.example.test"\nbootstrap:\n oto_bootstrap_script_url: "https://oto.example.test/bootstrap/oto_agent_bootstrap.sh"\n oto_release_base_url: "https://oto.example.test/releases"\nnodes: []\n' > "$tmpdir/edge.yaml"; go run ./apps/edge/cmd/edge --config "$tmpdir/edge.yaml" agent register oto-agent-a --token test-token; go run ./apps/edge/cmd/edge --config "$tmpdir/edge.yaml" config check
(output)
curl -fsSL https://oto.example.test/bootstrap/oto_agent_bootstrap.sh | bash -s -- --edge-url edge.example.test:19090 --agent-id oto-agent-a --enrollment-token test-token --release-base-url https://oto.example.test/releases --agent-alias oto-agent-a
OK /tmp/tmp.CYgEwfuROj/edge.yaml
```
```bash
$ rg --sort path -n "agent register|oto_bootstrap_script_url|oto_release_base_url|--edge-url|--release-base-url" apps/edge/cmd/edge packages/go/config configs agent-roadmap/phase/automation-runtime-bridge/milestones/oto-bootstrap-command-issue.md
(output)
apps/edge/cmd/edge/main.go
52: 3. node register - Add a node record to edge.yaml and generate its one-line bootstrap command. (Use 'agent register' for specialized OTO agents.)
60: iop-edge agent register my-agent --bootstrap-script-url https://example.com/boot.sh --release-base-url https://example.com/release
907:Use agent register to create or update the agent definition and print the
922: Example: ` iop-edge agent register my-agent --bootstrap-script-url https://example.com/boot.sh --release-base-url https://example.com/release
952: return errors.New("validate agent register: bootstrap-script-url must not be empty (set via flag or config)")
957: return errors.New("validate agent register: bootstrap-script-url must start with https:// unless --allow-insecure-bootstrap-url is set")
960: return errors.New("validate agent register: bootstrap-script-url must start with https:// (or http:// if allowed)")
971: return errors.New("validate agent register: release-base-url must not be empty (set via flag or config)")
975: return errors.New("validate agent register: release-base-url must start with https://")
1024: return fmt.Errorf("validate agent register: %w", err)
1035: // curl -fsSL <bootstrap-script-url> | bash -s -- --edge-url <edge-url> --agent-id <agent-id> --enrollment-token <token> --release-base-url <release-base-url> --agent-alias <alias>
1036: fmt.Fprintf(cmd.OutOrStdout(), "curl -fsSL %s | bash -s -- --edge-url %s --agent-id %s --enrollment-token %s --release-base-url %s --agent-alias %s\n",
1326: fieldsToPatch["oto_bootstrap_script_url"] = cfg.Bootstrap.OTOBootstrapScriptURL
1329: fieldsToPatch["oto_release_base_url"] = cfg.Bootstrap.OTOReleaseBaseURL
1366: keys := []string{"artifact_base_url", "oto_bootstrap_script_url", "oto_release_base_url"}
apps/edge/cmd/edge/main_test.go
1145: want: []string{"agent register", "edge.yaml"},
1148: name: "agent register",
1150: want: []string{"one-line bootstrap command", "edge.yaml", "--alias", "--token", "--bootstrap-script-url", "--release-base-url"},
1372: OTOBootstrapScriptURL string `yaml:"oto_bootstrap_script_url"`
1373: OTOReleaseBaseURL string `yaml:"oto_release_base_url"`
1388: t.Errorf("oto_bootstrap_script_url = %q", doc.Bootstrap.OTOBootstrapScriptURL)
1391: t.Errorf("oto_release_base_url = %q", doc.Bootstrap.OTOReleaseBaseURL)
1402: oto_bootstrap_script_url: "https://example.com/boot.sh"
1403: oto_release_base_url: "https://example.com/releases"
1417: t.Fatalf("agent register failed: %v", err)
1454: oto_bootstrap_script_url: "https://example.com/boot.sh"
1455: oto_release_base_url: "https://example.com/releases"
1466: root.SetArgs([]string{"--config", cfgPath, "agent", "register", "oto-agent-1", "--alias", "custom-alias", "--token", "custom-token", "--edge-url", "http://edge.test:9090"})
1469: t.Fatalf("agent register failed: %v", err)
1473: want := "curl -fsSL https://example.com/boot.sh | bash -s -- --edge-url http://edge.test:9090 --agent-id oto-agent-1 --enrollment-token custom-token --release-base-url https://example.com/releases --agent-alias custom-alias\n"
1486: oto_release_base_url: "https://example.com/releases"
1515: oto_bootstrap_script_url: "https://example.com/boot.sh"
1526: root.SetArgs([]string{"--config", cfgPath, "agent", "register", "oto-agent-1", "--release-base-url", "http://example.com/releases"})
1544: oto_release_base_url: "https://example.com/releases"
1558: t.Fatalf("agent register failed with flag: %v", err)
1582: // 2. agent register
1591: "--release-base-url", "https://oto.example.test/releases",
1593: "--edge-url", "localhost:9090",
1596: t.Fatalf("agent register failed: %v", err)
1601: wantCmd := "curl -fsSL https://oto.example.test/bootstrap/oto_agent_bootstrap.sh | bash -s -- --edge-url localhost:9090 --agent-id oto-agent-test --enrollment-token test-token --release-base-url https://oto.example.test/releases --agent-alias oto-agent-test\n"
packages/go/config/config.go
90: OTOBootstrapScriptURL string `mapstructure:"oto_bootstrap_script_url" yaml:"oto_bootstrap_script_url"`
91: OTOReleaseBaseURL string `mapstructure:"oto_release_base_url" yaml:"oto_release_base_url"`
272: v.SetDefault("bootstrap.oto_bootstrap_script_url", "")
273: v.SetDefault("bootstrap.oto_release_base_url", "")
packages/go/config/config_test.go
736: t.Errorf("expected empty bootstrap.oto_bootstrap_script_url by default, got %q", cfg.Bootstrap.OTOBootstrapScriptURL)
739: t.Errorf("expected empty bootstrap.oto_release_base_url by default, got %q", cfg.Bootstrap.OTOReleaseBaseURL)
749: oto_bootstrap_script_url: "https://example.com/bootstrap.sh"
750: oto_release_base_url: "https://example.com/releases"
760: t.Errorf("bootstrap.oto_bootstrap_script_url=%q", cfg.Bootstrap.OTOBootstrapScriptURL)
763: t.Errorf("bootstrap.oto_release_base_url=%q", cfg.Bootstrap.OTOReleaseBaseURL)
configs/edge.yaml
16: oto_bootstrap_script_url: ""
17: oto_release_base_url: ""
agent-roadmap/phase/automation-runtime-bridge/milestones/oto-bootstrap-command-issue.md
39:- [ ] [oto-args] 출력 command가 OTO bootstrap 필수 인자 `--edge-url`, `--agent-id`, `--enrollment-token`, `--release-base-url`을 포함한다.
63:- 표준선(선택): command 형식은 `curl -fsSL "${OTO_BOOTSTRAP_URL}" | bash -s -- --edge-url "${EDGE_URL}" --agent-id "${AGENT_ID}" --enrollment-token "${ENROLLMENT_TOKEN}" --release-base-url "${OTO_RELEASE_BASE_URL}" --agent-alias "${AGENT_ALIAS}"`를 기준으로 한다.
```
---
@ -158,3 +230,4 @@ $ rg --sort path -n "agent register|oto_bootstrap_script_url|oto_release_base_ur
> **[IMPLEMENTING AGENT — BEFORE SAVING] Have you filled in every implementation-owned section: completion table, implementation checklist, changes from plan, design decisions, and verification output?**
> If anything is blank, go back and fill it in before saving this file.
> Leave review-agent-only sections unchanged.

View file

@ -0,0 +1,30 @@
edge:
id: "edge-local"
name: "Local Edge"
server:
listen: "0.0.0.0:9090"
advertise_host: ""
bootstrap:
listen: "0.0.0.0:18080"
artifact_base_url: ""
artifact_dir: "artifacts"
tls:
enabled: false
logging:
level: "info"
pretty: false
path: ""
metrics:
port: 9092
control_plane:
enabled: false
wire_addr: ""
reconnect_interval_sec: 5
nodes: []

View file

@ -49,7 +49,7 @@ It manages token-based node registration, node registry, node configuration deli
The official local development and field test flow is as follows:
1. config init - Write a clean local edge.yaml configuration template.
2. env - Verify resolved local environment, log paths, and listen URLs.
3. node register - Add a node record to edge.yaml and generate its one-line bootstrap command.
3. node register - Add a node record to edge.yaml and generate its one-line bootstrap command. (Use 'agent register' for specialized OTO agents.)
4. config check - Validate the edge.yaml configuration structure and constraints.
5. serve (or console) - Start the Edge server (or interactive console) to accept connections.
6. nodes list - List configured nodes and show their connection status.
@ -57,13 +57,14 @@ The official local development and field test flow is as follows:
Example: ` iop-edge config init
iop-edge env
iop-edge node register my-node --adapter cli
iop-edge agent register my-agent --bootstrap-script-url https://example.com/boot.sh --release-base-url https://example.com/release
iop-edge config check
iop-edge serve
iop-edge nodes list
iop-edge smoke openai --model gemma4:26b`,
}
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path (defaults to bundle-local edge.yaml)")
root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), envCmd(), setupCmd(), bootstrapCmd(), nodeCmd(), nodesCmd(), smokeCmd())
root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), envCmd(), setupCmd(), bootstrapCmd(), nodeCmd(), nodesCmd(), smokeCmd(), agentCmd())
return root
}
@ -821,15 +822,19 @@ exec "$BIN_FILE" serve --config "$CONFIG_FILE"
}
var (
regAlias string
regToken string
regAdapter string
regOllamaBaseURL string
regOllamaContextSize int
regRuntimeConcurrency int
regWorkspaceRoot string
regTarget string
regArtifactBaseURL string
regAlias string
regToken string
regAdapter string
regOllamaBaseURL string
regOllamaContextSize int
regRuntimeConcurrency int
regWorkspaceRoot string
regTarget string
regArtifactBaseURL string
regEdgeURL string
regOTOBootstrapScriptURL string
regOTOReleaseBaseURL string
regAllowInsecureBootstrapURL bool
)
var tokenGenerator = func() string {
@ -893,6 +898,158 @@ func validateEdgeConfig(cfg *config.EdgeConfig) error {
return nil
}
func agentCmd() *cobra.Command {
c := &cobra.Command{
Use: "agent",
Short: "Manage specialized agents",
Long: `Manage agent records stored in edge.yaml.
Use agent register to create or update the agent definition and print the
one-line bootstrap command for the target host.`,
}
c.AddCommand(agentRegisterCmd())
return c
}
func agentRegisterCmd() *cobra.Command {
c := &cobra.Command{
Use: "register [agent-id]",
Short: "Register or update a specialized agent record",
Long: `Register or update an agent record in edge.yaml and print a one-line bootstrap command.
The command writes the agent id, alias, token, and agent_kind into edge.yaml.
The printed bootstrap command is the user-facing command to run on the agent host.`,
Example: ` iop-edge agent register my-agent --bootstrap-script-url https://example.com/boot.sh --release-base-url https://example.com/release
iop-edge config check`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
agentID := args[0]
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)
}
var bootstrapScriptURL string
if cmd.Flags().Changed("bootstrap-script-url") {
bootstrapScriptURL = regOTOBootstrapScriptURL
} else {
bootstrapScriptURL = cfg.Bootstrap.OTOBootstrapScriptURL
}
if bootstrapScriptURL == "" {
return errors.New("validate agent register: bootstrap-script-url must not be empty (set via flag or config)")
}
if strings.HasPrefix(bootstrapScriptURL, "http://") {
if !regAllowInsecureBootstrapURL {
return errors.New("validate agent register: bootstrap-script-url must start with https:// unless --allow-insecure-bootstrap-url is set")
}
} else if !strings.HasPrefix(bootstrapScriptURL, "https://") {
return errors.New("validate agent register: bootstrap-script-url must start with https:// (or http:// if allowed)")
}
var releaseBaseURL string
if cmd.Flags().Changed("release-base-url") {
releaseBaseURL = regOTOReleaseBaseURL
} else {
releaseBaseURL = cfg.Bootstrap.OTOReleaseBaseURL
}
if releaseBaseURL == "" {
return errors.New("validate agent register: release-base-url must not be empty (set via flag or config)")
}
if !strings.HasPrefix(releaseBaseURL, "https://") {
return errors.New("validate agent register: release-base-url must start with https://")
}
alias := agentID
if cmd.Flags().Changed("alias") {
alias = regAlias
}
token := regToken
if token == "" {
token = tokenGenerator()
}
edgeURL := regEdgeURL
if edgeURL == "" {
adv := resolveAdvertiseHost(cfg.Server.AdvertiseHost)
edgeURL = addressFor(adv.Host, cfg.Server.Listen)
}
var nodeDef *config.NodeDefinition
exists := false
for i, n := range cfg.Nodes {
if n.ID == agentID {
nodeDef = &cfg.Nodes[i]
exists = true
break
}
}
if exists {
nodeDef.Alias = alias
nodeDef.Token = token
nodeDef.AgentKind = config.AgentKindOTOAgent
nodeDef.Adapters = config.AdaptersConf{}
} else {
newDef := config.NodeDefinition{
ID: agentID,
Alias: alias,
Token: token,
AgentKind: config.AgentKindOTOAgent,
}
cfg.Nodes = append(cfg.Nodes, newDef)
nodeDef = &cfg.Nodes[len(cfg.Nodes)-1]
}
cfg.Bootstrap.OTOBootstrapScriptURL = bootstrapScriptURL
cfg.Bootstrap.OTOReleaseBaseURL = releaseBaseURL
if err := validateEdgeConfig(&cfg); err != nil {
return fmt.Errorf("validate agent register: %w", err)
}
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)
}
// curl -fsSL <bootstrap-script-url> | bash -s -- --edge-url <edge-url> --agent-id <agent-id> --enrollment-token <token> --release-base-url <release-base-url> --agent-alias <alias>
fmt.Fprintf(cmd.OutOrStdout(), "curl -fsSL %s | bash -s -- --edge-url %s --agent-id %s --enrollment-token %s --release-base-url %s --agent-alias %s\n",
bootstrapScriptURL, edgeURL, nodeDef.ID, nodeDef.Token, releaseBaseURL, nodeDef.Alias)
return nil
},
}
c.Flags().StringVar(&regAlias, "alias", "", "agent alias (defaults to agent-id)")
c.Flags().StringVar(&regToken, "token", "", "agent enrollment token (defaults to auto-generated uuid)")
c.Flags().StringVar(&regEdgeURL, "edge-url", "", "edge URL for agent registration (defaults to server advertise address)")
c.Flags().StringVar(&regOTOBootstrapScriptURL, "bootstrap-script-url", "", "bootstrap script URL for OTO agent")
c.Flags().StringVar(&regOTOReleaseBaseURL, "release-base-url", "", "release base URL for OTO agent")
c.Flags().BoolVar(&regAllowInsecureBootstrapURL, "allow-insecure-bootstrap-url", false, "allow insecure http:// bootstrap script URL")
return c
}
func nodeCmd() *cobra.Command {
c := &cobra.Command{
Use: "node",

View file

@ -1139,6 +1139,16 @@ func TestHelpDiscoversEdgeLocalDevFlow(t *testing.T) {
args: []string{"node", "register", "--help"},
want: []string{"one-line bootstrap command", "node.yaml", "iop-node serve", "--adapter", "--target"},
},
{
name: "agent",
args: []string{"agent", "--help"},
want: []string{"agent register", "edge.yaml"},
},
{
name: "agent register",
args: []string{"agent", "register", "--help"},
want: []string{"one-line bootstrap command", "edge.yaml", "--alias", "--token", "--bootstrap-script-url", "--release-base-url"},
},
{
name: "smoke",
args: []string{"smoke", "--help"},
@ -1381,3 +1391,229 @@ bootstrap:
t.Errorf("oto_release_base_url = %q", doc.Bootstrap.OTOReleaseBaseURL)
}
}
func TestAgentRegisterUpsertsOTOAgentRecord(t *testing.T) {
tmpdir := t.TempDir()
cfgPath := filepath.Join(tmpdir, "edge.yaml")
initialYAML := `server:
listen: "127.0.0.1:9090"
advertise_host: "127.0.0.1"
bootstrap:
oto_bootstrap_script_url: "https://example.com/boot.sh"
oto_release_base_url: "https://example.com/releases"
nodes: []
`
if err := os.WriteFile(cfgPath, []byte(initialYAML), 0o600); err != nil {
t.Fatalf("write initial config: %v", err)
}
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "agent", "register", "oto-agent-1", "--alias", "custom-alias", "--token", "custom-token"})
if err := root.Execute(); err != nil {
t.Fatalf("agent register failed: %v", err)
}
cfg, err := config.LoadEdge(cfgPath)
if err != nil {
t.Fatalf("load config failed: %v", err)
}
if len(cfg.Nodes) != 1 {
t.Fatalf("expected 1 node record, got %d", len(cfg.Nodes))
}
n := cfg.Nodes[0]
if n.ID != "oto-agent-1" {
t.Errorf("expected ID oto-agent-1, got %s", n.ID)
}
if n.Alias != "custom-alias" {
t.Errorf("expected Alias custom-alias, got %s", n.Alias)
}
if n.Token != "custom-token" {
t.Errorf("expected Token custom-token, got %s", n.Token)
}
if n.AgentKind != config.AgentKindOTOAgent {
t.Errorf("expected AgentKind %q, got %s", config.AgentKindOTOAgent, n.AgentKind)
}
if n.Adapters.Ollama.Enabled || n.Adapters.CLI.Enabled {
t.Errorf("expected adapters to be disabled for OTO agent, got Ollama=%v, CLI=%v", n.Adapters.Ollama.Enabled, n.Adapters.CLI.Enabled)
}
}
func TestAgentRegisterPrintsOneLineOTOCommand(t *testing.T) {
tmpdir := t.TempDir()
cfgPath := filepath.Join(tmpdir, "edge.yaml")
initialYAML := `server:
listen: "127.0.0.1:9090"
advertise_host: "127.0.0.1"
bootstrap:
oto_bootstrap_script_url: "https://example.com/boot.sh"
oto_release_base_url: "https://example.com/releases"
nodes: []
`
if err := os.WriteFile(cfgPath, []byte(initialYAML), 0o600); err != nil {
t.Fatalf("write initial config: %v", err)
}
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "agent", "register", "oto-agent-1", "--alias", "custom-alias", "--token", "custom-token", "--edge-url", "http://edge.test:9090"})
if err := root.Execute(); err != nil {
t.Fatalf("agent register failed: %v", err)
}
got := out.String()
want := "curl -fsSL https://example.com/boot.sh | bash -s -- --edge-url http://edge.test:9090 --agent-id oto-agent-1 --enrollment-token custom-token --release-base-url https://example.com/releases --agent-alias custom-alias\n"
if got != want {
t.Errorf("unexpected output:\n got: %q\nwant: %q", got, want)
}
}
func TestAgentRegisterRejectsInsecureBootstrapURLWithoutFlag(t *testing.T) {
tmpdir := t.TempDir()
cfgPath := filepath.Join(tmpdir, "edge.yaml")
initialYAML := `server:
listen: "127.0.0.1:9090"
advertise_host: "127.0.0.1"
bootstrap:
oto_release_base_url: "https://example.com/releases"
nodes: []
`
if err := os.WriteFile(cfgPath, []byte(initialYAML), 0o600); err != nil {
t.Fatalf("write initial config: %v", err)
}
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "agent", "register", "oto-agent-1", "--bootstrap-script-url", "http://example.com/boot.sh"})
err := root.Execute()
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "bootstrap-script-url must start with https:// unless --allow-insecure-bootstrap-url is set") {
t.Errorf("unexpected error: %v", err)
}
}
func TestAgentRegisterRejectsInsecureReleaseBaseURL(t *testing.T) {
tmpdir := t.TempDir()
cfgPath := filepath.Join(tmpdir, "edge.yaml")
initialYAML := `server:
listen: "127.0.0.1:9090"
advertise_host: "127.0.0.1"
bootstrap:
oto_bootstrap_script_url: "https://example.com/boot.sh"
nodes: []
`
if err := os.WriteFile(cfgPath, []byte(initialYAML), 0o600); err != nil {
t.Fatalf("write initial config: %v", err)
}
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "agent", "register", "oto-agent-1", "--release-base-url", "http://example.com/releases"})
err := root.Execute()
if err == nil {
t.Fatal("expected error, got nil")
}
if !strings.Contains(err.Error(), "release-base-url must start with https://") {
t.Errorf("unexpected error: %v", err)
}
}
func TestAgentRegisterAllowsInsecureBootstrapURLWithExplicitFlag(t *testing.T) {
tmpdir := t.TempDir()
cfgPath := filepath.Join(tmpdir, "edge.yaml")
initialYAML := `server:
listen: "127.0.0.1:9090"
advertise_host: "127.0.0.1"
bootstrap:
oto_release_base_url: "https://example.com/releases"
nodes: []
`
if err := os.WriteFile(cfgPath, []byte(initialYAML), 0o600); err != nil {
t.Fatalf("write initial config: %v", err)
}
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "agent", "register", "oto-agent-1", "--bootstrap-script-url", "http://example.com/boot.sh", "--allow-insecure-bootstrap-url"})
if err := root.Execute(); err != nil {
t.Fatalf("agent register failed with flag: %v", err)
}
got := out.String()
if !strings.Contains(got, "http://example.com/boot.sh") {
t.Errorf("output should contain http bootstrap URL, got: %q", got)
}
}
func TestTempFileCLIWorkflowConfigInitAgentRegisterConfigCheck(t *testing.T) {
tmpdir := t.TempDir()
cfgPath := filepath.Join(tmpdir, "edge.yaml")
t.Chdir(tmpdir)
// 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("config init failed: %v", err)
}
// 2. agent register
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{
"--config", cfgPath,
"agent", "register", "oto-agent-test",
"--bootstrap-script-url", "https://oto.example.test/bootstrap/oto_agent_bootstrap.sh",
"--release-base-url", "https://oto.example.test/releases",
"--token", "test-token",
"--edge-url", "localhost:9090",
})
if err := root.Execute(); err != nil {
t.Fatalf("agent register failed: %v", err)
}
// Assert exact-output format
gotCmd := out.String()
wantCmd := "curl -fsSL https://oto.example.test/bootstrap/oto_agent_bootstrap.sh | bash -s -- --edge-url localhost:9090 --agent-id oto-agent-test --enrollment-token test-token --release-base-url https://oto.example.test/releases --agent-alias oto-agent-test\n"
if gotCmd != wantCmd {
t.Errorf("unexpected printed command:\n got: %q\nwant: %q", gotCmd, wantCmd)
}
// 3. config check
root = rootCmd()
out.Reset()
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{"--config", cfgPath, "config", "check"})
if err := root.Execute(); err != nil {
t.Fatalf("config check failed: %v", err)
}
gotCheck := out.String()
if !strings.Contains(gotCheck, "OK") {
t.Errorf("expected OK, got: %q", gotCheck)
}
}