feat: initialize independent Bell product skeleton (#9)

This commit is contained in:
QiuSW
2026-08-12 17:35:49 +08:00
parent be2c1e3c81
commit a5d846a437
31 changed files with 8910 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
package bell
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"git.ilapage.cn/ila/yovision/Bell/server/config"
"git.ilapage.cn/ila/yovision/Bell/server/internal/platform"
)
const Version = "0.1.0"
func Run(ctx context.Context, args []string) error {
command := "serve"
if len(args) > 0 {
command = args[0]
}
if command == "version" {
fmt.Println(Version)
return nil
}
if command != "serve" && command != "migrate" {
return fmt.Errorf("unknown command %q (use serve, migrate, or version)", command)
}
cfg, err := config.Load()
if err != nil {
return err
}
db, err := platform.OpenDatabase(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer db.Close()
if err := platform.Migrate(ctx, db); err != nil {
return err
}
if command == "migrate" {
return nil
}
app := platform.NewHTTPApp(db)
server := &http.Server{Addr: cfg.HTTPAddress, Handler: app.Handler()}
serverCtx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
go func() {
<-serverCtx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
slog.Info("Bell listening", "address", cfg.HTTPAddress, "environment", cfg.Environment)
if err := server.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package config
import (
"fmt"
"os"
"strconv"
"time"
)
type Config struct {
Environment string
HTTPAddress string
DatabaseURL string
ShutdownTimeout time.Duration
}
func Load() (Config, error) {
cfg := Config{
Environment: value("BELL_ENV", "development"),
HTTPAddress: value("BELL_HTTP_ADDRESS", "127.0.0.1:8082"),
DatabaseURL: os.Getenv("BELL_DATABASE_URL"),
ShutdownTimeout: 10 * time.Second,
}
if raw := os.Getenv("BELL_SHUTDOWN_SECONDS"); raw != "" {
seconds, err := strconv.Atoi(raw)
if err != nil || seconds < 1 || seconds > 300 {
return Config{}, fmt.Errorf("BELL_SHUTDOWN_SECONDS must be between 1 and 300")
}
cfg.ShutdownTimeout = time.Duration(seconds) * time.Second
}
if cfg.DatabaseURL == "" {
return Config{}, fmt.Errorf("BELL_DATABASE_URL is required")
}
return cfg, nil
}
func value(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
+14
View File
@@ -0,0 +1,14 @@
module git.ilapage.cn/ila/yovision/Bell/server
go 1.26.5
require github.com/jackc/pgx/v5 v5.7.6
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/crypto v0.37.0 // indirect
golang.org/x/sync v0.13.0 // indirect
golang.org/x/text v0.24.0 // indirect
)
+28
View File
@@ -0,0 +1,28 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+30
View File
@@ -0,0 +1,30 @@
package platform
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func OpenDatabase(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
poolConfig, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse Bell database URL: %w", err)
}
poolConfig.MaxConns = 8
poolConfig.MinConns = 1
poolConfig.MaxConnLifetime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
if err != nil {
return nil, fmt.Errorf("open Bell database: %w", err)
}
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping Bell database: %w", err)
}
return pool, nil
}
+48
View File
@@ -0,0 +1,48 @@
package platform
import (
"encoding/json"
"net/http"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
type HTTPApp struct {
DB *pgxpool.Pool
Mux *http.ServeMux
}
func NewHTTPApp(db *pgxpool.Pool) *HTTPApp {
app := &HTTPApp{DB: db, Mux: http.NewServeMux()}
app.Mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"status": "ok", "service": "bell", "time": time.Now().UTC()})
})
app.Mux.HandleFunc("GET /readyz", func(w http.ResponseWriter, r *http.Request) {
if err := db.Ping(r.Context()); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"status": "not_ready"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
})
return app
}
func (a *HTTPApp) Handler() http.Handler {
return securityHeaders(a.Mux)
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
@@ -0,0 +1,16 @@
package platform
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestSecurityHeaders(t *testing.T) {
h := securityHeaders(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }))
recorder := httptest.NewRecorder()
h.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
if recorder.Header().Get("X-Frame-Options") != "DENY" {
t.Fatal("missing frame protection")
}
}
@@ -0,0 +1,56 @@
package platform
import (
"context"
"embed"
"fmt"
"io/fs"
"sort"
"github.com/jackc/pgx/v5/pgxpool"
)
//go:embed migrations/*.sql
var migrationFiles embed.FS
func Migrate(ctx context.Context, pool *pgxpool.Pool) error {
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS bell_schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())`); err != nil {
return fmt.Errorf("create migration ledger: %w", err)
}
entries, err := fs.ReadDir(migrationFiles, "migrations")
if err != nil {
return fmt.Errorf("read embedded migrations: %w", err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
if entry.IsDir() {
continue
}
var applied bool
if err := pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM bell_schema_migrations WHERE name=$1)`, entry.Name()).Scan(&applied); err != nil {
return err
}
if applied {
continue
}
sqlBytes, err := migrationFiles.ReadFile("migrations/" + entry.Name())
if err != nil {
return err
}
tx, err := pool.Begin(ctx)
if err != nil {
return err
}
if _, err = tx.Exec(ctx, string(sqlBytes)); err == nil {
_, err = tx.Exec(ctx, `INSERT INTO bell_schema_migrations(name) VALUES($1)`, entry.Name())
}
if err != nil {
_ = tx.Rollback(ctx)
return fmt.Errorf("apply migration %s: %w", entry.Name(), err)
}
if err := tx.Commit(ctx); err != nil {
return fmt.Errorf("commit migration %s: %w", entry.Name(), err)
}
}
return nil
}
@@ -0,0 +1,5 @@
CREATE TABLE IF NOT EXISTS bell_runtime_probe (
singleton boolean PRIMARY KEY DEFAULT true CHECK (singleton),
started_at timestamptz NOT NULL DEFAULT now()
);
+19
View File
@@ -0,0 +1,19 @@
// Bell is derived from the frozen go-admin baseline. See Bell/LICENSES/SOURCES.md.
package main
import (
"context"
"fmt"
"log/slog"
"os"
"git.ilapage.cn/ila/yovision/Bell/server/cmd/bell"
)
func main() {
if err := bell.Run(context.Background(), os.Args[1:]); err != nil {
slog.Error("bell stopped", "error", err)
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}