iop/apps/control-plane/cmd/control-plane/main.go
toki 4c8441e6c9 feat(credential): Provider Credential Slot 라우팅을 구현한다
사용자별 credential 저장, lease, projection, runtime 전달과 OpenAI-compatible 계약 및 검증 근거를 함께 반영한다.
2026-08-02 09:10:11 +09:00

207 lines
6.5 KiB
Go

package main
import (
"context"
"fmt"
"net/url"
"os"
"os/signal"
"strings"
"github.com/spf13/cobra"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
"iop/apps/control-plane/internal/credentialseal"
"iop/packages/go/config"
"iop/packages/go/observability"
)
var cfgFile string
type controlPlaneConfig struct {
Server struct {
Listen string `yaml:"listen"`
WireListen string `yaml:"wire_listen"`
EdgeWireListen string `yaml:"edge_wire_listen"`
EdgeWireTLS config.TLSConf `yaml:"edge_wire_tls"`
} `yaml:"server"`
Database struct {
URL string `yaml:"url"`
} `yaml:"database"`
CredentialEncryption credentialseal.FileConfig `yaml:"credential_encryption"`
CredentialPlane struct {
Enabled bool `yaml:"enabled"`
HTTPS config.TLSConf `yaml:"https"`
IssuerKeyID string `yaml:"issuer_key_id"`
IssuerPrivateKey string `yaml:"issuer_private_key"`
LeaseTTLSeconds int `yaml:"lease_ttl_seconds"`
LeaseCacheSize int `yaml:"lease_cache_size"`
} `yaml:"credential_plane"`
Redis struct {
URL string `yaml:"url"`
KeyPrefix string `yaml:"key_prefix"`
} `yaml:"redis"`
Logging struct {
Level string `yaml:"level"`
Pretty bool `yaml:"pretty"`
} `yaml:"logging"`
Metrics struct {
Port int `yaml:"port"`
} `yaml:"metrics"`
}
func main() {
if err := rootCmd().Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func rootCmd() *cobra.Command {
root := &cobra.Command{
Use: "control-plane",
Short: "IOP Control Plane - multi-edge management server",
Long: `control-plane will manage Edge connections, observe Edge state,
adjust Edge configuration, deliver commands to Edge, and receive Edge events.
It controls the system through Edge, not by connecting to or scheduling Node
directly. The main IOP communication layer is protobuf-socket; net/http is kept
for health, readiness, and bootstrap endpoints.`,
}
root.PersistentFlags().StringVarP(&cfgFile, "config", "c", "configs/control-plane.yaml", "config file path")
root.AddCommand(serveCmd(), principalCmd())
return root
}
func serveCmd() *cobra.Command {
return &cobra.Command{
Use: "serve",
Short: "Start the control-plane server",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := loadConfig(cfgFile)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
logger, err := observability.NewLogger(cfg.Logging.Level, cfg.Logging.Pretty)
if err != nil {
return fmt.Errorf("logger: %w", err)
}
defer func() { _ = logger.Sync() }()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
return run(ctx, cfg, logger)
},
}
}
func defaultConfig() controlPlaneConfig {
var cfg controlPlaneConfig
cfg.Server.Listen = "0.0.0.0:18000"
cfg.Server.WireListen = "0.0.0.0:19080"
cfg.Server.EdgeWireListen = "0.0.0.0:19081"
cfg.Database.URL = ""
cfg.Redis.URL = ""
cfg.Redis.KeyPrefix = "iop:control-plane:"
cfg.Logging.Level = "info"
cfg.Metrics.Port = 0
cfg.CredentialPlane.LeaseTTLSeconds = 30
cfg.CredentialPlane.LeaseCacheSize = 256
return cfg
}
func loadConfig(path string) (controlPlaneConfig, error) {
cfg := defaultConfig()
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)
if cfg.CredentialPlane.Enabled {
if strings.TrimSpace(cfg.Database.URL) == "" || strings.TrimSpace(cfg.CredentialEncryption.KeyFile) == "" {
return cfg, fmt.Errorf("credential_plane requires database.url and credential_encryption")
}
if !cfg.CredentialPlane.HTTPS.Enabled {
return cfg, fmt.Errorf("credential_plane requires HTTPS")
}
if err := cfg.CredentialPlane.HTTPS.ValidateHTTPServer(); err != nil {
return cfg, fmt.Errorf("credential_plane.https: %w", err)
}
if !cfg.Server.EdgeWireTLS.Enabled {
return cfg, fmt.Errorf("credential_plane requires server.edge_wire_tls")
}
if err := cfg.Server.EdgeWireTLS.ValidateServer("edge"); err != nil {
return cfg, fmt.Errorf("server.edge_wire_tls: %w", err)
}
if strings.TrimSpace(cfg.CredentialPlane.IssuerKeyID) == "" || strings.TrimSpace(cfg.CredentialPlane.IssuerPrivateKey) == "" {
return cfg, fmt.Errorf("credential_plane issuer_key_id and issuer_private_key are required")
}
if cfg.CredentialPlane.LeaseTTLSeconds < config.MinCredentialLeaseTTLSeconds || cfg.CredentialPlane.LeaseTTLSeconds > config.MaxCredentialLeaseTTLSeconds {
return cfg, fmt.Errorf("credential_plane.lease_ttl_seconds must be between %d and %d", config.MinCredentialLeaseTTLSeconds, config.MaxCredentialLeaseTTLSeconds)
}
if cfg.CredentialPlane.LeaseCacheSize < config.MinCredentialCacheSize || cfg.CredentialPlane.LeaseCacheSize > config.MaxCredentialCacheSize {
return cfg, fmt.Errorf("credential_plane.lease_cache_size must be between %d and %d", config.MinCredentialCacheSize, config.MaxCredentialCacheSize)
}
}
return cfg, nil
}
func applyEnvOverrides(cfg *controlPlaneConfig) {
if listen := os.Getenv("IOP_LISTEN"); listen != "" {
cfg.Server.Listen = listen
}
if wireListen := os.Getenv("IOP_WIRE_LISTEN"); wireListen != "" {
cfg.Server.WireListen = wireListen
}
if edgeWireListen := os.Getenv("IOP_EDGE_WIRE_LISTEN"); edgeWireListen != "" {
cfg.Server.EdgeWireListen = edgeWireListen
}
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
}
if metricsPort := os.Getenv("IOP_METRICS_PORT"); metricsPort != "" {
var port int
if _, err := fmt.Sscanf(metricsPort, "%d", &port); err == nil {
cfg.Metrics.Port = port
}
}
}
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
}