feat: control-plane and hostsetup updates - add main_test.go, hostsetup package, update configs and docs

This commit is contained in:
toki 2026-05-19 22:12:27 +09:00
parent a52ecb8efe
commit 7880f1266f
11 changed files with 804 additions and 13 deletions

View file

@ -6,7 +6,9 @@ IOP_WEB_PORT=3000
IOP_POSTGRES_USER=iop
IOP_POSTGRES_PASSWORD=iop_dev_password
IOP_POSTGRES_DB=iop
IOP_POSTGRES_DB=iop-control-plane-dev
IOP_POSTGRES_PORT=5432
IOP_REDIS_PORT=6379
IOP_REDIS_DB=2
IOP_REDIS_KEY_PREFIX=iop:control-plane:dev:

View file

@ -13,6 +13,10 @@ IOP의 프론트엔드는 Control Plane 내부 UI가 아니라 `apps/web`의 Rea
- `GET /healthz`
- `GET /readyz`
- protobuf-socket wire endpoint 예약: `server.wire_listen`
- Control Plane DB 접속 설정 예약: `database.url`
- Control Plane Redis 접속 설정 예약: `redis.url`, `redis.key_prefix`
로컬 직접 실행 테스트는 `configs/control-plane.yaml``database.url`, `redis.url`, `redis.key_prefix`를 사용한다. Postgres 기본 DB명은 `iop-control-plane-local`이고 Redis는 `code-server-redis`의 DB 1과 `iop:control-plane:local:` key prefix를 사용한다. dev compose 환경은 `IOP_DATABASE_URL`, `IOP_REDIS_URL`, `IOP_REDIS_KEY_PREFIX` 환경변수로 override하고 Postgres 기본 DB명을 `iop-control-plane-dev`, Redis DB를 2와 `iop:control-plane:dev:` key prefix로 둔다.
## 계획된 기능

View file

@ -4,8 +4,10 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"os"
"os/signal"
"strings"
"time"
"github.com/spf13/cobra"
@ -23,6 +25,13 @@ type controlPlaneConfig struct {
Listen string `yaml:"listen"`
WireListen string `yaml:"wire_listen"`
} `yaml:"server"`
Database struct {
URL string `yaml:"url"`
} `yaml:"database"`
Redis struct {
URL string `yaml:"url"`
KeyPrefix string `yaml:"key_prefix"`
} `yaml:"redis"`
Logging struct {
Level string `yaml:"level"`
Pretty bool `yaml:"pretty"`
@ -78,25 +87,40 @@ func defaultConfig() controlPlaneConfig {
var cfg controlPlaneConfig
cfg.Server.Listen = "0.0.0.0:9080"
cfg.Server.WireListen = "0.0.0.0:19080"
cfg.Database.URL = "postgres://nomadcode:nomadcode@code-server-postgres:5432/iop-control-plane-local?sslmode=disable"
cfg.Redis.URL = "redis://code-server-redis:6379/1"
cfg.Redis.KeyPrefix = "iop:control-plane:local:"
cfg.Logging.Level = "info"
return cfg
}
func loadConfig(path string) (controlPlaneConfig, error) {
cfg := defaultConfig()
if path == "" {
return cfg, nil
}
b, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := yaml.Unmarshal(b, &cfg); err != nil {
return cfg, err
if path != "" {
b, err := os.ReadFile(path)
if err != nil {
return cfg, err
}
if err := yaml.Unmarshal(b, &cfg); err != nil {
return cfg, err
}
}
applyEnvOverrides(&cfg)
return cfg, nil
}
func applyEnvOverrides(cfg *controlPlaneConfig) {
if databaseURL := os.Getenv("IOP_DATABASE_URL"); databaseURL != "" {
cfg.Database.URL = databaseURL
}
if redisURL := os.Getenv("IOP_REDIS_URL"); redisURL != "" {
cfg.Redis.URL = redisURL
}
if redisKeyPrefix := os.Getenv("IOP_REDIS_KEY_PREFIX"); redisKeyPrefix != "" {
cfg.Redis.KeyPrefix = redisKeyPrefix
}
}
func run(ctx context.Context, cfg controlPlaneConfig, logger *zap.Logger) error {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
@ -113,6 +137,12 @@ func run(ctx context.Context, cfg controlPlaneConfig, logger *zap.Logger) error
zap.String("protocol", wire.Protocol),
zap.String("listen", wireEndpoint.Listen),
)
if cfg.Database.URL != "" {
logger.Info("control-plane database configured", databaseLogFields(cfg.Database.URL)...)
}
if cfg.Redis.URL != "" {
logger.Info("control-plane redis configured", redisLogFields(cfg.Redis.URL, cfg.Redis.KeyPrefix)...)
}
// TODO(control-plane): start the protobuf-socket listener here and use it
// for Portal-Control Plane and Control Plane-Edge message sessions.
// Keep net/http limited to health, readiness, and bootstrap endpoints.
@ -142,3 +172,30 @@ func run(ctx context.Context, cfg controlPlaneConfig, logger *zap.Logger) error
return err
}
}
func databaseLogFields(rawURL string) []zap.Field {
return serviceURLLogFields(rawURL)
}
func redisLogFields(rawURL string, keyPrefix string) []zap.Field {
fields := serviceURLLogFields(rawURL)
if keyPrefix != "" {
fields = append(fields, zap.String("key_prefix", keyPrefix))
}
return fields
}
func serviceURLLogFields(rawURL string) []zap.Field {
fields := []zap.Field{zap.Bool("configured", true)}
parsed, err := url.Parse(rawURL)
if err != nil {
return fields
}
if parsed.Host != "" {
fields = append(fields, zap.String("host", parsed.Host))
}
if dbName := strings.TrimPrefix(parsed.Path, "/"); dbName != "" {
fields = append(fields, zap.String("database", dbName))
}
return fields
}

View file

@ -0,0 +1,165 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
)
func TestLoadConfigDatabaseDefaultsToLocal(t *testing.T) {
t.Setenv("IOP_DATABASE_URL", "")
t.Setenv("IOP_REDIS_URL", "")
t.Setenv("IOP_REDIS_KEY_PREFIX", "")
cfg, err := loadConfig("")
if err != nil {
t.Fatalf("load config: %v", err)
}
const wantDatabase = "postgres://nomadcode:nomadcode@code-server-postgres:5432/iop-control-plane-local?sslmode=disable"
if cfg.Database.URL != wantDatabase {
t.Fatalf("database url: got %q want %q", cfg.Database.URL, wantDatabase)
}
const wantRedis = "redis://code-server-redis:6379/1"
if cfg.Redis.URL != wantRedis {
t.Fatalf("redis url: got %q want %q", cfg.Redis.URL, wantRedis)
}
const wantRedisKeyPrefix = "iop:control-plane:local:"
if cfg.Redis.KeyPrefix != wantRedisKeyPrefix {
t.Fatalf("redis key prefix: got %q want %q", cfg.Redis.KeyPrefix, wantRedisKeyPrefix)
}
}
func TestLoadConfigDatabaseFromYAML(t *testing.T) {
t.Setenv("IOP_DATABASE_URL", "")
t.Setenv("IOP_REDIS_URL", "")
t.Setenv("IOP_REDIS_KEY_PREFIX", "")
path := writeConfig(t, `
server:
listen: "127.0.0.1:9080"
database:
url: "postgres://user:pass@db:5432/iop-control-plane-local?sslmode=disable"
redis:
url: "redis://cache:6379/1"
key_prefix: "iop:test:"
`)
cfg, err := loadConfig(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
const want = "postgres://user:pass@db:5432/iop-control-plane-local?sslmode=disable"
if cfg.Database.URL != want {
t.Fatalf("database url: got %q want %q", cfg.Database.URL, want)
}
if cfg.Redis.URL != "redis://cache:6379/1" {
t.Fatalf("redis url: got %q", cfg.Redis.URL)
}
if cfg.Redis.KeyPrefix != "iop:test:" {
t.Fatalf("redis key prefix: got %q", cfg.Redis.KeyPrefix)
}
}
func TestLoadConfigRepositoryLocalConfig(t *testing.T) {
t.Setenv("IOP_DATABASE_URL", "")
t.Setenv("IOP_REDIS_URL", "")
t.Setenv("IOP_REDIS_KEY_PREFIX", "")
cfg, err := loadConfig("../../../../configs/control-plane.yaml")
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.Redis.URL != "redis://code-server-redis:6379/1" {
t.Fatalf("redis url: got %q", cfg.Redis.URL)
}
if cfg.Redis.KeyPrefix != "iop:control-plane:local:" {
t.Fatalf("redis key prefix: got %q", cfg.Redis.KeyPrefix)
}
}
func TestLoadConfigEnvOverrides(t *testing.T) {
path := writeConfig(t, `
database:
url: "postgres://user:pass@db:5432/iop-control-plane-local?sslmode=disable"
redis:
url: "redis://cache:6379/1"
key_prefix: "iop:control-plane:local:"
`)
const wantDatabase = "postgres://user:pass@db:5432/iop-control-plane-dev?sslmode=disable"
const wantRedis = "redis://redis:6379/2"
const wantRedisKeyPrefix = "iop:control-plane:dev:"
t.Setenv("IOP_DATABASE_URL", wantDatabase)
t.Setenv("IOP_REDIS_URL", wantRedis)
t.Setenv("IOP_REDIS_KEY_PREFIX", wantRedisKeyPrefix)
cfg, err := loadConfig(path)
if err != nil {
t.Fatalf("load config: %v", err)
}
if cfg.Database.URL != wantDatabase {
t.Fatalf("database url: got %q want %q", cfg.Database.URL, wantDatabase)
}
if cfg.Redis.URL != wantRedis {
t.Fatalf("redis url: got %q want %q", cfg.Redis.URL, wantRedis)
}
if cfg.Redis.KeyPrefix != wantRedisKeyPrefix {
t.Fatalf("redis key prefix: got %q want %q", cfg.Redis.KeyPrefix, wantRedisKeyPrefix)
}
}
func TestDatabaseLogFieldsDoNotExposeCredentials(t *testing.T) {
fields := databaseLogFields("postgres://user:secret@db.example:5432/iop-control-plane-local?sslmode=disable")
var host, database string
for _, field := range fields {
switch field.Key {
case "host":
host = field.String
case "database":
database = field.String
}
}
if host != "db.example:5432" {
t.Fatalf("host field: got %q", host)
}
if database != "iop-control-plane-local" {
t.Fatalf("database field: got %q", database)
}
if strings.Contains(fmt.Sprint(fields), "secret") {
t.Fatal("secret leaked into log fields")
}
}
func TestRedisLogFields(t *testing.T) {
fields := redisLogFields("redis://:secret@cache.example:6379/2", "iop:control-plane:dev:")
var host, database, keyPrefix string
for _, field := range fields {
switch field.Key {
case "host":
host = field.String
case "database":
database = field.String
case "key_prefix":
keyPrefix = field.String
}
}
if host != "cache.example:6379" {
t.Fatalf("host field: got %q", host)
}
if database != "2" {
t.Fatalf("database field: got %q", database)
}
if keyPrefix != "iop:control-plane:dev:" {
t.Fatalf("key_prefix field: got %q", keyPrefix)
}
if strings.Contains(fmt.Sprint(fields), "secret") {
t.Fatal("secret leaked into log fields")
}
}
func writeConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "control-plane.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
return path
}

View file

@ -2,6 +2,13 @@ server:
listen: "0.0.0.0:9080"
wire_listen: "0.0.0.0:19080"
database:
url: "postgres://nomadcode:nomadcode@code-server-postgres:5432/iop-control-plane-local?sslmode=disable"
redis:
url: "redis://code-server-redis:6379/1"
key_prefix: "iop:control-plane:local:"
tls:
enabled: false

View file

@ -7,7 +7,7 @@ services:
environment:
POSTGRES_USER: ${IOP_POSTGRES_USER:-iop}
POSTGRES_PASSWORD: ${IOP_POSTGRES_PASSWORD:-iop_dev_password}
POSTGRES_DB: ${IOP_POSTGRES_DB:-iop}
POSTGRES_DB: ${IOP_POSTGRES_DB:-iop-control-plane-dev}
ports:
- "${IOP_POSTGRES_PORT:-5432}:5432"
volumes:
@ -38,8 +38,9 @@ services:
dockerfile: go-iop/apps/control-plane/Dockerfile
restart: unless-stopped
environment:
IOP_DATABASE_URL: "postgres://${IOP_POSTGRES_USER:-iop}:${IOP_POSTGRES_PASSWORD:-iop_dev_password}@postgres:5432/${IOP_POSTGRES_DB:-iop}?sslmode=disable"
IOP_REDIS_URL: "redis://redis:6379/0"
IOP_DATABASE_URL: "postgres://${IOP_POSTGRES_USER:-iop}:${IOP_POSTGRES_PASSWORD:-iop_dev_password}@postgres:5432/${IOP_POSTGRES_DB:-iop-control-plane-dev}?sslmode=disable"
IOP_REDIS_URL: "redis://redis:6379/${IOP_REDIS_DB:-2}"
IOP_REDIS_KEY_PREFIX: "${IOP_REDIS_KEY_PREFIX:-iop:control-plane:dev:}"
ports:
- "${IOP_CONTROL_PLANE_HTTP_PORT:-9080}:9080"
- "${IOP_CONTROL_PLANE_WIRE_PORT:-19080}:19080"

View file

@ -71,6 +71,7 @@ docker compose up --build -d
| redis | 6379 | Control Plane cache/queue 후보 |
현재 Control Plane 구현은 DB/Redis를 아직 사용하지 않지만, dev compose에는 먼저 포함해 이후 schema, queue, audit, event 처리 작업을 같은 배포 단위 안에서 진행할 수 있게 둔다.
로컬에서 Control Plane 바이너리를 직접 실행하는 테스트는 외부 `code-server-postgres``iop-control-plane-local` DB와 외부 `code-server-redis`의 Redis DB 1, `iop:control-plane:local:` key prefix를 사용한다. dev compose 환경은 Postgres `iop-control-plane-dev` DB와 Redis DB 2, `iop:control-plane:dev:` key prefix를 사용한다. compose의 `control-plane` 서비스는 `IOP_DATABASE_URL`, `IOP_REDIS_URL`, `IOP_REDIS_KEY_PREFIX` 환경변수로 dev 값을 지정해 `configs/control-plane.yaml`의 로컬 기본값을 덮어쓴다.
헬스체크는 다음 명령으로 확인한다.

221
packages/hostsetup/setup.go Normal file
View file

@ -0,0 +1,221 @@
package hostsetup
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
)
type AppSpec struct {
Name string
Description string
DefaultConfig string
DefaultDataDir string
DefaultUnit string
ConfigTemplate string
}
type SetupOptions struct {
BinaryPath string
ConfigPath string
DataDir string
UnitPath string
User string
Group string
Enable bool
Start bool
Restart bool
DryRun bool
OverwriteConfig bool
Runner CommandRunner
Getuid func() int
}
type CommandRunner interface {
Run(ctx context.Context, name string, args ...string) ([]byte, error)
}
type execRunner struct{}
func (execRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).CombinedOutput()
}
const defaultUser = "iop"
func Run(ctx context.Context, spec AppSpec, opts SetupOptions, out io.Writer) error {
if out == nil {
out = io.Discard
}
if err := applyDefaults(spec, &opts); err != nil {
return err
}
plan := buildPlan(spec, opts)
fmt.Fprintln(out, plan)
unit := renderUnit(spec, opts)
if opts.DryRun {
fmt.Fprintln(out, "--- unit preview ---")
fmt.Fprintln(out, unit)
fmt.Fprintln(out, "--- config preview ---")
fmt.Fprintln(out, spec.ConfigTemplate)
return nil
}
if needsRoot(opts) && opts.Getuid() != 0 {
return fmt.Errorf("hostsetup: root required to write %s and %s", opts.UnitPath, opts.ConfigPath)
}
if err := ensureUserGroup(ctx, opts, out); err != nil {
return err
}
if err := ensureDirs(opts); err != nil {
return err
}
if err := ensureConfig(spec, opts, out); err != nil {
return err
}
if err := writeUnit(opts, unit); err != nil {
return err
}
if _, err := opts.Runner.Run(ctx, "systemctl", "daemon-reload"); err != nil {
return fmt.Errorf("systemctl daemon-reload: %w", err)
}
if opts.Enable {
if _, err := opts.Runner.Run(ctx, "systemctl", "enable", spec.Name); err != nil {
return fmt.Errorf("systemctl enable: %w", err)
}
}
if opts.Restart {
if _, err := opts.Runner.Run(ctx, "systemctl", "restart", spec.Name); err != nil {
return fmt.Errorf("systemctl restart: %w", err)
}
} else if opts.Start {
if _, err := opts.Runner.Run(ctx, "systemctl", "start", spec.Name); err != nil {
return fmt.Errorf("systemctl start: %w", err)
}
}
return nil
}
func applyDefaults(spec AppSpec, opts *SetupOptions) error {
if spec.Name == "" {
return errors.New("hostsetup: spec.Name required")
}
if opts.BinaryPath == "" {
bin, err := os.Executable()
if err != nil {
return fmt.Errorf("resolve binary: %w", err)
}
opts.BinaryPath = bin
}
if opts.ConfigPath == "" {
opts.ConfigPath = spec.DefaultConfig
}
if opts.DataDir == "" {
opts.DataDir = spec.DefaultDataDir
}
if opts.UnitPath == "" {
opts.UnitPath = spec.DefaultUnit
}
if opts.User == "" {
opts.User = defaultUser
}
if opts.Group == "" {
opts.Group = opts.User
}
if opts.Runner == nil {
opts.Runner = execRunner{}
}
if opts.Getuid == nil {
opts.Getuid = os.Geteuid
}
return nil
}
func needsRoot(opts SetupOptions) bool {
return isSystemPath(opts.UnitPath) || isSystemPath(opts.ConfigPath) || isSystemPath(opts.DataDir)
}
func isSystemPath(p string) bool {
return strings.HasPrefix(p, "/etc/") || strings.HasPrefix(p, "/var/lib/") || strings.HasPrefix(p, "/usr/")
}
func buildPlan(spec AppSpec, opts SetupOptions) string {
var b strings.Builder
fmt.Fprintf(&b, "hostsetup plan for %s\n", spec.Name)
fmt.Fprintf(&b, " binary : %s\n", opts.BinaryPath)
fmt.Fprintf(&b, " config : %s\n", opts.ConfigPath)
fmt.Fprintf(&b, " data : %s\n", opts.DataDir)
fmt.Fprintf(&b, " unit : %s\n", opts.UnitPath)
fmt.Fprintf(&b, " user : %s:%s\n", opts.User, opts.Group)
fmt.Fprintf(&b, " enable=%t start=%t restart=%t dry-run=%t overwrite-config=%t",
opts.Enable, opts.Start, opts.Restart, opts.DryRun, opts.OverwriteConfig)
return b.String()
}
func ensureUserGroup(ctx context.Context, opts SetupOptions, out io.Writer) error {
if _, err := opts.Runner.Run(ctx, "getent", "group", opts.Group); err != nil {
if _, err := opts.Runner.Run(ctx, "groupadd", "--system", opts.Group); err != nil {
return fmt.Errorf("groupadd %s: %w", opts.Group, err)
}
fmt.Fprintf(out, "created group %s\n", opts.Group)
}
if _, err := opts.Runner.Run(ctx, "id", "-u", opts.User); err != nil {
if _, err := opts.Runner.Run(ctx, "useradd",
"--system", "--gid", opts.Group,
"--home-dir", opts.DataDir, "--shell", "/usr/sbin/nologin",
opts.User); err != nil {
return fmt.Errorf("useradd %s: %w", opts.User, err)
}
fmt.Fprintf(out, "created user %s\n", opts.User)
}
return nil
}
func ensureDirs(opts SetupOptions) error {
if dir := filepath.Dir(opts.ConfigPath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
}
if opts.DataDir != "" {
if err := os.MkdirAll(opts.DataDir, 0o750); err != nil {
return fmt.Errorf("mkdir %s: %w", opts.DataDir, err)
}
}
if dir := filepath.Dir(opts.UnitPath); dir != "" && dir != "." {
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", dir, err)
}
}
return nil
}
func ensureConfig(spec AppSpec, opts SetupOptions, out io.Writer) error {
_, err := os.Stat(opts.ConfigPath)
switch {
case err == nil && !opts.OverwriteConfig:
fmt.Fprintf(out, "config %s already exists, skipping\n", opts.ConfigPath)
return nil
case err == nil && opts.OverwriteConfig:
fmt.Fprintf(out, "overwriting config %s\n", opts.ConfigPath)
case errors.Is(err, os.ErrNotExist):
fmt.Fprintf(out, "writing config %s\n", opts.ConfigPath)
default:
return fmt.Errorf("stat config: %w", err)
}
return os.WriteFile(opts.ConfigPath, []byte(spec.ConfigTemplate), 0o600)
}
func writeUnit(opts SetupOptions, unit string) error {
return os.WriteFile(opts.UnitPath, []byte(unit), 0o644)
}

View file

@ -0,0 +1,251 @@
package hostsetup
import (
"bytes"
"context"
"os"
"path/filepath"
"strings"
"testing"
)
type fakeRunner struct {
calls [][]string
missing map[string]bool // name+arg0 lookups that should return error to simulate absence
failures map[string]error
}
func newFakeRunner() *fakeRunner {
return &fakeRunner{
missing: map[string]bool{},
failures: map[string]error{},
}
}
func (f *fakeRunner) Run(_ context.Context, name string, args ...string) ([]byte, error) {
rec := append([]string{name}, args...)
f.calls = append(f.calls, rec)
key := strings.Join(rec, " ")
if err, ok := f.failures[key]; ok {
return nil, err
}
// getent/id lookups: return error to simulate "not found" if marked missing
if name == "getent" || name == "id" {
if f.missing[key] {
return nil, &exitErr{}
}
}
return nil, nil
}
type exitErr struct{}
func (exitErr) Error() string { return "not found" }
func (f *fakeRunner) called(name string, args ...string) bool {
want := append([]string{name}, args...)
for _, c := range f.calls {
if len(c) != len(want) {
continue
}
match := true
for i := range c {
if c[i] != want[i] {
match = false
break
}
}
if match {
return true
}
}
return false
}
func testSpec(dir string) AppSpec {
return AppSpec{
Name: "iop-test",
Description: "iop test",
DefaultConfig: filepath.Join(dir, "etc", "iop", "test.yaml"),
DefaultDataDir: filepath.Join(dir, "var", "lib", "iop"),
DefaultUnit: filepath.Join(dir, "etc", "systemd", "system", "iop-test.service"),
ConfigTemplate: "key: value\n",
}
}
func baseOpts(runner CommandRunner) SetupOptions {
return SetupOptions{
BinaryPath: "/usr/local/bin/iop-test",
Enable: true,
Start: true,
Runner: runner,
Getuid: func() int { return 0 },
}
}
func TestRunDryRunDoesNotWriteFiles(t *testing.T) {
dir := t.TempDir()
spec := testSpec(dir)
runner := newFakeRunner()
opts := baseOpts(runner)
opts.DryRun = true
var out bytes.Buffer
if err := Run(context.Background(), spec, opts, &out); err != nil {
t.Fatalf("Run: %v", err)
}
if _, err := os.Stat(spec.DefaultConfig); !os.IsNotExist(err) {
t.Fatalf("config should not exist after dry-run: err=%v", err)
}
if _, err := os.Stat(spec.DefaultUnit); !os.IsNotExist(err) {
t.Fatalf("unit should not exist after dry-run: err=%v", err)
}
if len(runner.calls) != 0 {
t.Fatalf("dry-run should not invoke runner, got %v", runner.calls)
}
if !strings.Contains(out.String(), "unit preview") {
t.Fatalf("dry-run output missing unit preview: %s", out.String())
}
if !strings.Contains(out.String(), "config preview") {
t.Fatalf("dry-run output missing config preview: %s", out.String())
}
}
func TestRunWritesMissingConfigAndUnit(t *testing.T) {
dir := t.TempDir()
spec := testSpec(dir)
runner := newFakeRunner()
// user/group do not exist yet
runner.missing["getent group iop"] = true
runner.missing["id -u iop"] = true
opts := baseOpts(runner)
var out bytes.Buffer
if err := Run(context.Background(), spec, opts, &out); err != nil {
t.Fatalf("Run: %v", err)
}
cfg, err := os.ReadFile(spec.DefaultConfig)
if err != nil {
t.Fatalf("read config: %v", err)
}
if string(cfg) != spec.ConfigTemplate {
t.Fatalf("config content mismatch: %q", cfg)
}
info, err := os.Stat(spec.DefaultConfig)
if err != nil {
t.Fatalf("stat config: %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("config mode: want 0600 got %o", info.Mode().Perm())
}
unit, err := os.ReadFile(spec.DefaultUnit)
if err != nil {
t.Fatalf("read unit: %v", err)
}
if !strings.Contains(string(unit), "ExecStart=/usr/local/bin/iop-test serve --config "+spec.DefaultConfig) {
t.Fatalf("unit ExecStart wrong: %s", unit)
}
uinfo, err := os.Stat(spec.DefaultUnit)
if err != nil {
t.Fatalf("stat unit: %v", err)
}
if uinfo.Mode().Perm() != 0o644 {
t.Fatalf("unit mode: want 0644 got %o", uinfo.Mode().Perm())
}
if _, err := os.Stat(spec.DefaultDataDir); err != nil {
t.Fatalf("data dir missing: %v", err)
}
if !runner.called("groupadd", "--system", "iop") {
t.Fatalf("groupadd not called: %v", runner.calls)
}
if !runner.called("useradd", "--system", "--gid", "iop", "--home-dir", spec.DefaultDataDir, "--shell", "/usr/sbin/nologin", "iop") {
t.Fatalf("useradd not called: %v", runner.calls)
}
if !runner.called("systemctl", "daemon-reload") {
t.Fatalf("daemon-reload not called")
}
if !runner.called("systemctl", "enable", "iop-test") {
t.Fatalf("enable not called")
}
if !runner.called("systemctl", "start", "iop-test") {
t.Fatalf("start not called")
}
}
func TestRunDoesNotOverwriteExistingConfig(t *testing.T) {
dir := t.TempDir()
spec := testSpec(dir)
runner := newFakeRunner()
opts := baseOpts(runner)
if err := os.MkdirAll(filepath.Dir(spec.DefaultConfig), 0o755); err != nil {
t.Fatal(err)
}
existing := "preserved: true\n"
if err := os.WriteFile(spec.DefaultConfig, []byte(existing), 0o600); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
if err := Run(context.Background(), spec, opts, &out); err != nil {
t.Fatalf("Run: %v", err)
}
got, err := os.ReadFile(spec.DefaultConfig)
if err != nil {
t.Fatal(err)
}
if string(got) != existing {
t.Fatalf("config was overwritten: %q", got)
}
// With OverwriteConfig=true, it should overwrite.
opts.OverwriteConfig = true
if err := Run(context.Background(), spec, opts, &out); err != nil {
t.Fatalf("Run overwrite: %v", err)
}
got, err = os.ReadFile(spec.DefaultConfig)
if err != nil {
t.Fatal(err)
}
if string(got) != spec.ConfigTemplate {
t.Fatalf("overwrite did not apply: %q", got)
}
}
func TestRenderUnit(t *testing.T) {
spec := AppSpec{Name: "iop-edge", Description: "IOP Edge"}
opts := SetupOptions{
BinaryPath: "/usr/local/bin/iop-edge",
ConfigPath: "/etc/iop/edge.yaml",
DataDir: "/var/lib/iop/edge",
User: "iop",
Group: "iop",
}
unit := renderUnit(spec, opts)
for _, want := range []string{
"[Unit]",
"Description=IOP Edge",
"After=network-online.target",
"[Service]",
"Type=simple",
"User=iop",
"Group=iop",
"ExecStart=/usr/local/bin/iop-edge serve --config /etc/iop/edge.yaml",
"WorkingDirectory=/var/lib/iop/edge",
"Restart=on-failure",
"[Install]",
"WantedBy=multi-user.target",
} {
if !strings.Contains(unit, want) {
t.Fatalf("unit missing %q\n%s", want, unit)
}
}
}

View file

@ -0,0 +1,27 @@
package hostsetup
import (
"fmt"
"strings"
)
func renderUnit(spec AppSpec, opts SetupOptions) string {
var b strings.Builder
fmt.Fprintf(&b, "[Unit]\n")
fmt.Fprintf(&b, "Description=%s\n", spec.Description)
fmt.Fprintf(&b, "After=network-online.target\n")
fmt.Fprintf(&b, "Wants=network-online.target\n\n")
fmt.Fprintf(&b, "[Service]\n")
fmt.Fprintf(&b, "Type=simple\n")
fmt.Fprintf(&b, "User=%s\n", opts.User)
fmt.Fprintf(&b, "Group=%s\n", opts.Group)
fmt.Fprintf(&b, "ExecStart=%s serve --config %s\n", opts.BinaryPath, opts.ConfigPath)
fmt.Fprintf(&b, "WorkingDirectory=%s\n", opts.DataDir)
fmt.Fprintf(&b, "Restart=on-failure\n")
fmt.Fprintf(&b, "RestartSec=5\n\n")
fmt.Fprintf(&b, "[Install]\n")
fmt.Fprintf(&b, "WantedBy=multi-user.target\n")
return b.String()
}

View file

@ -0,0 +1,55 @@
package hostsetup
const edgeConfigTemplate = `edge:
id: "edge-local"
name: "Local Edge"
server:
listen: "0.0.0.0:9090"
tls:
enabled: false
logging:
level: "info"
pretty: false
metrics:
port: 9092
nodes: []
`
const nodeConfigTemplate = `transport:
edge_addr: "localhost:9090"
token: "changeme"
logging:
level: "info"
pretty: false
metrics:
port: 9091
`
func EdgeSpec() AppSpec {
return AppSpec{
Name: "iop-edge",
Description: "IOP Edge — execution group controller",
DefaultConfig: "/etc/iop/edge.yaml",
DefaultDataDir: "/var/lib/iop/edge",
DefaultUnit: "/etc/systemd/system/iop-edge.service",
ConfigTemplate: edgeConfigTemplate,
}
}
func NodeSpec() AppSpec {
return AppSpec{
Name: "iop-node",
Description: "IOP Node — runtime worker",
DefaultConfig: "/etc/iop/node.yaml",
DefaultDataDir: "/var/lib/iop/node",
DefaultUnit: "/etc/systemd/system/iop-node.service",
ConfigTemplate: nodeConfigTemplate,
}
}