feat: control-plane and hostsetup updates - add main_test.go, hostsetup package, update configs and docs
This commit is contained in:
parent
a52ecb8efe
commit
7880f1266f
11 changed files with 804 additions and 13 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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로 둔다.
|
||||
|
||||
## 계획된 기능
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
165
apps/control-plane/cmd/control-plane/main_test.go
Normal file
165
apps/control-plane/cmd/control-plane/main_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
221
packages/hostsetup/setup.go
Normal 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)
|
||||
}
|
||||
251
packages/hostsetup/setup_test.go
Normal file
251
packages/hostsetup/setup_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
27
packages/hostsetup/systemd.go
Normal file
27
packages/hostsetup/systemd.go
Normal 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()
|
||||
}
|
||||
55
packages/hostsetup/templates.go
Normal file
55
packages/hostsetup/templates.go
Normal 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,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue