feat(server): скелет Go-сервера с healthz, readyz и api/v1/meta
- internal/config: конфигурация из env с валидацией и дефолтами (§5.1, §9.1) - internal/httpx: middleware (request id, slog-логи, recover, security-заголовки), единый формат ошибок §8.5 и лимит тела запроса - internal/database: SQLite WAL + busy_timeout + foreign_keys, один writer и пул чтения, goose-миграции embedded, PRAGMA optimize по расписанию - internal/meta: полезная нагрузка /api/v1/meta (версия, фичи, лимиты) - internal/server: маршруты, SPA-раздача собранного клиента, OpenAPI 3.1 - cmd/glchat: точка входа с graceful shutdown и JSON-логами - тесты: конфиг, миграции и идемпотентность, HTTP-эндпоинты, статика, 404/405
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultListenAddr = "127.0.0.1:8080"
|
||||
DefaultDataDir = "/opt/glchat/data"
|
||||
DefaultLogLevel = "info"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Domain string
|
||||
FilesDomain string
|
||||
InstanceName string
|
||||
ListenAddr string
|
||||
DataDir string
|
||||
WebRoot string
|
||||
DatabasePath string
|
||||
SQLiteCache string
|
||||
SQLiteMmap string
|
||||
LogLevel string
|
||||
LogFormat string
|
||||
ReadPoolSize int
|
||||
MaxUploadSize int64
|
||||
Version string
|
||||
Commit string
|
||||
BuildDate string
|
||||
ShutdownTTL time.Duration
|
||||
Maintenance time.Duration
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
cfg := Config{
|
||||
Domain: env("DOMAIN", "localhost"),
|
||||
FilesDomain: env("FILES_DOMAIN", ""),
|
||||
InstanceName: env("INSTANCE_NAME", "glchat"),
|
||||
ListenAddr: env("LISTEN_ADDR", DefaultListenAddr),
|
||||
DataDir: env("DATA_DIR", DefaultDataDir),
|
||||
WebRoot: env("WEB_ROOT", "/app/web"),
|
||||
DatabasePath: env("DATABASE_PATH", ""),
|
||||
SQLiteCache: env("SQLITE_CACHE_SIZE", "-8000"),
|
||||
SQLiteMmap: env("SQLITE_MMAP_SIZE", "134217728"),
|
||||
LogLevel: strings.ToLower(env("LOG_LEVEL", DefaultLogLevel)),
|
||||
LogFormat: strings.ToLower(env("LOG_FORMAT", "json")),
|
||||
ReadPoolSize: envInt("SQLITE_READ_POOL", 4),
|
||||
MaxUploadSize: envInt64("MAX_UPLOAD_SIZE", 26214400),
|
||||
Version: env("APP_VERSION", "dev"),
|
||||
Commit: env("APP_COMMIT", "none"),
|
||||
BuildDate: env("APP_BUILD_DATE", "unknown"),
|
||||
ShutdownTTL: time.Duration(envInt("SHUTDOWN_TIMEOUT_SECONDS", 20)) * time.Second,
|
||||
Maintenance: time.Duration(envInt("DB_MAINTENANCE_INTERVAL_SECONDS", 21600)) * time.Second,
|
||||
}
|
||||
if cfg.FilesDomain == "" {
|
||||
cfg.FilesDomain = "files." + cfg.Domain
|
||||
}
|
||||
if cfg.DatabasePath == "" {
|
||||
cfg.DatabasePath = cfg.DataDir + "/db/glchat.db"
|
||||
}
|
||||
if cfg.ReadPoolSize < 1 {
|
||||
return Config{}, fmt.Errorf("SQLITE_READ_POOL must be >= 1, got %d", cfg.ReadPoolSize)
|
||||
}
|
||||
if _, err := parseLogLevel(cfg.LogLevel); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if cfg.LogFormat != "json" && cfg.LogFormat != "text" {
|
||||
return Config{}, fmt.Errorf("LOG_FORMAT must be json or text, got %q", cfg.LogFormat)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) BaseURL() string { return "https://" + c.Domain }
|
||||
func (c Config) FilesURL() string { return "https://" + c.FilesDomain }
|
||||
|
||||
func (c Config) LogLevelNumeric() int {
|
||||
level, err := parseLogLevel(c.LogLevel)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
func parseLogLevel(level string) (int, error) {
|
||||
switch level {
|
||||
case "debug":
|
||||
return -4, nil
|
||||
case "info":
|
||||
return 0, nil
|
||||
case "warn", "warning":
|
||||
return 4, nil
|
||||
case "error":
|
||||
return 8, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown LOG_LEVEL %q", level)
|
||||
}
|
||||
}
|
||||
|
||||
func env(key, fallback string) string {
|
||||
if v, ok := os.LookupEnv(key); ok && v != "" {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func envInt(key string, fallback int) int {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.Atoi(v)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func envInt64(key string, fallback int64) int64 {
|
||||
v, ok := os.LookupEnv(key)
|
||||
if !ok || v == "" {
|
||||
return fallback
|
||||
}
|
||||
n, err := strconv.ParseInt(v, 10, 64)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestLoadDefaults(t *testing.T) {
|
||||
t.Setenv("DOMAIN", "")
|
||||
t.Setenv("LISTEN_ADDR", "")
|
||||
t.Setenv("DATA_DIR", "")
|
||||
t.Setenv("DATABASE_PATH", "")
|
||||
t.Setenv("INSTANCE_NAME", "")
|
||||
t.Setenv("FILES_DOMAIN", "")
|
||||
t.Setenv("LOG_LEVEL", "")
|
||||
t.Setenv("LOG_FORMAT", "")
|
||||
t.Setenv("SQLITE_READ_POOL", "")
|
||||
t.Setenv("MAX_UPLOAD_SIZE", "")
|
||||
t.Setenv("SHUTDOWN_TIMEOUT_SECONDS", "")
|
||||
t.Setenv("DB_MAINTENANCE_INTERVAL_SECONDS", "")
|
||||
t.Setenv("APP_VERSION", "")
|
||||
t.Setenv("APP_COMMIT", "")
|
||||
t.Setenv("APP_BUILD_DATE", "")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() returned error: %v", err)
|
||||
}
|
||||
if cfg.ListenAddr != DefaultListenAddr {
|
||||
t.Errorf("ListenAddr = %q, want %q", cfg.ListenAddr, DefaultListenAddr)
|
||||
}
|
||||
if cfg.DataDir != DefaultDataDir {
|
||||
t.Errorf("DataDir = %q, want %q", cfg.DataDir, DefaultDataDir)
|
||||
}
|
||||
if cfg.DatabasePath != DefaultDataDir+"/db/glchat.db" {
|
||||
t.Errorf("DatabasePath = %q, want default under data dir", cfg.DatabasePath)
|
||||
}
|
||||
if cfg.FilesDomain != "files.localhost" {
|
||||
t.Errorf("FilesDomain = %q, want files.localhost", cfg.FilesDomain)
|
||||
}
|
||||
if cfg.ReadPoolSize != 4 {
|
||||
t.Errorf("ReadPoolSize = %d, want 4", cfg.ReadPoolSize)
|
||||
}
|
||||
if cfg.MaxUploadSize != 26214400 {
|
||||
t.Errorf("MaxUploadSize = %d, want 26214400", cfg.MaxUploadSize)
|
||||
}
|
||||
if cfg.Version != "dev" {
|
||||
t.Errorf("Version = %q, want dev", cfg.Version)
|
||||
}
|
||||
if cfg.LogLevelNumeric() != 0 {
|
||||
t.Errorf("LogLevelNumeric() = %d, want 0 for info", cfg.LogLevelNumeric())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadEnvOverrides(t *testing.T) {
|
||||
t.Setenv("DOMAIN", "gl.mhspx.su")
|
||||
t.Setenv("FILES_DOMAIN", "")
|
||||
t.Setenv("LISTEN_ADDR", "0.0.0.0:9090")
|
||||
t.Setenv("DATA_DIR", "/srv/glchat")
|
||||
t.Setenv("DATABASE_PATH", "/srv/glchat/db/custom.db")
|
||||
t.Setenv("LOG_LEVEL", "debug")
|
||||
t.Setenv("LOG_FORMAT", "text")
|
||||
t.Setenv("SQLITE_READ_POOL", "8")
|
||||
t.Setenv("MAX_UPLOAD_SIZE", "104857600")
|
||||
t.Setenv("APP_VERSION", "v0.1.0")
|
||||
t.Setenv("APP_COMMIT", "abc1234")
|
||||
t.Setenv("SHUTDOWN_TIMEOUT_SECONDS", "5")
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() returned error: %v", err)
|
||||
}
|
||||
if cfg.Domain != "gl.mhspx.su" {
|
||||
t.Errorf("Domain = %q", cfg.Domain)
|
||||
}
|
||||
if cfg.FilesDomain != "files.gl.mhspx.su" {
|
||||
t.Errorf("FilesDomain = %q, want derived subdomain", cfg.FilesDomain)
|
||||
}
|
||||
if cfg.DatabasePath != "/srv/glchat/db/custom.db" {
|
||||
t.Errorf("DatabasePath = %q", cfg.DatabasePath)
|
||||
}
|
||||
if cfg.ReadPoolSize != 8 {
|
||||
t.Errorf("ReadPoolSize = %d", cfg.ReadPoolSize)
|
||||
}
|
||||
if cfg.MaxUploadSize != 104857600 {
|
||||
t.Errorf("MaxUploadSize = %d", cfg.MaxUploadSize)
|
||||
}
|
||||
if cfg.LogLevelNumeric() != -4 {
|
||||
t.Errorf("LogLevelNumeric() = %d, want -4 for debug", cfg.LogLevelNumeric())
|
||||
}
|
||||
if cfg.LogFormat != "text" {
|
||||
t.Errorf("LogFormat = %q", cfg.LogFormat)
|
||||
}
|
||||
if cfg.BaseURL() != "https://gl.mhspx.su" {
|
||||
t.Errorf("BaseURL() = %q", cfg.BaseURL())
|
||||
}
|
||||
if cfg.FilesURL() != "https://files.gl.mhspx.su" {
|
||||
t.Errorf("FilesURL() = %q", cfg.FilesURL())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsInvalidValues(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
}{
|
||||
{name: "log level", env: map[string]string{"LOG_LEVEL": "verbose"}},
|
||||
{name: "log format", env: map[string]string{"LOG_FORMAT": "xml"}},
|
||||
{name: "read pool", env: map[string]string{"SQLITE_READ_POOL": "0"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Setenv("LOG_LEVEL", "info")
|
||||
t.Setenv("LOG_FORMAT", "json")
|
||||
t.Setenv("SQLITE_READ_POOL", "4")
|
||||
for k, v := range tc.env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatalf("Load() accepted invalid %s", tc.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user