mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-09 09:06:36 +08:00
feat(cmd): assemble control plane via Wavelet plugins plus server
This commit is contained in:
+2
-32
@@ -1,4 +1,3 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
@@ -6,12 +5,7 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
"sync"
|
||||
|
||||
"Wavelet/OpenFlare/plugins/server/infra/task/scheduler"
|
||||
"Wavelet/OpenFlare/plugins/server/infra/task/worker"
|
||||
"Wavelet/OpenFlare/plugins/server/platform/bootstrap"
|
||||
"Wavelet/core"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -20,30 +14,6 @@ var allCmd = &cobra.Command{
|
||||
Use: "all",
|
||||
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
log.Println("[All] 融合模式启动")
|
||||
bootstrap.RegisterAll()
|
||||
runBootstrap(bootstrap.Options{API: true})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
// 启动 Asynq Worker 任务处理服务
|
||||
wg.Go(func() {
|
||||
log.Println("[All] 启动 Worker 服务")
|
||||
if err := worker.StartWorker(); err != nil {
|
||||
log.Printf("[All] Worker 启动失败: %v\n", err)
|
||||
}
|
||||
})
|
||||
|
||||
// 启动 Asynq 定时任务调度器
|
||||
wg.Go(func() {
|
||||
log.Println("[All] 启动 Scheduler 服务")
|
||||
if err := scheduler.StartScheduler(); err != nil {
|
||||
log.Printf("[All] Scheduler 启动失败: %v\n", err)
|
||||
}
|
||||
})
|
||||
|
||||
// API 服务持有前台阻塞与退出信号处理(runHTTPApp 返回后即已优雅退出)
|
||||
runHTTPApp("API + Worker + Scheduler")
|
||||
wg.Wait()
|
||||
runProfileApp(core.ProfileAll, "all (API + Worker + Scheduler)", true)
|
||||
},
|
||||
}
|
||||
|
||||
+2
-5
@@ -1,11 +1,10 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/OpenFlare/plugins/server/platform/bootstrap"
|
||||
"Wavelet/core"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -14,8 +13,6 @@ var apiCmd = &cobra.Command{
|
||||
Use: "api",
|
||||
Short: "wavelet API",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
bootstrap.RegisterAPI()
|
||||
runBootstrap(bootstrap.Options{API: true})
|
||||
runHTTPApp("API")
|
||||
runProfileApp(core.ProfileAPI, "api", true)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
ofserver "Wavelet/OpenFlare/plugins/server"
|
||||
"Wavelet/OpenFlare/plugins/server/stamp"
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"Wavelet/plugins/domain/admin"
|
||||
"Wavelet/plugins/domain/auth"
|
||||
"Wavelet/plugins/domain/cap"
|
||||
"Wavelet/plugins/domain/message_gateway"
|
||||
"Wavelet/plugins/domain/risk_control"
|
||||
"Wavelet/plugins/domain/system"
|
||||
"Wavelet/plugins/domain/upload"
|
||||
"Wavelet/plugins/domain/user"
|
||||
"Wavelet/plugins/drivers/driver_asynq_cron"
|
||||
"Wavelet/plugins/drivers/driver_asynq_worker"
|
||||
"Wavelet/plugins/drivers/driver_http"
|
||||
"Wavelet/plugins/drivers/driver_inproc_cron"
|
||||
"Wavelet/plugins/drivers/driver_inproc_worker"
|
||||
"Wavelet/plugins/infra/cache"
|
||||
"Wavelet/plugins/infra/cache_memory"
|
||||
"Wavelet/plugins/infra/config"
|
||||
"Wavelet/plugins/infra/logger"
|
||||
"Wavelet/plugins/infra/storage"
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
goosedb "github.com/pressly/goose/v3/database"
|
||||
|
||||
infradb "Wavelet/plugins/infra/database"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultShutdownTimeout = 15 * time.Second
|
||||
defaultHTTPAddr = "127.0.0.1:8000"
|
||||
|
||||
// migrationAdvisoryLockKey serializes baseline + plugin Up across Postgres
|
||||
// sessions (ASCII "wave"). SQLite is single-writer and needs no extra lock.
|
||||
migrationAdvisoryLockKey int64 = 0x77617665
|
||||
)
|
||||
|
||||
// runProfileApp prepares and runs the application for a given profile.
|
||||
func runProfileApp(profile core.Profile, mode string, listensForHTTP bool) {
|
||||
app := newOpenFlareApp(profile)
|
||||
if err := app.Prepare(); err != nil {
|
||||
log.Fatalf("[%s] prepare failed: %v\n", mode, err)
|
||||
}
|
||||
state := startupState{
|
||||
mode: mode,
|
||||
listensForHTTP: listensForHTTP,
|
||||
env: app.Context().Config().String("app.env", "production"),
|
||||
}
|
||||
if listensForHTTP {
|
||||
state.addr = app.Context().Config().String("app.addr", defaultHTTPAddr)
|
||||
}
|
||||
printStartupBanner(state)
|
||||
if err := app.Run(); err != nil {
|
||||
log.Fatalf("[%s] run failed: %v\n", mode, err)
|
||||
}
|
||||
}
|
||||
|
||||
// newOpenFlareApp creates a core.App wired with Wavelet platform plugins plus the OpenFlare server plugin.
|
||||
func newOpenFlareApp(profile core.Profile, opts ...core.AppOption) *core.App {
|
||||
src, err := config.NewSource()
|
||||
if err != nil {
|
||||
log.Fatalf("[App] load config source failed: %v\n", err)
|
||||
}
|
||||
|
||||
appOpts := []core.AppOption{
|
||||
core.WithProfile(profile),
|
||||
core.WithConfigSource(src),
|
||||
core.WithShutdownTimeout(defaultShutdownTimeout),
|
||||
core.WithMigrationBaseline(stamp.Legacy),
|
||||
}
|
||||
appOpts = append(appOpts, opts...)
|
||||
|
||||
app := core.NewApp(appOpts...)
|
||||
|
||||
// 1. Register standard infrastructure plugins
|
||||
app.Use(
|
||||
infradb.New(),
|
||||
logger.New(),
|
||||
storage.New(),
|
||||
)
|
||||
|
||||
// 2. Register Cache and Async/Cron Drivers (both gated: cache vs cache_memory, asynq vs inproc)
|
||||
app.Use(
|
||||
cache.New(),
|
||||
cache_memory.New(),
|
||||
driver_asynq_worker.New(),
|
||||
driver_inproc_worker.New(),
|
||||
driver_asynq_cron.New(),
|
||||
driver_inproc_cron.New(),
|
||||
)
|
||||
|
||||
// 3. Register all 8 domain business plugins (admin first to ensure schema and base config tables exist)
|
||||
app.Use(
|
||||
admin.New(),
|
||||
user.New(),
|
||||
auth.New(),
|
||||
message_gateway.New(),
|
||||
risk_control.New(),
|
||||
upload.New(),
|
||||
cap.New(),
|
||||
system.New(),
|
||||
)
|
||||
|
||||
// 4. OpenFlare business routes (after domain plugins, before the HTTP driver)
|
||||
app.Use(
|
||||
ofserver.New(),
|
||||
)
|
||||
|
||||
// 5. Bind Goose migration engine
|
||||
app.SetMigrationEngine(&gooseEngine{})
|
||||
|
||||
// 6. Mount HTTP runtime driver
|
||||
app.Use(
|
||||
driver_http.New(),
|
||||
)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
// ─── Schema Version Store ──────────────────────────────────────────────────────
|
||||
|
||||
// sharedStore implements database.Store using a single w_schema_versions table.
|
||||
// All plugins share this table, with plugin_id as the discriminator.
|
||||
//
|
||||
// Schema:
|
||||
//
|
||||
// w_schema_versions (
|
||||
// plugin_id VARCHAR(64) NOT NULL,
|
||||
// version_id BIGINT NOT NULL,
|
||||
// applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
// PRIMARY KEY (plugin_id, version_id)
|
||||
// )
|
||||
type sharedStore struct {
|
||||
pluginID string
|
||||
dialect string // "postgres" or "sqlite3"
|
||||
}
|
||||
|
||||
func (s *sharedStore) Tablename() string { return "w_schema_versions" }
|
||||
|
||||
func (s *sharedStore) CreateVersionTable(ctx context.Context, db goosedb.DBTxConn) error {
|
||||
_, err := db.ExecContext(ctx, schemaVersionsDDL(s.dialect))
|
||||
return err
|
||||
}
|
||||
|
||||
func schemaVersionsDDL(dialect string) string {
|
||||
timeType := "TIMESTAMPTZ"
|
||||
if dialect == "sqlite3" || dialect == "sqlite" {
|
||||
timeType = "DATETIME"
|
||||
}
|
||||
return fmt.Sprintf(`CREATE TABLE IF NOT EXISTS w_schema_versions (
|
||||
plugin_id VARCHAR(64) NOT NULL,
|
||||
version_id BIGINT NOT NULL,
|
||||
applied_at %s NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (plugin_id, version_id)
|
||||
)`, timeType)
|
||||
}
|
||||
|
||||
//nolint:mnd
|
||||
func (s *sharedStore) Insert(ctx context.Context, db goosedb.DBTxConn, req goosedb.InsertRequest) error {
|
||||
p := s.placeholder
|
||||
_, err := db.ExecContext(ctx,
|
||||
fmt.Sprintf("INSERT INTO w_schema_versions (plugin_id, version_id) VALUES (%s, %s) ON CONFLICT (plugin_id, version_id) DO NOTHING", p(1), p(2)),
|
||||
s.pluginID, req.Version)
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:mnd
|
||||
func (s *sharedStore) Delete(ctx context.Context, db goosedb.DBTxConn, version int64) error {
|
||||
p := s.placeholder
|
||||
_, err := db.ExecContext(ctx,
|
||||
fmt.Sprintf("DELETE FROM w_schema_versions WHERE plugin_id = %s AND version_id = %s", p(1), p(2)),
|
||||
s.pluginID, version)
|
||||
return err
|
||||
}
|
||||
|
||||
//nolint:mnd
|
||||
func (s *sharedStore) GetMigration(ctx context.Context, db goosedb.DBTxConn, version int64) (*goosedb.GetMigrationResult, error) {
|
||||
p := s.placeholder
|
||||
var t time.Time
|
||||
err := db.QueryRowContext(ctx,
|
||||
fmt.Sprintf("SELECT applied_at FROM w_schema_versions WHERE plugin_id = %s AND version_id = %s", p(1), p(2)),
|
||||
s.pluginID, version).Scan(&t)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, goosedb.ErrVersionNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &goosedb.GetMigrationResult{Timestamp: t, IsApplied: true}, nil
|
||||
}
|
||||
|
||||
func (s *sharedStore) GetLatestVersion(ctx context.Context, db goosedb.DBTxConn) (int64, error) {
|
||||
p := s.placeholder
|
||||
var version int64
|
||||
err := db.QueryRowContext(ctx,
|
||||
fmt.Sprintf("SELECT COALESCE(MAX(version_id), 0) FROM w_schema_versions WHERE plugin_id = %s", p(1)),
|
||||
s.pluginID).Scan(&version)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (s *sharedStore) ListMigrations(ctx context.Context, db goosedb.DBTxConn) ([]*goosedb.ListMigrationsResult, error) {
|
||||
p := s.placeholder
|
||||
rows, err := db.QueryContext(ctx,
|
||||
fmt.Sprintf("SELECT version_id, TRUE FROM w_schema_versions WHERE plugin_id = %s ORDER BY version_id DESC", p(1)),
|
||||
s.pluginID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var results []*goosedb.ListMigrationsResult
|
||||
for rows.Next() {
|
||||
var r goosedb.ListMigrationsResult
|
||||
if err := rows.Scan(&r.Version, &r.IsApplied); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, &r)
|
||||
}
|
||||
return results, rows.Err()
|
||||
}
|
||||
|
||||
func (s *sharedStore) placeholder(n int) string {
|
||||
if s.dialect == "postgres" {
|
||||
return fmt.Sprintf("$%d", n)
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// ─── Migration Engine ──────────────────────────────────────────────────────────
|
||||
|
||||
// gooseEngine implements core.MigrationEngine by iterating all plugin-registered
|
||||
// migration entries and applying each plugin's migrations against the shared DB.
|
||||
//
|
||||
// Each plugin owns its own `migrations/*.sql` directory, embedded via go:embed
|
||||
// and registered via ctx.Migrations().Register(pluginID, embedFS).
|
||||
//
|
||||
// Version tracking: all plugins share a single w_schema_versions table with
|
||||
// plugin_id as the discriminator column. Querying this table shows the current
|
||||
// migration version of every plugin at a glance.
|
||||
type gooseEngine struct{}
|
||||
|
||||
func (e *gooseEngine) Migrate(ctx *core.Context, entries []core.MigrationEntry) error {
|
||||
if len(entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve DBService from the IoC container.
|
||||
var dbSvc contracts.DBService
|
||||
if err := core.Using[contracts.DBService](ctx, func(svc contracts.DBService) {
|
||||
dbSvc = svc
|
||||
}); err != nil {
|
||||
return fmt.Errorf("migration: resolve DBService: %w", err)
|
||||
}
|
||||
|
||||
gormDB := dbSvc.GORM()
|
||||
if gormDB == nil {
|
||||
return fmt.Errorf("migration: DBService.GORM() returned nil")
|
||||
}
|
||||
|
||||
sqlDB, err := gormDB.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration: get underlying DB from GORM: %w", err)
|
||||
}
|
||||
|
||||
dialect := gooseDialect(ctx)
|
||||
dialectStr := string(dialect)
|
||||
goCtx := context.Background()
|
||||
if ctx != nil {
|
||||
goCtx = ctx.GoContext()
|
||||
}
|
||||
if goCtx == nil {
|
||||
goCtx = context.Background()
|
||||
}
|
||||
|
||||
bootstrap := &sharedStore{dialect: dialectStr}
|
||||
if err := bootstrap.CreateVersionTable(goCtx, sqlDB); err != nil {
|
||||
return fmt.Errorf("migration: create version table: %w", err)
|
||||
}
|
||||
|
||||
if dialect == goose.DialectPostgres {
|
||||
conn, lockErr := sqlDB.Conn(goCtx)
|
||||
if lockErr != nil {
|
||||
return fmt.Errorf("migration: pin connection for advisory lock: %w", lockErr)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if _, lockErr = conn.ExecContext(goCtx, "SELECT pg_advisory_lock($1)", migrationAdvisoryLockKey); lockErr != nil {
|
||||
return fmt.Errorf("migration: advisory lock: %w", lockErr)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", migrationAdvisoryLockKey)
|
||||
}()
|
||||
}
|
||||
|
||||
if fn := ctx.MigrationBaseline(); fn != nil {
|
||||
if err := fn(ctx); err != nil {
|
||||
return fmt.Errorf("migration baseline: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
store := &sharedStore{
|
||||
pluginID: entry.PluginID,
|
||||
dialect: dialectStr,
|
||||
}
|
||||
|
||||
migrationFS := findMigrationFS(entry.FS, dialect)
|
||||
provider, err := goose.NewProvider(goose.DialectCustom, sqlDB, migrationFS, goose.WithStore(store))
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %s: create provider: %w", entry.PluginID, err)
|
||||
}
|
||||
|
||||
results, err := provider.Up(context.Background())
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration %s: apply %w", entry.PluginID, err)
|
||||
}
|
||||
|
||||
version, vErr := provider.GetDBVersion(context.Background())
|
||||
if vErr != nil {
|
||||
version = 0
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
log.Printf("[migrate] %s: applied %d migration(s) (v%d)", entry.PluginID, len(results), version)
|
||||
} else {
|
||||
log.Printf("[migrate] %s: v%d", entry.PluginID, version)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// gooseDialect returns the goose dialect based on the configured database engine.
|
||||
func gooseDialect(ctx *core.Context) goose.Dialect {
|
||||
if ctx != nil && ctx.Config() != nil && ctx.Config().Bool("database.enabled", false) {
|
||||
return goose.DialectPostgres
|
||||
}
|
||||
return goose.DialectSQLite3
|
||||
}
|
||||
|
||||
func findMigrationFS(rootFS fs.FS, dialect goose.Dialect) fs.FS {
|
||||
dialectDir := "postgres"
|
||||
if dialect == goose.DialectSQLite3 {
|
||||
dialectDir = "sqlite"
|
||||
}
|
||||
|
||||
// 1. Direct search for dialect folder (e.g., "sqlite", "migrations/sqlite", "logstore/migrations/sqlite")
|
||||
for _, subDir := range []string{
|
||||
dialectDir,
|
||||
"migrations/" + dialectDir,
|
||||
"logstore/migrations/" + dialectDir,
|
||||
} {
|
||||
if sub, err := fs.Sub(rootFS, subDir); err == nil {
|
||||
if matches, err := fs.Glob(sub, "*.sql"); err == nil && len(matches) > 0 {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Recursive walk to find a directory named dialectDir with *.sql files
|
||||
var foundDir string
|
||||
_ = fs.WalkDir(rootFS, ".", func(path string, d fs.DirEntry, err error) error {
|
||||
if err == nil && d.IsDir() && filepath.Base(path) == dialectDir {
|
||||
if sub, subErr := fs.Sub(rootFS, path); subErr == nil {
|
||||
if matches, globErr := fs.Glob(sub, "*.sql"); globErr == nil && len(matches) > 0 {
|
||||
foundDir = path
|
||||
return fs.SkipAll
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
if foundDir != "" && foundDir != "." {
|
||||
if sub, err := fs.Sub(rootFS, foundDir); err == nil {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fallback to generic migrations / root if dialect specific is not present
|
||||
for _, subDir := range []string{"migrations", "logstore/migrations"} {
|
||||
if sub, err := fs.Sub(rootFS, subDir); err == nil {
|
||||
if matches, err := fs.Glob(sub, "*.sql"); err == nil && len(matches) > 0 {
|
||||
return sub
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return rootFS
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"Wavelet/core"
|
||||
)
|
||||
|
||||
func testSource(t *testing.T) core.ConfigSource {
|
||||
t.Helper()
|
||||
return core.NewMapSource(map[string]any{
|
||||
"app": map[string]any{
|
||||
"addr": "127.0.0.1:0",
|
||||
"env": "testing",
|
||||
},
|
||||
"redis": map[string]any{
|
||||
"enabled": false,
|
||||
},
|
||||
"database": map[string]any{
|
||||
"enabled": false,
|
||||
"sqlite_path": filepath.Join(t.TempDir(), "openflare-cmd.db"),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewOpenFlareAppRegistersServerAndWaveletUser(t *testing.T) {
|
||||
app := newOpenFlareApp(core.ProfileAPI, core.WithConfigSource(testSource(t)))
|
||||
if err := app.Prepare(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
names := map[string]bool{}
|
||||
for _, p := range app.Plugins() {
|
||||
names[p.Name()] = true
|
||||
}
|
||||
for _, n := range []string{"user", "auth", "cap", "admin", "server"} {
|
||||
if !names[n] {
|
||||
t.Errorf("missing plugin %s", n)
|
||||
}
|
||||
}
|
||||
|
||||
if err := app.Reconcile(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := map[string]bool{}
|
||||
for _, rd := range app.Context().Router().Routes() {
|
||||
got[rd.Method+" "+rd.Path] = true
|
||||
}
|
||||
for _, want := range []string{
|
||||
"GET /api/health",
|
||||
"GET /api/v1/user/self",
|
||||
"GET /api/v1/d/nodes",
|
||||
"POST /api/cap/challenge",
|
||||
} {
|
||||
if !got[want] {
|
||||
t.Errorf("missing route %s", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
-26
@@ -4,28 +4,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/buildinfo"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"Wavelet/OpenFlare/plugins/server/infra/config"
|
||||
"Wavelet/OpenFlare/plugins/server/infra/persistence/migrator"
|
||||
"Wavelet/pkg/buildinfo"
|
||||
)
|
||||
|
||||
type startupState struct {
|
||||
mode string
|
||||
relationalDB migrator.Report
|
||||
clickHouseDB migrator.Report
|
||||
listensForHTTP bool
|
||||
env string
|
||||
addr string
|
||||
}
|
||||
|
||||
func printStartupBanner(state startupState) {
|
||||
log.Print(formatStartupBanner(state))
|
||||
fmt.Println(formatStartupBanner(state))
|
||||
}
|
||||
|
||||
func formatStartupBanner(state startupState) string {
|
||||
env := state.env
|
||||
if env == "" {
|
||||
env = "production"
|
||||
}
|
||||
addr := state.addr
|
||||
if addr == "" {
|
||||
addr = "127.0.0.1:3000"
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
"",
|
||||
" ____ ________ ",
|
||||
@@ -34,18 +39,16 @@ func formatStartupBanner(state startupState) string {
|
||||
"/ /_/ / /_/ / __/ / / / __/ / / /_/ / / / __/",
|
||||
"\\____/ .___/\\___/_/ /_/_/ /_/\\__,_/_/ \\___/ ",
|
||||
" /_/ ",
|
||||
" OpenFlare " + buildinfo.Version,
|
||||
fmt.Sprintf(" OpenFlare %s", buildinfo.Version),
|
||||
"",
|
||||
" Environment: " + config.Config.App.Env,
|
||||
fmt.Sprintf(" Environment: %s", env),
|
||||
fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()),
|
||||
" Build time: " + buildTime(),
|
||||
" Database: " + formatMigration(state.relationalDB),
|
||||
" Analytics: " + formatMigration(state.clickHouseDB),
|
||||
fmt.Sprintf(" Build time: %s", buildTime()),
|
||||
}
|
||||
if state.listensForHTTP {
|
||||
lines = append(lines, " Listening: http://"+config.Config.App.Addr)
|
||||
lines = append(lines, fmt.Sprintf(" Listening: http://%s", addr))
|
||||
}
|
||||
lines = append(lines, " Mode: "+state.mode, "")
|
||||
lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "")
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
@@ -55,14 +58,3 @@ func buildTime() string {
|
||||
}
|
||||
return buildinfo.BuildTime
|
||||
}
|
||||
|
||||
func formatMigration(report migrator.Report) string {
|
||||
if !report.Enabled {
|
||||
return "disabled"
|
||||
}
|
||||
state := "up to date"
|
||||
if report.Applied {
|
||||
state = "upgraded"
|
||||
}
|
||||
return fmt.Sprintf("%s (version %d, %s)", report.Backend, report.Version, state)
|
||||
}
|
||||
|
||||
@@ -4,49 +4,33 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/buildinfo"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"Wavelet/OpenFlare/plugins/server/infra/config"
|
||||
"Wavelet/OpenFlare/plugins/server/infra/persistence/migrator"
|
||||
"Wavelet/pkg/buildinfo"
|
||||
)
|
||||
|
||||
func TestFormatStartupBanner(t *testing.T) {
|
||||
previousVersion := buildinfo.Version
|
||||
previousBuildTime := buildinfo.BuildTime
|
||||
previousEnv := config.Config.App.Env
|
||||
previousAddr := config.Config.App.Addr
|
||||
t.Cleanup(func() {
|
||||
buildinfo.Version = previousVersion
|
||||
buildinfo.BuildTime = previousBuildTime
|
||||
config.Config.App.Env = previousEnv
|
||||
config.Config.App.Addr = previousAddr
|
||||
})
|
||||
|
||||
buildinfo.Version = "v3.2.1"
|
||||
buildinfo.BuildTime = "2026-07-13T08:00:00Z"
|
||||
config.Config.App.Env = "production"
|
||||
config.Config.App.Addr = ":3000"
|
||||
|
||||
banner := formatStartupBanner(startupState{
|
||||
mode: "API",
|
||||
relationalDB: migrator.Report{
|
||||
Backend: "PostgreSQL",
|
||||
Enabled: true,
|
||||
Version: 202607150003,
|
||||
Applied: true,
|
||||
},
|
||||
clickHouseDB: migrator.Report{Backend: "ClickHouse"},
|
||||
mode: "API",
|
||||
listensForHTTP: true,
|
||||
env: "production",
|
||||
addr: ":3000",
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"OpenFlare v3.2.1",
|
||||
"Environment: production",
|
||||
"Build time: 2026-07-13T08:00:00Z",
|
||||
"Database: PostgreSQL (version 202607150003, upgraded)",
|
||||
"Analytics: disabled",
|
||||
"Listening: http://:3000",
|
||||
"Mode: API",
|
||||
} {
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"Wavelet/OpenFlare/plugins/server/platform/bootstrap"
|
||||
"Wavelet/pkg/trace"
|
||||
)
|
||||
|
||||
func runBootstrap(opts bootstrap.Options) {
|
||||
ctx, span := trace.Start(context.Background(), "bootstrap.Init")
|
||||
defer span.End()
|
||||
bootstrap.Init(ctx, opts)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"Wavelet/OpenFlare/plugins/server"
|
||||
"Wavelet/OpenFlare/plugins/server/infra/config"
|
||||
"Wavelet/OpenFlare/plugins/server/platform/bootstrap"
|
||||
"Wavelet/OpenFlare/plugins/server/router"
|
||||
"Wavelet/core"
|
||||
"Wavelet/plugins/drivers/driver_http"
|
||||
)
|
||||
|
||||
// defaultShutdownTimeout 在配置未给出优雅退出超时时兜底。
|
||||
const defaultShutdownTimeout = 10 * time.Second
|
||||
|
||||
// runHTTPApp 以 Cordis 方式启动控制面:装配根持有 gin engine(引擎级中间件与前端
|
||||
// SPA 兜底),server 插件经 ctx.Router() 声明路由,driver_http 负责监听与优雅退出。
|
||||
// 本函数阻塞至收到退出信号。
|
||||
func runHTTPApp(mode string) {
|
||||
engine := router.BuildEngine()
|
||||
timeout := shutdownTimeout()
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithProfile(core.ProfileAPI),
|
||||
core.WithShutdownTimeout(timeout),
|
||||
)
|
||||
httpDriver := driver_http.New(
|
||||
driver_http.WithEngine(engine),
|
||||
driver_http.WithAddr(config.Config.App.Addr),
|
||||
)
|
||||
app.Use(server.New())
|
||||
app.Use(httpDriver)
|
||||
|
||||
if err := app.Prepare(); err != nil {
|
||||
log.Fatalf("[API] prepare failed: %v\n", err)
|
||||
}
|
||||
|
||||
printStartupBanner(startupState{
|
||||
mode: mode,
|
||||
relationalDB: latestMigrationState.relationalDB,
|
||||
clickHouseDB: latestMigrationState.clickHouseDB,
|
||||
listensForHTTP: true,
|
||||
})
|
||||
|
||||
if err := app.Run(); err != nil {
|
||||
log.Printf("[API] server failed: %v\n", err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
bootstrap.Stop(ctx)
|
||||
log.Println("[API] server exited")
|
||||
}
|
||||
|
||||
func shutdownTimeout() time.Duration {
|
||||
if config.Config.App.GracefulShutdownTimeout <= 0 {
|
||||
return defaultShutdownTimeout
|
||||
}
|
||||
return time.Duration(config.Config.App.GracefulShutdownTimeout) * time.Second
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core"
|
||||
"Wavelet/core/contracts"
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type migrateTestDB struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (s migrateTestDB) GORM() *gorm.DB { return s.db }
|
||||
|
||||
func (s migrateTestDB) DB(ctx context.Context) *gorm.DB { return s.db.WithContext(ctx) }
|
||||
|
||||
func (s migrateTestDB) Named(string) *gorm.DB { return s.db }
|
||||
|
||||
type migrateTestPlugin struct {
|
||||
db *gorm.DB
|
||||
fs fstest.MapFS
|
||||
}
|
||||
|
||||
func (p *migrateTestPlugin) Name() string { return "t" }
|
||||
|
||||
func (p *migrateTestPlugin) Apply(ctx *core.Context) error {
|
||||
core.Provide[contracts.DBService](ctx, migrateTestDB{db: p.db})
|
||||
ctx.Migrations().Register("t", p.fs)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sqliteTableExists(t *testing.T, db *gorm.DB, name string) bool {
|
||||
t.Helper()
|
||||
var n int
|
||||
err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", name).Scan(&n).Error
|
||||
require.NoError(t, err)
|
||||
return n > 0
|
||||
}
|
||||
|
||||
func testMigrationFS() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"migrations/sqlite/00001_init.sql": &fstest.MapFile{Data: []byte(`-- +goose Up
|
||||
CREATE TABLE t_up (id INTEGER PRIMARY KEY);
|
||||
|
||||
-- +goose Down
|
||||
DROP TABLE t_up;
|
||||
`)},
|
||||
}
|
||||
}
|
||||
|
||||
func openMigrateTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "migrate.db")
|
||||
gdb, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
return gdb
|
||||
}
|
||||
|
||||
func TestGooseEngineMigrateOrderCreateTableBaselineUp(t *testing.T) {
|
||||
gdb := openMigrateTestDB(t)
|
||||
var order []string
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithMigrationEngine(&gooseEngine{}),
|
||||
core.WithMigrationBaseline(func(*core.Context) error {
|
||||
require.True(t, sqliteTableExists(t, gdb, "w_schema_versions"), "version table must exist before baseline")
|
||||
require.False(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must not run before baseline")
|
||||
order = append(order, "create-table", "baseline")
|
||||
return nil
|
||||
}),
|
||||
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
|
||||
)
|
||||
|
||||
require.NoError(t, app.Prepare())
|
||||
require.NoError(t, app.ApplyPlugins())
|
||||
require.NoError(t, app.RunMigrations())
|
||||
|
||||
require.True(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must run after baseline")
|
||||
order = append(order, "up")
|
||||
assert.Equal(t, []string{"create-table", "baseline", "up"}, order)
|
||||
}
|
||||
|
||||
func TestGooseEngineBaselineErrorSkipsUp(t *testing.T) {
|
||||
gdb := openMigrateTestDB(t)
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithMigrationEngine(&gooseEngine{}),
|
||||
core.WithMigrationBaseline(func(*core.Context) error {
|
||||
require.True(t, sqliteTableExists(t, gdb, "w_schema_versions"), "version table must exist before baseline")
|
||||
return assert.AnError
|
||||
}),
|
||||
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
|
||||
)
|
||||
|
||||
require.NoError(t, app.Prepare())
|
||||
require.NoError(t, app.ApplyPlugins())
|
||||
err := app.RunMigrations()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "migration baseline")
|
||||
assert.False(t, sqliteTableExists(t, gdb, "t_up"), "plugin Up must not run when baseline fails")
|
||||
}
|
||||
|
||||
func TestGooseEngineNilBaselineStillMigrates(t *testing.T) {
|
||||
gdb := openMigrateTestDB(t)
|
||||
|
||||
app := core.NewApp(
|
||||
core.WithMigrationEngine(&gooseEngine{}),
|
||||
core.WithPlugins(&migrateTestPlugin{db: gdb, fs: testMigrationFS()}),
|
||||
)
|
||||
|
||||
require.NoError(t, app.Prepare())
|
||||
require.NoError(t, app.ApplyPlugins())
|
||||
require.NoError(t, app.RunMigrations())
|
||||
assert.True(t, sqliteTableExists(t, gdb, "w_schema_versions"))
|
||||
assert.True(t, sqliteTableExists(t, gdb, "t_up"))
|
||||
}
|
||||
+15
-16
@@ -4,6 +4,8 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/plugins/domain/auth"
|
||||
"Wavelet/plugins/infra/database"
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
@@ -13,12 +15,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
db "Wavelet/OpenFlare/plugins/server/infra/persistence"
|
||||
"Wavelet/OpenFlare/plugins/server/infra/persistence/migrator"
|
||||
"Wavelet/OpenFlare/plugins/server/model"
|
||||
"Wavelet/OpenFlare/plugins/server/oauth"
|
||||
"Wavelet/OpenFlare/plugins/server/platform/bootstrap"
|
||||
"Wavelet/OpenFlare/plugins/server/repository"
|
||||
userdomain "Wavelet/plugins/domain/user"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
@@ -48,12 +45,14 @@ func generateRandomPassword(length int) (string, error) {
|
||||
var resetPasswdCmd = &cobra.Command{
|
||||
Use: "reset-passwd",
|
||||
Short: "重置指定账号密码",
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
migrator.Migrate()
|
||||
},
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
ctx := context.Background()
|
||||
runBootstrap(bootstrap.Options{})
|
||||
|
||||
// Ensure database is initialized
|
||||
dbConn := database.DB(ctx)
|
||||
if dbConn != nil {
|
||||
userdomain.SetDBService(database.NewService(dbConn))
|
||||
}
|
||||
|
||||
var username string
|
||||
if usernameFlag != "" {
|
||||
@@ -71,7 +70,7 @@ var resetPasswdCmd = &cobra.Command{
|
||||
}
|
||||
}
|
||||
|
||||
user, err := repository.GetUserByUsername(ctx, username)
|
||||
user, err := userdomain.GetUserByUsername(ctx, username)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
log.Fatalf("错误: 用户 '%s' 不存在\n", username)
|
||||
@@ -93,26 +92,26 @@ var resetPasswdCmd = &cobra.Command{
|
||||
log.Fatalf("加密密码失败: %v\n", err)
|
||||
}
|
||||
|
||||
err = db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
err = database.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&user).Update("password", user.Password).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Invalidate existing tokens
|
||||
var tokens []model.AccessToken
|
||||
var tokens []userdomain.AccessToken
|
||||
if err := tx.Where("user_id = ?", user.ID).Find(&tokens).Error; err == nil {
|
||||
for _, token := range tokens {
|
||||
oauth.InvalidateCachedToken(ctx, token.TokenHash)
|
||||
auth.InvalidateCachedToken(ctx, token.TokenHash)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Where("user_id = ?", user.ID).Delete(&model.AccessToken{}).Error
|
||||
return tx.Where("user_id = ?", user.ID).Delete(&userdomain.AccessToken{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("重置密码失败: %v\n", err)
|
||||
}
|
||||
|
||||
oauth.InvalidateCachedUser(ctx, user.ID)
|
||||
auth.InvalidateCachedUser(ctx, user.ID)
|
||||
|
||||
fmt.Println("成功重置密码!")
|
||||
fmt.Printf("用户名: %s\n", user.Username)
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/pkg/testhelper"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"Wavelet/OpenFlare/plugins/server/model"
|
||||
"Wavelet/OpenFlare/plugins/server/testhelper"
|
||||
userdomain "Wavelet/plugins/domain/user"
|
||||
)
|
||||
|
||||
func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
@@ -19,7 +19,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := model.User{
|
||||
user := userdomain.User{
|
||||
ID: 1001,
|
||||
Username: "testuser1",
|
||||
Nickname: "Test User 1",
|
||||
@@ -33,7 +33,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
// Create access token to test invalidation/deletion
|
||||
token := model.AccessToken{
|
||||
token := userdomain.AccessToken{
|
||||
ID: 1,
|
||||
UserID: user.ID,
|
||||
Name: "testtoken",
|
||||
@@ -74,7 +74,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify password in DB
|
||||
var dbUser model.User
|
||||
var dbUser userdomain.User
|
||||
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
|
||||
t.Fatalf("failed to query user from DB: %v", err)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func TestResetPasswdCmd_WithUserAndPassword(t *testing.T) {
|
||||
|
||||
// Verify token deleted
|
||||
var count int64
|
||||
dbConn.Model(&model.AccessToken{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
dbConn.Model(&userdomain.AccessToken{}).Where("user_id = ?", user.ID).Count(&count)
|
||||
if count != 0 {
|
||||
t.Errorf("expected access tokens to be deleted, got %d", count)
|
||||
}
|
||||
@@ -95,7 +95,7 @@ func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := model.User{
|
||||
user := userdomain.User{
|
||||
ID: 1002,
|
||||
Username: "testuser2",
|
||||
Nickname: "Test User 2",
|
||||
@@ -142,7 +142,7 @@ func TestResetPasswdCmd_WithUserAndRandomPassword(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify password in DB (should be updated and not equal to old one)
|
||||
var dbUser model.User
|
||||
var dbUser userdomain.User
|
||||
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
|
||||
t.Fatalf("failed to query user from DB: %v", err)
|
||||
}
|
||||
@@ -156,7 +156,7 @@ func TestResetPasswdCmd_InteractiveMode(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
// Seed test user
|
||||
user := model.User{
|
||||
user := userdomain.User{
|
||||
ID: 1003,
|
||||
Username: "testuser3",
|
||||
Nickname: "Test User 3",
|
||||
@@ -217,7 +217,7 @@ func TestResetPasswdCmd_InteractiveMode(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify user password changed in DB
|
||||
var dbUser model.User
|
||||
var dbUser userdomain.User
|
||||
if err := dbConn.Where("id = ?", user.ID).First(&dbUser).Error; err != nil {
|
||||
t.Fatalf("failed to query user from DB: %v", err)
|
||||
}
|
||||
|
||||
+58
-41
@@ -1,47 +1,83 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"Wavelet/core/extpoints"
|
||||
"Wavelet/pkg/buildinfo"
|
||||
"Wavelet/pkg/idgen"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/trace"
|
||||
"Wavelet/plugins/infra/config"
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"Wavelet/OpenFlare/plugins/server/infra/config"
|
||||
"Wavelet/OpenFlare/plugins/server/infra/persistence/migrator"
|
||||
"Wavelet/pkg/buildinfo"
|
||||
"Wavelet/pkg/logger"
|
||||
"Wavelet/pkg/trace"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const traceShutdownTimeout = 10 * time.Second
|
||||
|
||||
type hostConfig struct {
|
||||
App struct {
|
||||
AppName string `config:"app_name" env:"APP_NAME" default:"Wavelet"`
|
||||
Env string `config:"env" env:"APP_ENV" default:"production"`
|
||||
NodeID int64 `config:"node_id" env:"APP_NODE_ID" default:"1"`
|
||||
Addr string `config:"addr" env:"APP_ADDR" default:"127.0.0.1:3000"`
|
||||
} `config:"app"`
|
||||
Log struct {
|
||||
Level string `config:"level" env:"LOG_LEVEL" default:"info"`
|
||||
Format string `config:"format" env:"LOG_FORMAT" default:"json"`
|
||||
Output string `config:"output" env:"LOG_OUTPUT" default:"stdout"`
|
||||
FilePath string `config:"file_path" env:"LOG_FILE_PATH" default:"./logs/app.log"`
|
||||
MaxSize int `config:"max_size" env:"LOG_MAX_SIZE" default:"100"`
|
||||
MaxAge int `config:"max_age" env:"LOG_MAX_AGE" default:"30"`
|
||||
MaxBackups int `config:"max_backups" env:"LOG_MAX_BACKUPS" default:"10"`
|
||||
Compress bool `config:"compress" env:"LOG_COMPRESS" default:"true"`
|
||||
} `config:"log"`
|
||||
OTel struct {
|
||||
SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE" default:"1.0"`
|
||||
TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"`
|
||||
} `config:"otel"`
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "wavelet",
|
||||
PersistentPreRun: func(_ *cobra.Command, _ []string) {
|
||||
src, err := config.NewSource()
|
||||
if err != nil {
|
||||
log.Fatalf("[CMD] load config source failed: %v", err)
|
||||
}
|
||||
var cfg hostConfig
|
||||
reg := extpoints.NewConfigRegistry(src)
|
||||
_ = reg.Declare("host", extpoints.ConfigBinding{Target: &cfg})
|
||||
if err := reg.Resolve(); err != nil {
|
||||
log.Fatalf("[CMD] resolve host config failed: %v", err)
|
||||
}
|
||||
_ = reg.Bind("", &cfg)
|
||||
|
||||
// Initialize idgen snowflake generator
|
||||
if err := idgen.Init(cfg.App.NodeID); err != nil {
|
||||
log.Fatalf("[CMD] init idgen failed: %v", err)
|
||||
}
|
||||
|
||||
logger.Init(logger.Config{
|
||||
Level: config.Config.Log.Level,
|
||||
Format: config.Config.Log.Format,
|
||||
Output: config.Config.Log.Output,
|
||||
FilePath: config.Config.Log.FilePath,
|
||||
MaxSize: config.Config.Log.MaxSize,
|
||||
MaxAge: config.Config.Log.MaxAge,
|
||||
MaxBackups: config.Config.Log.MaxBackups,
|
||||
Compress: config.Config.Log.Compress,
|
||||
Level: cfg.Log.Level,
|
||||
Format: cfg.Log.Format,
|
||||
Output: cfg.Log.Output,
|
||||
FilePath: cfg.Log.FilePath,
|
||||
MaxSize: cfg.Log.MaxSize,
|
||||
MaxAge: cfg.Log.MaxAge,
|
||||
MaxBackups: cfg.Log.MaxBackups,
|
||||
Compress: cfg.Log.Compress,
|
||||
})
|
||||
trace.Init(trace.Config{
|
||||
AppName: config.Config.App.AppName,
|
||||
SamplingRate: config.Config.Otel.SamplingRate,
|
||||
TracerName: config.Config.Otel.TracerName,
|
||||
AppName: cfg.App.AppName,
|
||||
SamplingRate: cfg.OTel.SamplingRate,
|
||||
TracerName: cfg.OTel.TracerName,
|
||||
})
|
||||
},
|
||||
PreRun: func(_ *cobra.Command, _ []string) {
|
||||
runMigrations()
|
||||
},
|
||||
PersistentPostRun: func(_ *cobra.Command, _ []string) {
|
||||
shutdownTraceProvider()
|
||||
},
|
||||
@@ -51,16 +87,6 @@ var rootCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
var latestMigrationState struct {
|
||||
relationalDB migrator.Report
|
||||
clickHouseDB migrator.Report
|
||||
}
|
||||
|
||||
func runMigrations() {
|
||||
latestMigrationState.relationalDB = migrator.Migrate()
|
||||
latestMigrationState.clickHouseDB = migrator.MigrateClickHouse()
|
||||
}
|
||||
|
||||
func shutdownTraceProvider() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), traceShutdownTimeout)
|
||||
defer cancel()
|
||||
@@ -71,16 +97,7 @@ func init() {
|
||||
rootCmd.Version = buildinfo.Version
|
||||
rootCmd.CompletionOptions.DisableDefaultCmd = true
|
||||
|
||||
// 1. 为需要迁移的子命令动态绑定原先 rootCmd.PreRun 拥有的数据库迁移行为
|
||||
migratePreRun := func(_ *cobra.Command, _ []string) {
|
||||
runMigrations()
|
||||
}
|
||||
allCmd.PreRun = migratePreRun
|
||||
apiCmd.PreRun = migratePreRun
|
||||
workerCmd.PreRun = migratePreRun
|
||||
schedulerCmd.PreRun = migratePreRun
|
||||
|
||||
// 2. 集中将这些命令注册为真正的子命令,以解决 Cobra 的 unknown command 校验限制
|
||||
// 集中将子命令注册到根命令,以解决 Cobra 的 unknown command 校验限制
|
||||
rootCmd.AddCommand(allCmd, apiCmd, workerCmd, schedulerCmd)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"Wavelet/OpenFlare/plugins/server/infra/task/scheduler"
|
||||
"Wavelet/OpenFlare/plugins/server/platform/bootstrap"
|
||||
"Wavelet/core"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -17,11 +13,6 @@ var schedulerCmd = &cobra.Command{
|
||||
Use: "scheduler",
|
||||
Short: "wavelet Scheduler",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
runBootstrap(bootstrap.Options{})
|
||||
printStartupBanner(startupState{mode: "Scheduler", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB})
|
||||
log.Println("[Scheduler] 启动定时任务调度服务")
|
||||
if err := scheduler.StartScheduler(); err != nil {
|
||||
log.Fatalf("[调度器] 启动失败: %v", err)
|
||||
}
|
||||
runProfileApp(core.ProfileSchedule, "scheduler", false)
|
||||
},
|
||||
}
|
||||
|
||||
+2
-11
@@ -1,14 +1,10 @@
|
||||
// Copyright 2025 linux.do
|
||||
// Copyright 2026 Arctel.net
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"Wavelet/OpenFlare/plugins/server/infra/task/worker"
|
||||
"Wavelet/OpenFlare/plugins/server/platform/bootstrap"
|
||||
"Wavelet/core"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -17,11 +13,6 @@ var workerCmd = &cobra.Command{
|
||||
Use: "worker",
|
||||
Short: "wavelet Worker",
|
||||
Run: func(_ *cobra.Command, _ []string) {
|
||||
runBootstrap(bootstrap.Options{})
|
||||
printStartupBanner(startupState{mode: "Worker", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB})
|
||||
log.Println("[Worker] 启动任务处理服务")
|
||||
if err := worker.StartWorker(); err != nil {
|
||||
log.Fatalf("[工作器] 启动失败: %v", err)
|
||||
}
|
||||
runProfileApp(core.ProfileWorker, "worker", false)
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user