From 7880f1266f7c30885f2029fa44fd3e57774214f6 Mon Sep 17 00:00:00 2001 From: toki Date: Tue, 19 May 2026 22:12:27 +0900 Subject: [PATCH] feat: control-plane and hostsetup updates - add main_test.go, hostsetup package, update configs and docs --- .env.example | 4 +- apps/control-plane/README.md | 4 + apps/control-plane/cmd/control-plane/main.go | 75 +++++- .../cmd/control-plane/main_test.go | 165 ++++++++++++ configs/control-plane.yaml | 7 + docker-compose.yml | 7 +- docs/deploy-dev.md | 1 + packages/hostsetup/setup.go | 221 +++++++++++++++ packages/hostsetup/setup_test.go | 251 ++++++++++++++++++ packages/hostsetup/systemd.go | 27 ++ packages/hostsetup/templates.go | 55 ++++ 11 files changed, 804 insertions(+), 13 deletions(-) create mode 100644 apps/control-plane/cmd/control-plane/main_test.go create mode 100644 packages/hostsetup/setup.go create mode 100644 packages/hostsetup/setup_test.go create mode 100644 packages/hostsetup/systemd.go create mode 100644 packages/hostsetup/templates.go diff --git a/.env.example b/.env.example index eed1d50..2292903 100644 --- a/.env.example +++ b/.env.example @@ -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: diff --git a/apps/control-plane/README.md b/apps/control-plane/README.md index df94923..2c56351 100644 --- a/apps/control-plane/README.md +++ b/apps/control-plane/README.md @@ -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로 둔다. ## 계획된 기능 diff --git a/apps/control-plane/cmd/control-plane/main.go b/apps/control-plane/cmd/control-plane/main.go index acd73b9..df5a8be 100644 --- a/apps/control-plane/cmd/control-plane/main.go +++ b/apps/control-plane/cmd/control-plane/main.go @@ -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 +} diff --git a/apps/control-plane/cmd/control-plane/main_test.go b/apps/control-plane/cmd/control-plane/main_test.go new file mode 100644 index 0000000..9d2f585 --- /dev/null +++ b/apps/control-plane/cmd/control-plane/main_test.go @@ -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 +} diff --git a/configs/control-plane.yaml b/configs/control-plane.yaml index 127f277..750e0ba 100644 --- a/configs/control-plane.yaml +++ b/configs/control-plane.yaml @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml index cfb4d2e..74b5973 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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" diff --git a/docs/deploy-dev.md b/docs/deploy-dev.md index ccb777e..f89467c 100644 --- a/docs/deploy-dev.md +++ b/docs/deploy-dev.md @@ -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`의 로컬 기본값을 덮어쓴다. 헬스체크는 다음 명령으로 확인한다. diff --git a/packages/hostsetup/setup.go b/packages/hostsetup/setup.go new file mode 100644 index 0000000..29a6a6f --- /dev/null +++ b/packages/hostsetup/setup.go @@ -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) +} diff --git a/packages/hostsetup/setup_test.go b/packages/hostsetup/setup_test.go new file mode 100644 index 0000000..b1281cd --- /dev/null +++ b/packages/hostsetup/setup_test.go @@ -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) + } + } +} diff --git a/packages/hostsetup/systemd.go b/packages/hostsetup/systemd.go new file mode 100644 index 0000000..9fc1441 --- /dev/null +++ b/packages/hostsetup/systemd.go @@ -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() +} diff --git a/packages/hostsetup/templates.go b/packages/hostsetup/templates.go new file mode 100644 index 0000000..abe3fe7 --- /dev/null +++ b/packages/hostsetup/templates.go @@ -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, + } +}