feat: edge local dev infrastructure and artifact server implementation

- Add artifact_server.go for edge node artifact serving
- Refactor bootstrap/runtime.go to use new artifact server
- Update Makefile for new build workflow
- Remove deprecated bin/build/field-binaries.sh
- Update edge README with new development workflow
- Update edge-local-dev-guide.md with artifact server info
- Add config and hostsetup updates for artifact serving
- Update agent-ops rules and testing domain rules
This commit is contained in:
toki 2026-05-28 18:53:12 +09:00
parent 329576345d
commit 5c3fef65e9
13 changed files with 499 additions and 251 deletions

2
.gitignore vendored
View file

@ -10,9 +10,11 @@ agent-ops/rules/private/
/temp_verif/
/bin/control-plane
/bin/edge
/bin/artifacts/
/bin/iop-control-plane
/bin/iop-edge
/bin/iop-node
/bin/iop-node-*
/bin/iop-worker
/bin/node
/bin/worker

View file

@ -1,8 +1,11 @@
.PHONY: all build build-edge build-node dist dist-edge dist-node tidy test test-e2e test-openai-ollama proto proto-dart portal-test portal-build-web clean
.PHONY: all build build-edge build-node build-node-target pack-node-target tidy test test-e2e test-openai-ollama proto proto-dart portal-test portal-build-web clean
GOFLAGS ?= -trimpath
BUILD_DIR ?= bin
DIST_APPS ?= edge node
NODE_TARGET ?= $(shell go env GOOS)-$(shell go env GOARCH)
NODE_TARGET_PARTS = $(subst -, ,$(NODE_TARGET))
NODE_GOOS = $(word 1,$(NODE_TARGET_PARTS))
NODE_GOARCH = $(word 2,$(NODE_TARGET_PARTS))
all: build
@ -16,14 +19,12 @@ build-edge:
build-node:
go build $(GOFLAGS) -o $(BUILD_DIR)/iop-node ./apps/node/cmd/node
dist:
APPS="$(DIST_APPS)" GOFLAGS="$(GOFLAGS)" bin/build/field-binaries.sh
build-node-target:
@test -n "$(NODE_GOOS)" && test -n "$(NODE_GOARCH)" || (echo "NODE_TARGET must be <goos>-<goarch>" >&2; exit 2)
GOOS=$(NODE_GOOS) GOARCH=$(NODE_GOARCH) go build $(GOFLAGS) -o $(BUILD_DIR)/iop-node-$(NODE_TARGET) ./apps/node/cmd/node
dist-edge:
$(MAKE) dist DIST_APPS=edge
dist-node:
$(MAKE) dist DIST_APPS=node
pack-node-target: build-edge build-node-target
cd $(BUILD_DIR) && ./iop-edge bootstrap pack --target $(NODE_TARGET) --node-binary ./iop-node-$(NODE_TARGET)
tidy:
go mod tidy
@ -87,6 +88,8 @@ portal-build-web:
clean:
rm -f bin/iop-edge bin/iop-node bin/iop-control-plane bin/iop-worker
rm -f bin/iop-node-*
rm -f bin/node bin/edge bin/control-plane bin/worker
rm -rf bin/artifacts
rm -rf dist
rm -f iop.db

View file

@ -15,7 +15,7 @@ last_rule_updated_at: 2026-05-28
- `Makefile` — 공식 test target, proto generation, 보조 smoke target을 선언하는 위치이다.
- `bin/edge.sh` — repo 내부 edge console/server 개발 진단 helper이다.
- `bin/node.sh` — repo 내부 node 연결 개발 진단 helper이다. field 사용자 기본 경로로 안내하지 않는다.
- `bin/build/field-binaries.sh` — field 배포용 edge/node 바이너리 build entrypoint이다.
- `iop-edge bootstrap pack` — Node host bootstrap용 artifact 폴더를 준비하는 공식 command이다.
- `scripts/e2e-smoke.sh` — mock/real profile 기반 보조 edge-node smoke 검증이다.
- `scripts/e2e-openai-ollama.sh` — OpenAI-compatible Ollama 입력 표면 보조 smoke 검증이다.
@ -53,7 +53,7 @@ last_rule_updated_at: 2026-05-28
- field Node bootstrap의 사용자 명령은 `curl -fsSL <완성된-bootstrap-url> | bash -s <token>` 형태를 기준으로 한다. 다른 bootstrap/enrollment 작업에서도 같은 수준의 단일 token UX를 우선 적용하고, 예외가 필요하면 사용자에게 먼저 확인한다.
- repo 내부 edge-node 진단의 상세 수행 절차와 기능별 체크리스트는 `agent-ops/skills/project/e2e-smoke/SKILL.md`를 따른다.
- `make test-e2e`, `scripts/e2e-smoke.sh`, `scripts/e2e-openai-ollama.sh`는 보조 smoke 명령이다. 실행할 수 있으면 보조 확인으로 기록하되, full-cycle 실제 구동이나 field bootstrap 검증을 대체하지 않는다.
- `bin/build/field-binaries.sh` 변경 시 최소 현재 host target build를 실행하고 산출물 위치, checksum 생성, node bootstrap script가 positional token UX를 유지하는지 확인한다.
- `iop-edge bootstrap pack` 또는 내장 artifact server 변경 시 최소 현재 host target build를 실행하고 artifact 폴더 위치, checksum 생성, node bootstrap script가 positional token UX를 유지하는지 확인한다.
- 풀테스트에서는 실제 외부 CLI profile 검증을 필수로 수행한다. 환경, 계정, provider, 원격 endpoint 문제로 호출할 수 없거나 실패한 profile은 누락하지 말고 profile별 실패 또는 blocker로 보고한다.
- 작업 최종 보고에는 실행한 테스트 명령, repo 내부 edge-node 진단 수행 여부, 보조 E2E smoke 수행 여부, full-cycle 실제 구동 수행 여부를 명시한다. 수행하지 못한 필수 검증은 이유와 남은 위험을 함께 적는다.

View file

@ -23,7 +23,7 @@
- `proto/iop/` — IOP 메시지 계약 원본이다.
- `proto/gen/iop/` — protobuf 생성물이다.
- `configs/` — 앱별 YAML 설정 예시이다.
- `bin/` — 개발 보조 shell entrypoint와 field binary build entrypoint이다. Edge/Node 운영 UX의 공식 표면은 `iop-edge`/`iop-node` 바이너리 command로 모은다.
- `bin/` — 개발 보조 shell entrypoint와 로컬 바이너리 산출 위치이다. Edge/Node 운영 UX와 Node bootstrap artifact 준비는 `iop-edge`/`iop-node` 바이너리 command 표면에 모은다.
- `scripts/` — 보조 E2E smoke와 입력 표면 검증 스크립트이다.
- `Makefile` — 빌드와 테스트 진입점을 정의한다.
- `docs/` — 사람용 최신 가이드만 둔다. 환경별 값은 `agent-ops/rules/private/`에 두고, `rules.md`는 라우터로만 쓴다.

View file

@ -94,6 +94,7 @@ iop-edge env
iop-edge config init
iop-edge config print --config /etc/iop/edge.yaml
iop-edge config check --config /etc/iop/edge.yaml
iop-edge bootstrap pack
iop-edge node register <node-id>
iop-edge nodes list
iop-edge smoke openai --model <model>

View file

@ -3,6 +3,7 @@ package main
import (
"bytes"
"context"
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
@ -13,6 +14,7 @@ import (
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
"time"
@ -61,7 +63,7 @@ The official local development and field test flow is as follows:
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(), nodeCmd(), nodesCmd(), smokeCmd())
root.AddCommand(serveCmd(), consoleCmd(), versionCmd(), configCmd(), envCmd(), setupCmd(), bootstrapCmd(), nodeCmd(), nodesCmd(), smokeCmd())
return root
}
@ -109,6 +111,16 @@ func resolveEdgeLogPath(explicit string) (string, error) {
return filepath.Join(filepath.Dir(exe), "logs", "edge.log"), nil
}
func resolveBootstrapArtifactDir(dir string) string {
if dir == "" {
dir = "artifacts"
}
if filepath.IsAbs(dir) {
return dir
}
return filepath.Join(binaryDir(), dir)
}
// binaryDir returns the directory of the running executable. Falls back to the
// current working directory if os.Executable fails.
func binaryDir() string {
@ -140,6 +152,7 @@ func serveCmd() *cobra.Command {
}
}
cfg.Logging.Path = logPath
cfg.Bootstrap.ArtifactDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
fx.New(bootstrap.Module(cfg)).Run()
return nil
},
@ -165,6 +178,7 @@ func consoleCmd() *cobra.Command {
}
}
cfg.Logging.Path = logPath
cfg.Bootstrap.ArtifactDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
return runConsole(context.Background(), cfg, os.Stdin, os.Stdout)
},
}
@ -309,6 +323,7 @@ func envCmd() *cobra.Command {
if err != nil {
return err
}
cfg.Bootstrap.ArtifactDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
renderEnv(cmd.OutOrStdout(), envReport{
ConfigPath: path,
BinaryDir: binaryDir(),
@ -373,8 +388,12 @@ 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 defaultArtifactBaseURL(host string, listen ...string) string {
bootstrapListen := "0.0.0.0:18080"
if len(listen) > 0 && listen[0] != "" {
bootstrapListen = listen[0]
}
return "http://" + addressFor(host, bootstrapListen)
}
func bootstrapScriptURL(baseURL, target string) string {
@ -397,7 +416,7 @@ func renderEnv(w io.Writer, r envReport) {
artifactURL := strings.TrimSuffix(r.Bootstrap.ArtifactBaseURL, "/")
artifactSource := "configured"
if artifactURL == "" {
artifactURL = defaultArtifactBaseURL(r.Advertise.Host)
artifactURL = defaultArtifactBaseURL(r.Advertise.Host, r.Bootstrap.Listen)
artifactSource = "derived"
}
@ -406,6 +425,8 @@ func renderEnv(w io.Writer, r envReport) {
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))
fmt.Fprintf(w, "artifact_dir: %s\n", r.Bootstrap.ArtifactDir)
fmt.Fprintf(w, "artifact_listen: %s\n", r.Bootstrap.Listen)
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 {
@ -472,6 +493,255 @@ func setupCmd() *cobra.Command {
return c
}
func bootstrapCmd() *cobra.Command {
c := &cobra.Command{
Use: "bootstrap",
Short: "Prepare node bootstrap artifacts",
Long: `Prepare node bootstrap artifacts served by iop-edge serve.
The default output directory is the configured bootstrap.artifact_dir resolved
beside the iop-edge binary. The directory is static and can be copied as-is.`,
}
c.AddCommand(bootstrapPackCmd())
return c
}
func bootstrapPackCmd() *cobra.Command {
var nodeBinary string
var outputDir string
var target string
var artifactBaseURL string
var edgeAddr string
c := &cobra.Command{
Use: "pack",
Short: "Pack iop-node into the configured bootstrap artifact directory",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := loadEdgeConfigForBootstrapPack(cmd)
if err != nil {
return err
}
adv := resolveAdvertiseHost(cfg.Server.AdvertiseHost)
if nodeBinary == "" {
nodeBinary = filepath.Join(binaryDir(), "iop-node")
}
if outputDir == "" {
outputDir = resolveBootstrapArtifactDir(cfg.Bootstrap.ArtifactDir)
}
if target == "" {
target = runtime.GOOS + "-" + runtime.GOARCH
}
if artifactBaseURL == "" {
if cfg.Bootstrap.ArtifactBaseURL != "" {
artifactBaseURL = strings.TrimSuffix(cfg.Bootstrap.ArtifactBaseURL, "/")
} else {
artifactBaseURL = defaultArtifactBaseURL(adv.Host, cfg.Bootstrap.Listen)
}
}
if edgeAddr == "" {
edgeAddr = addressFor(adv.Host, cfg.Server.Listen)
}
if err := packNodeBootstrap(nodeBootstrapPackOptions{
NodeBinary: nodeBinary,
OutputDir: outputDir,
Target: target,
Version: version.Version,
ArtifactBaseURL: artifactBaseURL,
EdgeAddr: edgeAddr,
}); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "wrote bootstrap artifacts to %s\n", outputDir)
fmt.Fprintf(cmd.OutOrStdout(), "bootstrap: %s\n", bootstrapScriptURL(artifactBaseURL, target))
return nil
},
}
c.Flags().StringVar(&nodeBinary, "node-binary", "", "iop-node binary to pack (defaults to iop-node beside iop-edge)")
c.Flags().StringVarP(&outputDir, "output", "o", "", "artifact output directory (defaults to bootstrap.artifact_dir)")
c.Flags().StringVar(&target, "target", "", "target platform label (defaults to current GOOS-GOARCH)")
c.Flags().StringVar(&artifactBaseURL, "artifact-base-url", "", "artifact base URL baked into the bootstrap script")
c.Flags().StringVar(&edgeAddr, "edge-addr", "", "edge transport address baked into node.yaml")
return c
}
func loadEdgeConfigForBootstrapPack(cmd *cobra.Command) (*config.EdgeConfig, error) {
cfg, _, err := loadEdgeConfig(cmd)
if err == nil {
return cfg, nil
}
if !strings.Contains(err.Error(), "no edge.yaml found") {
return nil, err
}
return &config.EdgeConfig{
Server: config.EdgeServerConf{
Listen: "0.0.0.0:9090",
},
Bootstrap: config.EdgeBootstrapConf{
Listen: "0.0.0.0:18080",
ArtifactDir: "artifacts",
},
}, nil
}
type nodeBootstrapPackOptions struct {
NodeBinary string
OutputDir string
Target string
Version string
ArtifactBaseURL string
EdgeAddr string
}
func packNodeBootstrap(opts nodeBootstrapPackOptions) error {
if opts.NodeBinary == "" {
return errors.New("node binary path is required")
}
if opts.OutputDir == "" {
return errors.New("output directory is required")
}
if opts.Target == "" {
return errors.New("target is required")
}
if strings.ContainsAny(opts.Target, `/\`) {
return fmt.Errorf("target %q must not contain path separators", opts.Target)
}
targetDir := filepath.Join(opts.OutputDir, opts.Target)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
return fmt.Errorf("mkdir target dir: %w", err)
}
dstNode := filepath.Join(targetDir, "iop-node")
if err := copyFile(opts.NodeBinary, dstNode, 0o755); err != nil {
return fmt.Errorf("copy iop-node: %w", err)
}
sum, err := fileSHA256(dstNode)
if err != nil {
return err
}
sumLine := fmt.Sprintf("%x iop-node\n", sum)
if err := os.WriteFile(filepath.Join(targetDir, "iop-node.sha256"), []byte(sumLine), 0o644); err != nil {
return fmt.Errorf("write iop-node.sha256: %w", err)
}
if err := os.WriteFile(filepath.Join(targetDir, "SHA256SUMS"), []byte(sumLine), 0o644); err != nil {
return fmt.Errorf("write SHA256SUMS: %w", err)
}
bootstrapDir := filepath.Join(opts.OutputDir, "bootstrap")
if err := os.MkdirAll(bootstrapDir, 0o755); err != nil {
return fmt.Errorf("mkdir bootstrap dir: %w", err)
}
script := nodeBootstrapScript(opts)
scriptPath := filepath.Join(bootstrapDir, "node-"+opts.Target+".sh")
if err := os.WriteFile(scriptPath, []byte(script), 0o755); err != nil {
return fmt.Errorf("write bootstrap script: %w", err)
}
return nil
}
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, mode)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
return err
}
if err := out.Close(); err != nil {
return err
}
return os.Chmod(dst, mode)
}
func fileSHA256(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open checksum file: %w", err)
}
defer f.Close()
h := sha256.New()
if _, err := io.Copy(h, f); err != nil {
return nil, fmt.Errorf("hash file: %w", err)
}
return h.Sum(nil), nil
}
func shellQuote(s string) string {
return strconv.Quote(s)
}
func nodeBootstrapScript(opts nodeBootstrapPackOptions) string {
return `#!/usr/bin/env bash
set -euo pipefail
VERSION=` + shellQuote(opts.Version) + `
TARGET=` + shellQuote(opts.Target) + `
DEFAULT_ARTIFACT_BASE_URL=` + shellQuote(strings.TrimSuffix(opts.ArtifactBaseURL, "/")) + `
DEFAULT_EDGE_ADDR=` + shellQuote(opts.EdgeAddr) + `
POSITIONAL_NODE_TOKEN="${1:-}"
ARTIFACT_BASE_URL="$DEFAULT_ARTIFACT_BASE_URL"
EDGE_ADDR="$DEFAULT_EDGE_ADDR"
NODE_TOKEN="$POSITIONAL_NODE_TOKEN"
INSTALL_DIR="${IOP_HOME:-$HOME/iop-field}"
CONFIG_FILE="${IOP_NODE_CONFIG:-$INSTALL_DIR/node.yaml}"
BIN_FILE="$INSTALL_DIR/iop-node"
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/iop-node-bootstrap.XXXXXX")"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
ARTIFACT_BASE_URL="${ARTIFACT_BASE_URL%/}"
if [[ -z "$NODE_TOKEN" ]]; then
echo "[iop-bootstrap] missing node token" >&2
echo "[iop-bootstrap] usage: curl -fsSL <artifact-base-url>/bootstrap/node-$TARGET.sh | bash -s TOKEN" >&2
exit 2
fi
echo "[iop-bootstrap] version=$VERSION target=$TARGET"
echo "[iop-bootstrap] install_dir=$INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/iop-node" -o "$TMP_DIR/iop-node"
curl -fsSL "$ARTIFACT_BASE_URL/$TARGET/SHA256SUMS" -o "$TMP_DIR/SHA256SUMS"
if command -v sha256sum >/dev/null 2>&1; then
(cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | sha256sum -c -)
elif command -v shasum >/dev/null 2>&1; then
(cd "$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | shasum -a 256 -c -)
else
echo "[iop-bootstrap] warning: no sha256 checker found; skipping checksum verification" >&2
fi
cp "$TMP_DIR/iop-node" "$BIN_FILE"
chmod +x "$BIN_FILE"
if command -v xattr >/dev/null 2>&1; then
xattr -d com.apple.quarantine "$BIN_FILE" 2>/dev/null || true
fi
cat > "$CONFIG_FILE" <<YAML
transport:
edge_addr: "$EDGE_ADDR"
token: "$NODE_TOKEN"
logging:
level: "${IOP_LOG_LEVEL:-info}"
pretty: true
metrics:
port: ${IOP_NODE_METRICS_PORT:-9091}
YAML
echo "[iop-bootstrap] config=$CONFIG_FILE"
echo "[iop-bootstrap] starting node against $EDGE_ADDR"
exec "$BIN_FILE" serve --config "$CONFIG_FILE"
`
}
var (
regAlias string
regToken string
@ -702,7 +972,7 @@ directly in the default flow.`,
cfg.Bootstrap.ArtifactBaseURL = regArtifactBaseURL
}
if artifactURL == "" {
artifactURL = defaultArtifactBaseURL(resolveAdvertiseHost(cfg.Server.AdvertiseHost).Host)
artifactURL = defaultArtifactBaseURL(resolveAdvertiseHost(cfg.Server.AdvertiseHost).Host, cfg.Bootstrap.Listen)
}
// Validate mutated config before writing

View file

@ -18,12 +18,13 @@ import (
func TestRootCmdIncludesOperationalCommands(t *testing.T) {
root := rootCmd()
want := map[string]bool{
"serve": false,
"console": false,
"version": false,
"config": false,
"env": false,
"setup": false,
"serve": false,
"console": false,
"version": false,
"config": false,
"env": false,
"setup": false,
"bootstrap": false,
}
for _, c := range root.Commands() {
if _, ok := want[c.Name()]; ok {
@ -398,6 +399,61 @@ func TestResolveEdgeLogPathRespectsExplicitPath(t *testing.T) {
}
}
func TestBootstrapPackCreatesNodeArtifacts(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "edge.yaml")
yamlStr := `server:
listen: "127.0.0.1:39090"
advertise_host: "edge.example.test"
bootstrap:
listen: "127.0.0.1:38080"
artifact_dir: "artifacts"
`
if err := os.WriteFile(cfgPath, []byte(yamlStr), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
nodeBinary := filepath.Join(dir, "iop-node")
if err := os.WriteFile(nodeBinary, []byte("fake-node-binary"), 0o755); err != nil {
t.Fatalf("write node binary: %v", err)
}
outDir := filepath.Join(dir, "out")
root := rootCmd()
var out bytes.Buffer
root.SetOut(&out)
root.SetErr(&out)
root.SetArgs([]string{
"--config", cfgPath,
"bootstrap", "pack",
"--node-binary", nodeBinary,
"--output", outDir,
"--target", "linux-arm64",
})
if err := root.Execute(); err != nil {
t.Fatalf("execute pack: %v\n%s", err, out.String())
}
for _, path := range []string{
filepath.Join(outDir, "linux-arm64", "iop-node"),
filepath.Join(outDir, "linux-arm64", "SHA256SUMS"),
filepath.Join(outDir, "bootstrap", "node-linux-arm64.sh"),
} {
if _, err := os.Stat(path); err != nil {
t.Fatalf("expected artifact %s: %v", path, err)
}
}
script, err := os.ReadFile(filepath.Join(outDir, "bootstrap", "node-linux-arm64.sh"))
if err != nil {
t.Fatalf("read script: %v", err)
}
if !strings.Contains(string(script), `DEFAULT_ARTIFACT_BASE_URL="http://edge.example.test:38080"`) {
t.Fatalf("script missing derived artifact URL:\n%s", script)
}
if !strings.Contains(string(script), `DEFAULT_EDGE_ADDR="edge.example.test:39090"`) {
t.Fatalf("script missing derived edge addr:\n%s", script)
}
}
func TestResolveAdvertiseHostPrefersExplicit(t *testing.T) {
got := resolveAdvertiseHost("explicit.example.test")
if got.Host != "explicit.example.test" || got.Source != "explicit" {

View file

@ -0,0 +1,72 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"time"
"go.uber.org/zap"
)
// ArtifactServer serves node bootstrap scripts and node binaries from the
// configured artifact directory. It is intentionally static: iop-edge prepares
// the directory, then serve exposes it without mutating the artifacts.
type ArtifactServer struct {
listen string
dir string
logger *zap.Logger
server *http.Server
}
func NewArtifactServer(listen, dir string, logger *zap.Logger) *ArtifactServer {
return &ArtifactServer{listen: listen, dir: dir, logger: logger}
}
func (s *ArtifactServer) Start(ctx context.Context) error {
if s == nil || s.listen == "" || s.dir == "" {
return nil
}
if _, err := os.Stat(s.dir); err != nil {
if errors.Is(err, os.ErrNotExist) {
s.logger.Warn("bootstrap artifact directory does not exist", zap.String("dir", s.dir))
} else {
return fmt.Errorf("stat bootstrap artifact dir: %w", err)
}
}
mux := http.NewServeMux()
mux.Handle("/", http.FileServer(http.Dir(s.dir)))
s.server = &http.Server{
Addr: s.listen,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
ln, err := net.Listen("tcp", s.listen)
if err != nil {
return fmt.Errorf("bootstrap artifact server listen %s: %w", s.listen, err)
}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = s.server.Shutdown(shutdownCtx)
}()
go func() {
s.logger.Info("bootstrap artifact server listening", zap.String("addr", s.listen), zap.String("dir", s.dir))
if err := s.server.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Warn("bootstrap artifact server exited", zap.Error(err))
}
}()
return nil
}
func (s *ArtifactServer) Stop(ctx context.Context) error {
if s == nil || s.server == nil {
return nil
}
return s.server.Shutdown(ctx)
}

View file

@ -30,6 +30,7 @@ type Runtime struct {
Service *edgeservice.Service
Server *transport.Server
Input *edgeinput.Manager
Artifact *ArtifactServer
lifetimeMu sync.Mutex
lifetimeCancel context.CancelFunc
@ -50,6 +51,7 @@ func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
bus := edgeevents.NewBus()
svc := edgeservice.New(registry, bus)
inputManager := edgeinput.NewManager(*cfg, svc, logger.Named("input"))
artifactServer := NewArtifactServer(cfg.Bootstrap.Listen, cfg.Bootstrap.ArtifactDir, logger.Named("bootstrap"))
server, err := transport.NewServer(cfg.Server.Listen, registry, nodeStore, logger)
if err != nil {
@ -65,6 +67,7 @@ func NewRuntime(cfg *config.EdgeConfig) (*Runtime, error) {
Service: svc,
Server: server,
Input: inputManager,
Artifact: artifactServer,
}, nil
}
@ -93,6 +96,12 @@ func (r *Runtime) Start(ctx context.Context) error {
_ = r.Server.Stop()
return err
}
if err := r.Artifact.Start(lifetimeCtx); err != nil {
r.cancelLifetime()
_ = r.Input.Stop(context.Background())
_ = r.Server.Stop()
return err
}
r.startMetrics()
return nil
}
@ -102,11 +111,15 @@ func (r *Runtime) Stop() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
inputErr := r.Input.Stop(ctx)
artifactErr := r.Artifact.Stop(ctx)
err := r.Server.Stop()
_ = r.Logger.Sync()
if err == nil {
err = inputErr
}
if err == nil {
err = artifactErr
}
return err
}

View file

@ -1,213 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DIST_DIR="${DIST_DIR:-"$ROOT_DIR/dist/field"}"
TARGETS="${TARGETS:-}"
APPS="${APPS:-"edge node"}"
CGO_ENABLED="${CGO_ENABLED:-0}"
GOFLAGS="${GOFLAGS:-"-trimpath"}"
if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
cat <<'USAGE'
Build field-deployable IOP edge/node binaries.
Usage:
make dist
make dist-edge
make dist-node
This script is the build engine behind the Makefile targets.
Environment:
APPS="edge node" apps to build
TARGETS="linux/amd64 linux/arm64" GOOS/GOARCH target list
DIST_DIR=dist/field output directory
VERSION=<value> release version label
COMMIT=<value> commit label
BUILD_DATE=<value> build date label
ARTIFACT_BASE_URL=<url> default bootstrap artifact base URL
BOOTSTRAP_EDGE_ADDR=<host:port> default Edge address written to node.yaml
BOOTSTRAP_NODE_TOKEN=<token> optional fallback token baked into bootstrap script
CGO_ENABLED=0 Go CGO setting
GOFLAGS="-trimpath" extra go build flags
LDFLAGS="-s -w" linker flags
USAGE
exit 0
fi
VERSION="${VERSION:-"$(git -C "$ROOT_DIR" describe --tags --always --dirty 2>/dev/null || echo dev)"}"
COMMIT="${COMMIT:-"$(git -C "$ROOT_DIR" rev-parse --short=12 HEAD 2>/dev/null || echo unknown)"}"
BUILD_DATE="${BUILD_DATE:-"$(date -u +%Y%m%dT%H%M%SZ)"}"
LDFLAGS="${LDFLAGS:-"-s -w"}"
if [[ -z "$TARGETS" ]]; then
TARGETS="$(go env GOOS)/$(go env GOARCH)"
fi
checksum() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$@"
return
fi
shasum -a 256 "$@"
}
app_enabled() {
local wanted="$1"
local app
for app in $APPS; do
if [[ "$app" == "$wanted" ]]; then
return 0
fi
done
return 1
}
build_one() {
local app="$1"
local goos="$2"
local goarch="$3"
local output_dir="$DIST_DIR/$VERSION/$goos-$goarch"
local output="$output_dir/iop-$app"
mkdir -p "$output_dir"
echo "building $app for $goos/$goarch -> $output"
(
cd "$ROOT_DIR"
GOOS="$goos" GOARCH="$goarch" CGO_ENABLED="$CGO_ENABLED" \
go build $GOFLAGS -ldflags "$LDFLAGS" -o "$output" "./apps/$app/cmd/$app"
)
(
cd "$output_dir"
checksum "iop-$app" > "iop-$app.sha256"
)
}
write_node_bootstrap() {
local goos="$1"
local goarch="$2"
local target_dir="$goos-$goarch"
local bootstrap_dir="$DIST_DIR/$VERSION/bootstrap"
local script="$bootstrap_dir/node-$target_dir.sh"
local default_base_url="${ARTIFACT_BASE_URL:-"http://toki-labs.com:18080"}"
local default_edge_addr="${BOOTSTRAP_EDGE_ADDR:-"toki-labs.com:19090"}"
local default_node_token="${BOOTSTRAP_NODE_TOKEN:-}"
mkdir -p "$bootstrap_dir"
cat > "$script" <<EOF
#!/usr/bin/env bash
set -euo pipefail
VERSION="$VERSION"
TARGET="$target_dir"
DEFAULT_ARTIFACT_BASE_URL="$default_base_url"
DEFAULT_EDGE_ADDR="$default_edge_addr"
DEFAULT_NODE_TOKEN="$default_node_token"
POSITIONAL_NODE_TOKEN="\${1:-}"
ARTIFACT_BASE_URL="\$DEFAULT_ARTIFACT_BASE_URL"
EDGE_ADDR="\$DEFAULT_EDGE_ADDR"
NODE_TOKEN="\${POSITIONAL_NODE_TOKEN:-\$DEFAULT_NODE_TOKEN}"
INSTALL_DIR="\${IOP_HOME:-\$HOME/iop-field}"
CONFIG_FILE="\${IOP_NODE_CONFIG:-\$INSTALL_DIR/node.yaml}"
BIN_FILE="\$INSTALL_DIR/iop-node"
TMP_DIR="\$(mktemp -d "\${TMPDIR:-/tmp}/iop-node-bootstrap.XXXXXX")"
cleanup() {
rm -rf "\$TMP_DIR"
}
trap cleanup EXIT
ARTIFACT_BASE_URL="\${ARTIFACT_BASE_URL%/}"
if [[ -z "\$NODE_TOKEN" ]]; then
echo "[iop-bootstrap] missing node token" >&2
echo "[iop-bootstrap] usage: curl -fsSL <artifact-base-url>/bootstrap/node-\$TARGET.sh | bash -s TOKEN" >&2
exit 2
fi
echo "[iop-bootstrap] version=\$VERSION target=\$TARGET"
echo "[iop-bootstrap] install_dir=\$INSTALL_DIR"
mkdir -p "\$INSTALL_DIR"
curl -fsSL "\$ARTIFACT_BASE_URL/\$TARGET/iop-node" -o "\$TMP_DIR/iop-node"
curl -fsSL "\$ARTIFACT_BASE_URL/\$TARGET/SHA256SUMS" -o "\$TMP_DIR/SHA256SUMS"
if command -v sha256sum >/dev/null 2>&1; then
(cd "\$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | sha256sum -c -)
elif command -v shasum >/dev/null 2>&1; then
(cd "\$TMP_DIR" && grep -E '[ *]iop-node$' SHA256SUMS | shasum -a 256 -c -)
else
echo "[iop-bootstrap] warning: no sha256 checker found; skipping checksum verification" >&2
fi
cp "\$TMP_DIR/iop-node" "\$BIN_FILE"
chmod +x "\$BIN_FILE"
if command -v xattr >/dev/null 2>&1; then
xattr -d com.apple.quarantine "\$BIN_FILE" 2>/dev/null || true
fi
cat > "\$CONFIG_FILE" <<YAML
transport:
edge_addr: "\$EDGE_ADDR"
token: "\$NODE_TOKEN"
logging:
level: "\${IOP_LOG_LEVEL:-info}"
pretty: true
metrics:
port: \${IOP_NODE_METRICS_PORT:-9091}
YAML
echo "[iop-bootstrap] config=\$CONFIG_FILE"
echo "[iop-bootstrap] starting node against \$EDGE_ADDR"
exec "\$BIN_FILE" serve --config "\$CONFIG_FILE"
EOF
chmod +x "$script"
}
for target in $TARGETS; do
IFS=/ read -r goos goarch extra <<< "$target"
if [[ -z "${goos:-}" || -z "${goarch:-}" || -n "${extra:-}" ]]; then
echo "invalid target '$target'; expected GOOS/GOARCH" >&2
exit 1
fi
for app in $APPS; do
case "$app" in
edge|node)
build_one "$app" "$goos" "$goarch"
;;
*)
echo "unsupported app '$app'; expected edge or node" >&2
exit 1
;;
esac
done
output_dir="$DIST_DIR/$VERSION/$goos-$goarch"
present_apps=()
for app in edge node; do
if [[ -f "$output_dir/iop-$app" ]]; then
present_apps+=("$app")
fi
done
{
echo "version=$VERSION"
echo "commit=$COMMIT"
echo "build_date=$BUILD_DATE"
echo "target=$goos/$goarch"
echo "apps=${present_apps[*]:-}"
echo "updated_apps=$APPS"
} > "$output_dir/manifest.env"
(
cd "$output_dir"
checksum iop-* | grep -v '\.sha256$' > SHA256SUMS
)
if app_enabled node; then
write_node_bootstrap "$goos" "$goarch"
fi
done
echo "field binaries written to $DIST_DIR/$VERSION"

View file

@ -33,12 +33,6 @@ bin/iop-edge
bin/iop-node
```
checksum, manifest, bootstrap script까지 포함한 field bundle이 필요할 때만 별도로 만든다.
```bash
make dist
```
## 2. Bundle 디렉터리 준비
하나의 디렉터리에 `iop-edge``iop-node` 바이너리가 함께 있다고 가정한다.
@ -90,6 +84,7 @@ cd /opt/iop-dev
config path
binary dir
log dir
artifact dir
advertise host
Node transport address
artifact/bootstrap URL
@ -107,7 +102,32 @@ connected nodes
<bundle-dir>/logs/
```
## 5. Node 등록과 bootstrap 출력
## 5. Bootstrap artifact 준비
Node host에서 bootstrap 명령까지 테스트하려면 `iop-edge serve`가 제공할 artifact 폴더를 준비한다.
```bash
./iop-edge bootstrap pack
```
기본 산출물:
```text
<bundle-dir>/artifacts/
bootstrap/node-<os>-<arch>.sh
<os>-<arch>/iop-node
<os>-<arch>/SHA256SUMS
```
Mac Node host용 artifact가 필요하면 repo root에서 대상 platform을 지정해 pack한다.
```bash
make pack-node-target NODE_TARGET=darwin-arm64
```
Intel Mac이면 `NODE_TARGET=darwin-amd64`를 사용한다.
## 6. Node 등록과 bootstrap 출력
Node 정의는 `edge.yaml`을 직접 편집하지 않고 `iop-edge node register`로 만든다.
@ -121,6 +141,7 @@ Ollama Node로 등록할 때:
```bash
./iop-edge node register node-silicon-ollama \
--target darwin-arm64 \
--adapter ollama \
--ollama-base-url http://127.0.0.1:11434
```
@ -136,7 +157,7 @@ curl -fsSL <bootstrap-url> | bash -s <generated-token>
사용자는 이 한 줄만 Node host에서 실행한다.
전달받은 명령에 `IOP_ARTIFACT_BASE_URL=`, `IOP_EDGE_ADDR=`, `IOP_NODE_TOKEN=` 같은 named parameter가 있으면 기본 경로가 아니다.
## 6. Config 검증
## 7. Config 검증
```bash
./iop-edge config check
@ -144,7 +165,7 @@ curl -fsSL <bootstrap-url> | bash -s <generated-token>
이 단계에서 `nodes[]` token/id/alias 충돌, adapter/runtime 설정 오류가 잡혀야 한다.
## 7. Edge 실행
## 8. Edge 실행
개발 중 foreground 실행:
@ -152,6 +173,9 @@ curl -fsSL <bootstrap-url> | bash -s <generated-token>
./iop-edge serve
```
`serve`는 Edge transport와 함께 bootstrap artifact 서버도 시작한다.
기본 artifact 경로는 `<bundle-dir>/artifacts/`이고 기본 bootstrap URL 포트는 `18080`이다.
서비스로 올릴 때:
```bash
@ -164,7 +188,7 @@ sudo ./iop-edge setup --enable --start
tail -f logs/edge.log
```
## 8. Node host에서 bootstrap 실행
## 9. Node host에서 bootstrap 실행
`iop-edge node register ...`가 출력한 한 줄을 Node host에서 그대로 실행한다.
@ -175,21 +199,33 @@ curl -fsSL <bootstrap-url> | bash -s <generated-token>
Node host 사용자는 `node.yaml`을 만들거나 편집하지 않는다.
Node 프로세스 실행 방식은 bootstrap 내부 구현 세부다.
## 9. 연결 확인
## 10. 연결 확인
Edge host에서 확인한다.
Edge host 로그에서 Node 등록 이벤트를 확인한다.
```bash
tail -f logs/edge.log
```
기대 로그:
```text
node registered
```
설정에 등록된 Node 목록은 아래 명령으로 확인한다.
```bash
./iop-edge nodes list
```
OpenAI-compatible smoke:
OpenAI-compatible smoke는 OpenAI 입력 표면과 Ollama model 설정이 완료된 뒤 실행한다.
```bash
./iop-edge smoke openai --model gemma4:26b
```
## 10. Cline 접속 정보 확인
## 11. Cline 접속 정보 확인
```bash
./iop-edge env
@ -205,7 +241,7 @@ API Key: ASCII 값
API Key에는 한글이나 non-ASCII 문자를 넣지 않는다.
## 11. 문제 발생 시 공유할 것
## 12. 문제 발생 시 공유할 것
```bash
./iop-edge env

View file

@ -51,6 +51,8 @@ type EdgeServerConf struct {
type EdgeBootstrapConf struct {
ArtifactBaseURL string `mapstructure:"artifact_base_url" yaml:"artifact_base_url"`
Listen string `mapstructure:"listen" yaml:"listen"`
ArtifactDir string `mapstructure:"artifact_dir" yaml:"artifact_dir"`
}
type EdgeOpenAIConf struct {
@ -222,6 +224,8 @@ func setDefaults(v *viper.Viper) {
func setEdgeDefaults(v *viper.Viper) {
v.SetDefault("server.listen", "0.0.0.0:9090")
v.SetDefault("bootstrap.listen", "0.0.0.0:18080")
v.SetDefault("bootstrap.artifact_dir", "artifacts")
v.SetDefault("openai.enabled", false)
v.SetDefault("openai.listen", "0.0.0.0:8080")
v.SetDefault("openai.adapter", "ollama")

View file

@ -14,7 +14,9 @@ server:
advertise_host: ""
bootstrap:
listen: "0.0.0.0:18080"
artifact_base_url: ""
artifact_dir: "artifacts"
tls:
enabled: false
@ -43,7 +45,9 @@ server:
advertise_host: ""
bootstrap:
listen: "0.0.0.0:18080"
artifact_base_url: ""
artifact_dir: "artifacts"
tls:
enabled: false