feat(startup): print service banner after listener ready

This commit is contained in:
ryan
2026-07-13 15:49:19 +08:00
parent 26be762c3a
commit 6b6c786cfe
11 changed files with 200 additions and 13 deletions
+1
View File
@@ -23,6 +23,7 @@ sidebar: false
### 新增
- 系统完成初始化后输出 OpenFlare 启动 Banner,汇总版本、构建与运行环境、数据库迁移状态、运行模式和 HTTP 监听地址。
- 新增 `make prettier`,统一使用 `gofmt` 与项目固定版本的 Prettier 自动格式化前后端源码。
- WAF 规则支持可视化 DAG 编排、版本冲突保护和有序路由绑定,发布时编译为 OpenResty 纯内存运行图。
- WAF IP 组支持 checksum 驱动的 Worker 内存热刷新,并补充 City MMDB 地区匹配数据源。
+3 -1
View File
@@ -31,7 +31,9 @@ var allCmd = &cobra.Command{
go func() {
defer wg.Done()
log.Println("[All] 启动 API 服务")
router.Serve()
router.Serve(func() {
printStartupBanner(startupState{mode: "API + Worker + Scheduler", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB, listensForHTTP: true})
})
}()
// 启动 Asynq Worker 任务处理服务
+3 -1
View File
@@ -16,6 +16,8 @@ var apiCmd = &cobra.Command{
Run: func(_ *cobra.Command, _ []string) {
bootstrap.RegisterAPI()
runBootstrap(bootstrap.Options{API: true})
router.Serve()
router.Serve(func() {
printStartupBanner(startupState{mode: "API", relationalDB: latestMigrationState.relationalDB, clickHouseDB: latestMigrationState.clickHouseDB, listensForHTTP: true})
})
},
}
+68
View File
@@ -0,0 +1,68 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"fmt"
"log"
"runtime"
"strings"
"github.com/Rain-kl/Wavelet/internal/buildinfo"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db/migrator"
)
type startupState struct {
mode string
relationalDB migrator.Report
clickHouseDB migrator.Report
listensForHTTP bool
}
func printStartupBanner(state startupState) {
log.Print(formatStartupBanner(state))
}
func formatStartupBanner(state startupState) string {
lines := []string{
"",
" ____ ________ ",
" / __ \\____ ___ ____ / ____/ /___ _________ ",
" / / / / __ \\/ _ \\/ __ \\/ /_ / / __ `/ ___/ _ \\",
"/ /_/ / /_/ / __/ / / / __/ / / /_/ / / / __/",
"\\____/ .___/\\___/_/ /_/_/ /_/\\__,_/_/ \\___/ ",
" /_/ ",
fmt.Sprintf(" OpenFlare %s", buildinfo.Version),
"",
fmt.Sprintf(" Environment: %s", config.Config.App.Env),
fmt.Sprintf(" Runtime: %s/%s (%s)", runtime.GOOS, runtime.GOARCH, runtime.Version()),
fmt.Sprintf(" Build time: %s", buildTime()),
fmt.Sprintf(" Database: %s", formatMigration(state.relationalDB)),
fmt.Sprintf(" Analytics: %s", formatMigration(state.clickHouseDB)),
}
if state.listensForHTTP {
lines = append(lines, fmt.Sprintf(" Listening: http://%s", config.Config.App.Addr))
}
lines = append(lines, fmt.Sprintf(" Mode: %s", state.mode), "")
return strings.Join(lines, "\n")
}
func buildTime() string {
if buildinfo.BuildTime == "" {
return "development build"
}
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)
}
+57
View File
@@ -0,0 +1,57 @@
// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package cmd
import (
"strings"
"testing"
"github.com/Rain-kl/Wavelet/internal/buildinfo"
"github.com/Rain-kl/Wavelet/internal/config"
"github.com/Rain-kl/Wavelet/internal/db/migrator"
)
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"},
listensForHTTP: true,
})
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",
} {
if !strings.Contains(banner, want) {
t.Errorf("banner missing %q:\n%s", want, banner)
}
}
}
+12 -4
View File
@@ -39,8 +39,7 @@ var rootCmd = &cobra.Command{
})
},
PreRun: func(_ *cobra.Command, _ []string) {
migrator.Migrate()
migrator.MigrateClickHouse()
runMigrations()
},
PersistentPostRun: func(_ *cobra.Command, _ []string) {
shutdownTraceProvider()
@@ -51,6 +50,16 @@ 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()
@@ -63,8 +72,7 @@ func init() {
// 1. 为需要迁移的子命令动态绑定原先 rootCmd.PreRun 拥有的数据库迁移行为
migratePreRun := func(_ *cobra.Command, _ []string) {
migrator.Migrate()
migrator.MigrateClickHouse()
runMigrations()
}
allCmd.PreRun = migratePreRun
apiCmd.PreRun = migratePreRun
+1
View File
@@ -18,6 +18,7 @@ var schedulerCmd = &cobra.Command{
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)
+1
View File
@@ -18,6 +18,7 @@ var workerCmd = &cobra.Command{
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)
+18 -2
View File
@@ -30,9 +30,9 @@ const (
var clickhouseMigrationFS embed.FS
// MigrateClickHouse runs goose migrations against ClickHouse when enabled.
func MigrateClickHouse() {
func MigrateClickHouse() Report {
if !config.Config.ClickHouse.Enabled {
return
return Report{Backend: "ClickHouse"}
}
cfg := config.Config.ClickHouse
@@ -74,14 +74,30 @@ func MigrateClickHouse() {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] create goose provider failed: %v\n", err)
}
previousVersion, err := provider.GetDBVersion(context.Background())
if err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] get goose version failed: %v\n", err)
}
if _, err := provider.Up(context.Background()); err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] goose migrate failed: %v\n", err)
}
currentVersion, err := provider.GetDBVersion(context.Background())
if err != nil {
closeClickHouseDB(sqlDB)
log.Fatalf("[ClickHouse] get migrated goose version failed: %v\n", err)
}
closeClickHouseDB(sqlDB)
log.Println("[ClickHouse] goose migrate success")
return Report{
Backend: "ClickHouse",
Enabled: true,
Version: currentVersion,
Applied: currentVersion != previousVersion,
}
}
func closeClickHouseDB(sqlDB *sql.DB) {
+23 -1
View File
@@ -42,6 +42,14 @@ const (
zoneDropLegacySQLVersion int64 = 202607130001
)
// Report describes the database migration state observed during startup.
type Report struct {
Backend string
Enabled bool
Version int64
Applied bool
}
func gooseDialect() string {
if !config.Config.Database.Enabled {
return dialectSqlite
@@ -57,7 +65,7 @@ func migrationDir() string {
}
// Migrate 执行数据库迁移:全部结构变更走 goose SQL;Zone 历史域名导入在 SQL 之后自动执行。
func Migrate() {
func Migrate() Report {
gormDB := db.DB(context.Background())
if gormDB == nil {
log.Fatalf("[%s] database not initialized\n", dbType())
@@ -75,6 +83,10 @@ func Migrate() {
if err := resyncGooseVersionSequence(sqlDB); err != nil {
log.Fatalf("[%s] resync goose_db_version sequence failed: %v\n", dbType(), err)
}
previousVersion, err := goose.GetDBVersion(sqlDB)
if err != nil {
log.Fatalf("[%s] get goose version failed: %v\n", dbType(), err)
}
// 1) SQL up to zone-import marker (includes of_zones DDL; still has legacy columns).
if err := goose.UpTo(sqlDB, migrationDir(), zoneImportSQLVersion); err != nil {
log.Fatalf("[%s] goose migrate (up to zone import) failed: %v\n", dbType(), err)
@@ -91,8 +103,18 @@ func Migrate() {
}
clearSystemConfigCache()
currentVersion, err := goose.GetDBVersion(sqlDB)
if err != nil {
log.Fatalf("[%s] get migrated goose version failed: %v\n", dbType(), err)
}
log.Printf("[%s] goose migrate success\n", dbType())
return Report{
Backend: dbType(),
Enabled: true,
Version: currentVersion,
Applied: currentVersion != previousVersion,
}
}
// maybeImportZoneDomains runs legacy→Zone import only when the DB is still in the
+13 -4
View File
@@ -8,6 +8,7 @@ import (
"context"
"errors"
"log"
"net"
"net/http"
"os"
"os/signal"
@@ -29,8 +30,8 @@ import (
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
// Serve 启动 HTTP API 服务
func Serve() {
// Serve 启动 HTTP API 服务。onStarted 仅会在 HTTP 地址成功绑定后调用。
func Serve(onStarted func()) {
// 运行模式
if config.Config.App.IsProduction() {
gin.SetMode(gin.ReleaseMode)
@@ -85,9 +86,17 @@ func Serve() {
ReadHeaderTimeout: 10 * time.Second,
}
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", config.Config.App.Addr)
if err != nil {
log.Fatalf("[API] server failed to listen on %s: %v\n", config.Config.App.Addr, err)
}
if onStarted != nil {
onStarted()
}
go func() {
log.Printf("[API] server starting on %s\n", config.Config.App.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Printf("[API] server listening on %s\n", config.Config.App.Addr)
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("[API] server failed: %v\n", err)
}
}()