341 lines
13 KiB
Go
341 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"git.ipao.vip/rogee/creator-hub/internal/creator"
|
|
"git.ipao.vip/rogee/creator-hub/internal/hub"
|
|
"git.ipao.vip/rogee/creator-hub/internal/phasea"
|
|
"git.ipao.vip/rogee/creator-hub/internal/taskstate"
|
|
"github.com/gofiber/fiber/v3"
|
|
"github.com/sirupsen/logrus"
|
|
"github.com/spf13/cobra"
|
|
"github.com/spf13/viper"
|
|
)
|
|
|
|
type config struct {
|
|
listenAddr, webDir, databaseURL, credentialStoreDir string
|
|
username, password string
|
|
aiAPIKey, aiBaseURL string
|
|
credentialMasterKey []byte
|
|
logLevel logrus.Level
|
|
}
|
|
|
|
func main() {
|
|
logrus.SetFormatter(&logrus.JSONFormatter{})
|
|
if err := execute(newCommand()); err != nil {
|
|
logrus.WithField("service", "control-plane").WithError(err).Error("service stopped")
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func execute(command *cobra.Command) error {
|
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
|
defer stop()
|
|
return command.ExecuteContext(ctx)
|
|
}
|
|
|
|
func newCommand() *cobra.Command {
|
|
command := &cobra.Command{
|
|
Use: "control-plane",
|
|
Short: "Run the CreatorHub control plane",
|
|
Args: cobra.NoArgs,
|
|
SilenceErrors: true,
|
|
SilenceUsage: true,
|
|
RunE: func(command *cobra.Command, _ []string) error {
|
|
cfg, err := loadConfig()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
credentials, err := newPersistentCredentialBridge(cfg.credentialStoreDir, cfg.credentialMasterKey)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
logrus.SetLevel(cfg.logLevel)
|
|
phaseAStore, err := phasea.Open(command.Context(), cfg.databaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer phaseAStore.Close()
|
|
hubStore, err := hub.Open(command.Context(), cfg.databaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer hubStore.Close()
|
|
creatorStore, err := creator.Open(command.Context(), cfg.databaseURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer creatorStore.Close()
|
|
creatorStore.SetSecretBridge(creatorSecretBridge{bridge: credentials})
|
|
notify := newAttentionNotifier(os.Stderr)
|
|
phaseAStore.SetTaskNotifier(notify)
|
|
hubStore.SetTaskNotifier(notify)
|
|
logStartup(cfg)
|
|
heartbeatContext, stopHeartbeat := context.WithCancel(command.Context())
|
|
heartbeatDone := make(chan struct{})
|
|
go func() {
|
|
defer close(heartbeatDone)
|
|
runtimeLeaseHeartbeat(heartbeatContext, hubStore)
|
|
}()
|
|
creatorScheduleContext, stopCreatorScheduler := context.WithCancel(command.Context())
|
|
creatorScheduleDone := make(chan struct{})
|
|
go func() {
|
|
defer close(creatorScheduleDone)
|
|
runCreatorScheduler(creatorScheduleContext, creatorStore, phaseAStore, hubStore)
|
|
}()
|
|
creatorEventContext, stopCreatorEvents := context.WithCancel(command.Context())
|
|
creatorEventDone := make(chan struct{})
|
|
creatorEventExecutor := creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore}
|
|
creatorAI := &creator.ConfiguredBailian{Store: creatorStore, APIKey: cfg.aiAPIKey, BaseURL: cfg.aiBaseURL}
|
|
go func() {
|
|
defer close(creatorEventDone)
|
|
runCreatorEventListeners(creatorEventContext, creatorStore, phaseAStore, hubStore, creatorEventExecutor, creatorAI)
|
|
}()
|
|
listenErr := newHandlerWithCreatorAndAI(cfg.webDir, cfg.username, cfg.password, phaseAStore, hubStore, credentials, creatorStore, creatorAI, creatorAI).Listen(cfg.listenAddr, fiber.ListenConfig{
|
|
GracefulContext: command.Context(),
|
|
DisableStartupMessage: true,
|
|
})
|
|
stopCreatorScheduler()
|
|
stopCreatorEvents()
|
|
stopHeartbeat()
|
|
<-creatorScheduleDone
|
|
<-creatorEventDone
|
|
<-heartbeatDone
|
|
return listenErr
|
|
},
|
|
}
|
|
return command
|
|
}
|
|
|
|
func newAttentionNotifier(output io.Writer) taskstate.Notifier {
|
|
logger := logrus.New()
|
|
logger.SetFormatter(&logrus.JSONFormatter{})
|
|
logger.SetOutput(output)
|
|
return func(transition taskstate.Transition) {
|
|
if transition.State != "policy_hold" && transition.State != "needs_confirmation" {
|
|
return
|
|
}
|
|
logger.WithFields(logrus.Fields{
|
|
"service": "control-plane", "notification_channel": "structured_log", "event_type": transition.State,
|
|
"reason_code": transition.ReasonCode, "account_id": transition.AccountID, "task_id": transition.TaskID,
|
|
}).Warn("operator attention required")
|
|
}
|
|
}
|
|
|
|
func runtimeLeaseHeartbeat(ctx context.Context, store hubStore) {
|
|
ticker := time.NewTicker(20 * time.Second)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
if err := reconcileRuntimeLeases(ctx, store, defaultNetworkExitProbe(), nil); err != nil && ctx.Err() == nil {
|
|
logrus.WithField("service", "control-plane").WithError(err).Warn("runtime lease reconciliation failed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func logStartup(cfg config) {
|
|
logrus.WithFields(logrus.Fields{
|
|
"service": "control-plane",
|
|
"listen_addr": cfg.listenAddr,
|
|
}).Info("service starting")
|
|
}
|
|
|
|
func loadConfig() (config, error) {
|
|
v := viper.New()
|
|
v.SetDefault("listen_addr", ":8080")
|
|
v.SetDefault("web_dir", "web/dist")
|
|
v.SetDefault("database_url", "postgres://creatorhub@127.0.0.1:5432/creatorhub?sslmode=disable")
|
|
v.SetDefault("credential_store_dir", "/var/lib/creatorhub/credentials")
|
|
v.SetDefault("log_level", "info")
|
|
_ = v.BindEnv("listen_addr", "LISTEN_ADDR")
|
|
_ = v.BindEnv("web_dir", "WEB_DIR")
|
|
_ = v.BindEnv("database_url", "DATABASE_URL")
|
|
_ = v.BindEnv("credential_store_dir", "CREATORHUB_CREDENTIAL_STORE_DIR")
|
|
_ = v.BindEnv("credential_master_key", "CREATORHUB_CREDENTIAL_MASTER_KEY")
|
|
_ = v.BindEnv("log_level", "LOG_LEVEL")
|
|
_ = v.BindEnv("username", "CONTROL_PLANE_USERNAME")
|
|
_ = v.BindEnv("password", "CONTROL_PLANE_PASSWORD")
|
|
_ = v.BindEnv("ai_api_key", "BAILIAN_API_KEY")
|
|
_ = v.BindEnv("ai_base_url", "BAILIAN_BASE_URL")
|
|
|
|
level, err := logrus.ParseLevel(v.GetString("log_level"))
|
|
if err != nil {
|
|
return config{}, errors.New("LOG_LEVEL must be panic, fatal, error, warn, info, debug, or trace")
|
|
}
|
|
cfg := config{
|
|
listenAddr: strings.TrimSpace(v.GetString("listen_addr")),
|
|
webDir: strings.TrimSpace(v.GetString("web_dir")),
|
|
databaseURL: strings.TrimSpace(v.GetString("database_url")),
|
|
credentialStoreDir: strings.TrimSpace(v.GetString("credential_store_dir")),
|
|
username: strings.TrimSpace(v.GetString("username")),
|
|
password: v.GetString("password"),
|
|
aiAPIKey: strings.TrimSpace(v.GetString("ai_api_key")),
|
|
aiBaseURL: strings.TrimSpace(v.GetString("ai_base_url")),
|
|
logLevel: level,
|
|
}
|
|
if cfg.listenAddr == "" {
|
|
return config{}, errors.New("LISTEN_ADDR must not be empty")
|
|
}
|
|
if err := validateListenAddr(cfg.listenAddr); err != nil {
|
|
return config{}, err
|
|
}
|
|
if cfg.webDir == "" {
|
|
return config{}, errors.New("WEB_DIR must not be empty")
|
|
}
|
|
if cfg.username == "" || strings.Contains(cfg.username, ":") {
|
|
return config{}, errors.New("CONTROL_PLANE_USERNAME must be non-empty and contain no colon")
|
|
}
|
|
if len(cfg.password) < 6 {
|
|
return config{}, errors.New("CONTROL_PLANE_PASSWORD must contain at least 6 bytes")
|
|
}
|
|
if !filepath.IsAbs(cfg.credentialStoreDir) {
|
|
return config{}, errors.New("CREATORHUB_CREDENTIAL_STORE_DIR must be absolute")
|
|
}
|
|
cfg.credentialMasterKey, err = base64.StdEncoding.DecodeString(strings.TrimSpace(v.GetString("credential_master_key")))
|
|
if err != nil || len(cfg.credentialMasterKey) != 32 {
|
|
return config{}, errors.New("CREATORHUB_CREDENTIAL_MASTER_KEY must be base64 for exactly 32 bytes")
|
|
}
|
|
databaseURL, err := url.Parse(cfg.databaseURL)
|
|
if err != nil || databaseURL.Host == "" || databaseURL.Path == "" || databaseURL.Path == "/" ||
|
|
(databaseURL.Scheme != "postgres" && databaseURL.Scheme != "postgresql") {
|
|
return config{}, errors.New("DATABASE_URL must be a postgres URL with a host")
|
|
}
|
|
if cfg.aiBaseURL != "" {
|
|
aiURL, parseErr := url.Parse(cfg.aiBaseURL)
|
|
if parseErr != nil || aiURL.Host == "" || (aiURL.Scheme != "http" && aiURL.Scheme != "https") || aiURL.User != nil {
|
|
return config{}, errors.New("BAILIAN_BASE_URL must be an HTTP(S) URL without credentials")
|
|
}
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func validateListenAddr(addr string) error {
|
|
_, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return errors.New("LISTEN_ADDR must be a host:port address")
|
|
}
|
|
number, err := strconv.Atoi(port)
|
|
if err != nil || number < 1 || number > 65535 {
|
|
return errors.New("LISTEN_ADDR port must be 1..65535")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func newHandler(webDirectory, username, password string) *fiber.App {
|
|
return newHandlerWithStores(webDirectory, username, password, nil, nil)
|
|
}
|
|
|
|
func newHandlerWithStores(webDirectory, username, password string, phaseAStore *phasea.Store, hubStore *hub.Store) *fiber.App {
|
|
return newHandlerWithCredentialBridge(webDirectory, username, password, phaseAStore, hubStore, nil)
|
|
}
|
|
|
|
type creatorSecretBridge struct {
|
|
bridge phasea.CredentialBridge
|
|
}
|
|
|
|
func (b creatorSecretBridge) Store(ctx context.Context, reference creator.SecretReference, key, value string) error {
|
|
if b.bridge == nil {
|
|
return errors.New("credential bridge is unavailable")
|
|
}
|
|
return b.bridge.Store(ctx, phasea.CredentialReference{ID: reference.ID, Provider: reference.Provider}, key, value)
|
|
}
|
|
|
|
func (b creatorSecretBridge) Delete(ctx context.Context, reference creator.SecretReference, key string) error {
|
|
if b.bridge == nil {
|
|
return errors.New("credential bridge is unavailable")
|
|
}
|
|
return b.bridge.Delete(ctx, phasea.CredentialReference{ID: reference.ID, Provider: reference.Provider}, key)
|
|
}
|
|
|
|
func newHandlerWithCredentialBridge(webDirectory, username, password string, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge) *fiber.App {
|
|
return newHandlerWithCreator(webDirectory, username, password, phaseAStore, hubStore, credentials, nil)
|
|
}
|
|
|
|
func newHandlerWithCreator(webDirectory, username, password string, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, creatorStore *creator.Store) *fiber.App {
|
|
return newHandlerWithCreatorAndAI(webDirectory, username, password, phaseAStore, hubStore, credentials, creatorStore, nil, nil)
|
|
}
|
|
|
|
func newHandlerWithCreatorAndAI(webDirectory, username, password string, phaseAStore *phasea.Store, hubStore *hub.Store, credentials phasea.CredentialBridge, creatorStore *creator.Store, generator creator.TextGenerator, analyzer creator.ThemeAnalyzer) *fiber.App {
|
|
app := fiber.New(fiber.Config{
|
|
AppName: "CreatorHub control plane",
|
|
BodyLimit: 1 << 20,
|
|
// 读超时只约束请求读取;创建/升级环境的处理器可等待网关拉取镜像(最长 11 分钟)。
|
|
ReadTimeout: 5 * time.Second,
|
|
IdleTimeout: 60 * time.Second,
|
|
})
|
|
app.Get("/healthz", func(c fiber.Ctx) error {
|
|
c.Status(fiber.StatusNoContent)
|
|
return nil
|
|
})
|
|
app.Use(authenticate(username, password))
|
|
if hubStore != nil {
|
|
registerHub(app, hubStore)
|
|
}
|
|
if phaseAStore != nil {
|
|
registerPhaseA(app, phaseAStore, hubStore, credentials)
|
|
}
|
|
if creatorStore != nil {
|
|
if generator == nil && analyzer == nil {
|
|
registerCreator(app, creatorStore, phaseAStore, hubStore)
|
|
} else {
|
|
var executor creator.ActionExecutor
|
|
if phaseAStore != nil && hubStore != nil {
|
|
executor = creatorGatewayActionExecutor{store: creatorStore, phaseAStore: phaseAStore, hubStore: hubStore}
|
|
}
|
|
registerCreatorWithServices(app, creatorStore, phaseAStore, hubStore, executor, generator, analyzer)
|
|
}
|
|
}
|
|
app.Get("/*", spaHandler(webDirectory))
|
|
return app
|
|
}
|
|
|
|
func authenticate(username, password string) fiber.Handler {
|
|
wantUser, wantPassword := sha256.Sum256([]byte(username)), sha256.Sum256([]byte(password))
|
|
return func(c fiber.Ctx) error {
|
|
encoded, ok := strings.CutPrefix(c.Get(fiber.HeaderAuthorization), "Basic ")
|
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
|
user, suppliedPassword, found := strings.Cut(string(decoded), ":")
|
|
gotUser, gotPassword := sha256.Sum256([]byte(user)), sha256.Sum256([]byte(suppliedPassword))
|
|
userOK := subtle.ConstantTimeCompare(gotUser[:], wantUser[:])
|
|
passwordOK := subtle.ConstantTimeCompare(gotPassword[:], wantPassword[:])
|
|
if !ok || err != nil || !found || userOK != 1 || passwordOK != 1 {
|
|
c.Set(fiber.HeaderWWWAuthenticate, `Basic realm="CreatorHub", charset="UTF-8"`)
|
|
return c.Status(fiber.StatusUnauthorized).JSON(map[string]string{"error": "authentication required"})
|
|
}
|
|
return c.Next()
|
|
}
|
|
}
|
|
|
|
func spaHandler(directory string) fiber.Handler {
|
|
return func(c fiber.Ctx) error {
|
|
path, err := url.PathUnescape(strings.TrimPrefix(c.Path(), "/"))
|
|
path = filepath.Clean(path)
|
|
if err == nil && filepath.IsLocal(path) {
|
|
name := filepath.Join(directory, path)
|
|
if info, err := os.Stat(name); err == nil && !info.IsDir() {
|
|
return c.SendFile(name)
|
|
}
|
|
}
|
|
return c.SendFile(filepath.Join(directory, "index.html"))
|
|
}
|
|
}
|