feat: initialize independent Bell product skeleton (#9)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user