jenkins
This commit is contained in:
@@ -76,7 +76,7 @@ func databaseDSN(c *config.Database, name string) (string, error) {
|
||||
}
|
||||
query := c.Config
|
||||
if query == "" {
|
||||
query = "timeout=5s&parseTime=True&loc=Local&charset=utf8mb4"
|
||||
query = "timeout=5s&readTimeout=5s&writeTimeout=5s&parseTime=True&loc=Local&charset=utf8mb4"
|
||||
}
|
||||
return fmt.Sprintf("%s:%s@tcp(%s)/%s?%s", c.User, c.Password, net.JoinHostPort(host, port), name, query), nil
|
||||
case "pgsql":
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"kra/internal/config"
|
||||
)
|
||||
|
||||
func TestMySQLDatabaseDSNIncludesNetworkTimeouts(t *testing.T) {
|
||||
dsn, err := databaseDSN(&config.Database{
|
||||
Driver: "mysql",
|
||||
User: "root",
|
||||
Password: "secret",
|
||||
Host: "mysql",
|
||||
Name: "kra",
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, option := range []string{"timeout=5s", "readTimeout=5s", "writeTimeout=5s"} {
|
||||
if !strings.Contains(dsn, option) {
|
||||
t.Fatalf("DSN %q does not contain %q", dsn, option)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -137,6 +137,14 @@ func (d *Data) InitializeDatabase(ctx context.Context, input *system.DatabaseCon
|
||||
}
|
||||
}
|
||||
}()
|
||||
releaseLock, err := acquireInitializationLock(ctx, candidate, database)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer releaseLock()
|
||||
if persistedDatabaseConfigured(d.runtime) {
|
||||
return errors.New("数据库已初始化,无需重复初始化")
|
||||
}
|
||||
db := candidate.WithContext(ctx)
|
||||
if err := migrateAll(db, d.catalog); err != nil {
|
||||
return err
|
||||
|
||||
@@ -54,6 +54,23 @@ func TestInitializationStateTracksRealDatabaseConnection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistedDatabaseConfiguredReadsSharedRuntimeConfig(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.yaml")
|
||||
if err := os.WriteFile(configPath, []byte("data:\n database:\n driver: mysql\n name: kra\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runtime := config.NewStore(&config.Config{Admin: &config.Admin{ConfigPath: configPath}})
|
||||
if !persistedDatabaseConfigured(runtime) {
|
||||
t.Fatal("persisted database configuration was not detected")
|
||||
}
|
||||
if err := os.WriteFile(configPath, []byte("data:\n database:\n driver: mysql\n name: \"\"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if persistedDatabaseConfigured(runtime) {
|
||||
t.Fatal("empty persisted database configuration was treated as initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistDatabaseConfigPreservesTemplateAndRotatesJWTKey(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
configPath := filepath.Join(root, "config.yaml")
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package data
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
configpkg "kra/internal/config"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// acquireInitializationLock serializes first-install work across backend Pods.
|
||||
// The Kubernetes deployment uses MySQL, whose named lock is connection-scoped
|
||||
// and therefore remains held while the candidate database is migrated and seeded.
|
||||
func acquireInitializationLock(ctx context.Context, db *gorm.DB, database *configpkg.Database) (func(), error) {
|
||||
if normalizedDriver(database.Driver) != "mysql" {
|
||||
return func() {}, nil
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open initialization lock connection: %w", err)
|
||||
}
|
||||
conn, err := sqlDB.Conn(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reserve initialization lock connection: %w", err)
|
||||
}
|
||||
lockName := initializationLockName(database)
|
||||
var acquired sql.NullInt64
|
||||
if err := conn.QueryRowContext(ctx, "SELECT GET_LOCK(?, ?)", lockName, 60).Scan(&acquired); err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("acquire database initialization lock: %w", err)
|
||||
}
|
||||
if !acquired.Valid || acquired.Int64 != 1 {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("database initialization lock is busy")
|
||||
}
|
||||
return func() {
|
||||
releaseCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
_, _ = conn.ExecContext(releaseCtx, "SELECT RELEASE_LOCK(?)", lockName)
|
||||
cancel()
|
||||
_ = conn.Close()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func initializationLockName(database *configpkg.Database) string {
|
||||
identity := fmt.Sprintf("%s\x00%s\x00%s\x00%s", database.Driver, database.Host, database.Port, database.Name)
|
||||
sum := sha256.Sum256([]byte(identity))
|
||||
return "kra-init-" + hex.EncodeToString(sum[:])[:48]
|
||||
}
|
||||
|
||||
func persistedDatabaseConfigured(runtime *configpkg.Store) bool {
|
||||
if runtime == nil || runtime.ConfigPath() == "" {
|
||||
return false
|
||||
}
|
||||
persisted, err := configpkg.Load(runtime.ConfigPath())
|
||||
if err != nil || persisted == nil || persisted.Data == nil {
|
||||
return false
|
||||
}
|
||||
return databaseConnectionConfigured(persisted.Data.Database)
|
||||
}
|
||||
@@ -36,7 +36,11 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||
prefix = strings.TrimSuffix(snapshot.Admin.RouterPrefix, "/")
|
||||
}
|
||||
public := engine.Group(prefix)
|
||||
public.GET("/health", func(c *gin.Context) { c.JSON(http.StatusOK, "ok") })
|
||||
health := func(c *gin.Context) { c.JSON(http.StatusOK, "ok") }
|
||||
engine.GET("/health", health)
|
||||
if prefix != "" {
|
||||
public.GET("/health", health)
|
||||
}
|
||||
|
||||
private := engine.Group(prefix)
|
||||
// The reference administration behavior installs operation recording after JWT,
|
||||
@@ -103,11 +107,11 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||
}
|
||||
}
|
||||
if ws != nil && ws.Enabled() {
|
||||
engine.GET(ws.Path(), handleWebSocket)
|
||||
engine.GET(websocketRoutePath(prefix, ws), handleWebSocket)
|
||||
}
|
||||
if logger != nil {
|
||||
logger.Info("websocket endpoint initialized", "mod", "websocket",
|
||||
"enabled", ws != nil && ws.Enabled(), "path", websocketPath(ws))
|
||||
"enabled", ws != nil && ws.Enabled(), "path", websocketRoutePath(prefix, ws))
|
||||
}
|
||||
env := ""
|
||||
if snapshot != nil && snapshot.Admin != nil && snapshot.Admin.App != nil {
|
||||
@@ -125,10 +129,6 @@ func NewGinEngineWithRuntime(runtime *config.Store, access *systemservice.Access
|
||||
}
|
||||
|
||||
engine.NoRoute(func(c *gin.Context) {
|
||||
if ws != nil && ws.Enabled() && c.Request.Method == http.MethodGet && c.Request.URL.Path == websocketRoutePath(prefix, ws) {
|
||||
handleWebSocket(c)
|
||||
return
|
||||
}
|
||||
if staticfiles.Serve(c, runtime) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -133,6 +133,17 @@ func TestSwaggerSupportsRouterPrefix(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthEndpointKeepsRootProbePathWithRouterPrefix(t *testing.T) {
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{RouterPrefix: "/admin"}}), nil, emptyHandlers(), nil, nil, nil, nil, "test")
|
||||
for _, path := range []string{"/health", "/admin/health"} {
|
||||
response := httptest.NewRecorder()
|
||||
engine.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
if response.Code != http.StatusOK || response.Body.String() != `"ok"` {
|
||||
t.Fatalf("health path %s status=%d body=%s", path, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwaggerUsesRootBasePathWithoutRouterPrefix(t *testing.T) {
|
||||
engine := NewGinEngine(config.NewStore(&config.Config{Admin: &config.Admin{}}), nil, emptyHandlers(), nil, nil, nil, nil, "v1.0.0")
|
||||
response := httptest.NewRecorder()
|
||||
|
||||
@@ -102,6 +102,21 @@ func TestWebSocketRouteSupportsRouterPrefix(t *testing.T) {
|
||||
t.Fatal("prefixed websocket handshake returned nil connection")
|
||||
}
|
||||
_ = connection.Close()
|
||||
for _, route := range engine.Routes() {
|
||||
if route.Method == http.MethodGet && route.Path == "/ws" {
|
||||
t.Fatal("unprefixed websocket route should not be registered")
|
||||
}
|
||||
}
|
||||
found := false
|
||||
for _, route := range engine.Routes() {
|
||||
if route.Method == http.MethodGet && route.Path == "/admin/ws" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("prefixed websocket route was not registered")
|
||||
}
|
||||
}
|
||||
|
||||
var _ platformws.Hub = (*websocketintegration.Server)(nil)
|
||||
|
||||
Reference in New Issue
Block a user