iop/packages/hostsetup/setup.go
toki b4124f0bd6 feat: CLI setup, edge/node transport refactor, and infrastructure updates
- Add CLI core setup for edge and node services
- Refactor edge transport layer (server, integration tests)
- Refactor node transport layer (parser, session, heartbeat, client)
- Add main_test.go files for edge and node commands
- Add input package for edge service
- Add go.work and go.work.sum for workspace support
- Update configs, docs, and project rules
2026-05-20 16:37:42 +09:00

294 lines
7.7 KiB
Go

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
}
if err := validateDataDir(opts.DataDir); 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 := chownServicePaths(ctx, opts); 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
}
var unsafeDataDirs = map[string]bool{
"/": true,
"/etc": true,
"/home": true,
"/opt": true,
"/root": true,
"/srv": true,
"/tmp": true,
"/usr": true,
"/usr/lib": true,
"/usr/local": true,
"/var": true,
"/var/lib": true,
"/var/log": true,
"/var/run": true,
}
func ensureTraversable(path string) error {
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat %s: %w", path, err)
}
cur := info.Mode().Perm()
wanted := cur | 0o011 // g+x, o+x — minimum to traverse for service user
if wanted == cur {
return nil
}
if err := os.Chmod(path, wanted); err != nil {
return fmt.Errorf("chmod %s: %w", path, err)
}
return nil
}
func validateDataDir(p string) error {
if p == "" {
return nil
}
cleaned := filepath.Clean(p)
if unsafeDataDirs[cleaned] {
return fmt.Errorf("hostsetup: data dir %q is not an app-specific path; refuse to recursively chown", cleaned)
}
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 parent := filepath.Dir(opts.DataDir); parent != "" && parent != "." && parent != "/" {
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", parent, err)
}
if err := ensureTraversable(parent); err != nil {
return err
}
}
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)
}
func chownServicePaths(ctx context.Context, opts SetupOptions) error {
owner := opts.User + ":" + opts.Group
if opts.DataDir != "" {
if _, err := opts.Runner.Run(ctx, "chown", "-R", owner, opts.DataDir); err != nil {
return fmt.Errorf("chown %s %s: %w", owner, opts.DataDir, err)
}
}
if opts.ConfigPath != "" {
if _, err := opts.Runner.Run(ctx, "chown", owner, opts.ConfigPath); err != nil {
return fmt.Errorf("chown %s %s: %w", owner, opts.ConfigPath, err)
}
}
return nil
}