feat: 实现 version/serve 骨架、迁移占位与健康检查
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
fmt.Fprintln(os.Stderr, "usage: nixmsg <command>")
|
||||
os.Exit(2)
|
||||
}
|
||||
switch os.Args[1] {
|
||||
case "version":
|
||||
cmdVersion(os.Args[2:])
|
||||
case "serve":
|
||||
if err := cmdServe(os.Args[2:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/config"
|
||||
"git.asio.asia/nixevol/NixMsg/internal/store"
|
||||
"git.asio.asia/nixevol/NixMsg/web"
|
||||
)
|
||||
|
||||
func cmdServe(_ []string) error {
|
||||
cfgPath := config.PathFromEnv()
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
return runServe(ctx, cfg)
|
||||
}
|
||||
|
||||
func runServe(ctx context.Context, cfg config.Config) error {
|
||||
wire()
|
||||
|
||||
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir data_dir: %w", err)
|
||||
}
|
||||
|
||||
db, err := store.OpenWriter(cfg.DataDir, cfg.SQLiteSynchronous)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if migErr := store.Migrate(db); migErr != nil {
|
||||
return migErr
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
// 确保前端资源被链接进二进制;完整静态托管由后续任务完善。
|
||||
_ = web.Dist()
|
||||
|
||||
ln, err := net.Listen("tcp", cfg.Listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen %s: %w", cfg.Listen, err)
|
||||
}
|
||||
|
||||
if err := writeListenAddr(cfg.DataDir, ln.Addr().String()); err != nil {
|
||||
_ = ln.Close()
|
||||
return err
|
||||
}
|
||||
|
||||
srv := &http.Server{
|
||||
Handler: mux,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- srv.Serve(ln)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(shutdownCtx)
|
||||
serveErr := <-errCh
|
||||
if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) {
|
||||
return serveErr
|
||||
}
|
||||
return nil
|
||||
case serveErr := <-errCh:
|
||||
if errors.Is(serveErr, http.ErrServerClosed) {
|
||||
return nil
|
||||
}
|
||||
return serveErr
|
||||
}
|
||||
}
|
||||
|
||||
func writeListenAddr(dataDir, addr string) error {
|
||||
path := filepath.Join(dataDir, "listen.addr")
|
||||
return os.WriteFile(path, []byte(addr+"\n"), 0o644)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.asio.asia/nixevol/NixMsg/internal/config"
|
||||
)
|
||||
|
||||
func TestServeHealthzAndListenAddr(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
dataDir := t.TempDir()
|
||||
cfgPath := filepath.Join(dataDir, "config.yaml")
|
||||
cfgYAML := []byte("listen: \"127.0.0.1:0\"\ndata_dir: \"" + filepath.ToSlash(dataDir) + "\"\n")
|
||||
if err := os.WriteFile(cfgPath, cfgYAML, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- runServe(ctx, cfg)
|
||||
}()
|
||||
|
||||
var addr string
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
b, readErr := os.ReadFile(filepath.Join(dataDir, "listen.addr"))
|
||||
if readErr == nil {
|
||||
addr = strings.TrimSpace(string(b))
|
||||
if addr != "" {
|
||||
break
|
||||
}
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if addr == "" {
|
||||
t.Fatal("listen.addr not written")
|
||||
}
|
||||
|
||||
resp, err := http.Get("http://" + addr + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("healthz: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, body)
|
||||
}
|
||||
if string(body) != "ok" {
|
||||
t.Fatalf("unexpected body: %q", body)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(dataDir, "nixmsg.db")); err != nil {
|
||||
t.Fatalf("db missing: %v", err)
|
||||
}
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("serve exit: %v", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("serve did not stop")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// Version 由构建注入;未注入时显示开发占位。
|
||||
var Version = "dev"
|
||||
|
||||
func cmdVersion(_ []string) {
|
||||
fmt.Printf("nixmsg %s %s/%s\n", Version, runtime.GOOS, runtime.GOARCH)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package main
|
||||
|
||||
// wire 组装各业务模块。T0.1 仅占位,T0.4 起由各线注册。
|
||||
func wire() {}
|
||||
@@ -0,0 +1,122 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
)
|
||||
|
||||
// Config 对应 DEVELOPMENT 第 11.1 节的配置文件。
|
||||
type Config struct {
|
||||
Listen string `yaml:"listen"`
|
||||
AdminListen string `yaml:"admin_listen"`
|
||||
TLS TLSConfig `yaml:"tls"`
|
||||
TrustedProxies []string `yaml:"trusted_proxies"`
|
||||
DataDir string `yaml:"data_dir"`
|
||||
Limits LimitsConfig `yaml:"limits"`
|
||||
SessionIdleDays int `yaml:"session_idle_days"`
|
||||
RecordRetentionDays int `yaml:"record_retention_days"`
|
||||
IdempotencyHours int `yaml:"idempotency_hours"`
|
||||
ReceiptRetentionDays int `yaml:"receipt_retention_days"`
|
||||
SQLiteSynchronous string `yaml:"sqlite_synchronous"`
|
||||
Metrics MetricsConfig `yaml:"metrics"`
|
||||
Log LogConfig `yaml:"log"`
|
||||
}
|
||||
|
||||
type TLSConfig struct {
|
||||
CertFile string `yaml:"cert_file"`
|
||||
KeyFile string `yaml:"key_file"`
|
||||
AllowPlaintext bool `yaml:"allow_plaintext"`
|
||||
}
|
||||
|
||||
type LimitsConfig struct {
|
||||
MaxBodyBytes int `yaml:"max_body_bytes"`
|
||||
MaxMetaBytes int `yaml:"max_meta_bytes"`
|
||||
MaxFrameBytes int `yaml:"max_frame_bytes"`
|
||||
MaxTTLSeconds int `yaml:"max_ttl_seconds"`
|
||||
MaxScheduleSeconds int `yaml:"max_schedule_seconds"`
|
||||
MaxGroupMembers int `yaml:"max_group_members"`
|
||||
GraceSeconds int `yaml:"grace_seconds"`
|
||||
AckTimeoutSeconds int `yaml:"ack_timeout_seconds"`
|
||||
DeliveryWindow int `yaml:"delivery_window"`
|
||||
ReceiptWindow int `yaml:"receipt_window"`
|
||||
RequestsPerSecond int `yaml:"requests_per_second"`
|
||||
MaxPendingPerSender int `yaml:"max_pending_per_sender"`
|
||||
MaxPendingPerReceiver int `yaml:"max_pending_per_receiver"`
|
||||
}
|
||||
|
||||
type MetricsConfig struct {
|
||||
Token string `yaml:"token"`
|
||||
}
|
||||
|
||||
type LogConfig struct {
|
||||
Level string `yaml:"level"`
|
||||
}
|
||||
|
||||
// Default 返回与 DEVELOPMENT 11.1 示例一致的默认值。
|
||||
func Default() Config {
|
||||
return Config{
|
||||
Listen: ":7443",
|
||||
AdminListen: "",
|
||||
TLS: TLSConfig{
|
||||
AllowPlaintext: false,
|
||||
},
|
||||
TrustedProxies: nil,
|
||||
DataDir: "./data",
|
||||
Limits: LimitsConfig{
|
||||
MaxBodyBytes: 262144,
|
||||
MaxMetaBytes: 4096,
|
||||
MaxFrameBytes: 786432,
|
||||
MaxTTLSeconds: 2592000,
|
||||
MaxScheduleSeconds: 31536000,
|
||||
MaxGroupMembers: 1000,
|
||||
GraceSeconds: 60,
|
||||
AckTimeoutSeconds: 300,
|
||||
DeliveryWindow: 32,
|
||||
ReceiptWindow: 64,
|
||||
RequestsPerSecond: 50,
|
||||
MaxPendingPerSender: 10000,
|
||||
MaxPendingPerReceiver: 10000,
|
||||
},
|
||||
SessionIdleDays: 30,
|
||||
RecordRetentionDays: 7,
|
||||
IdempotencyHours: 24,
|
||||
ReceiptRetentionDays: 7,
|
||||
SQLiteSynchronous: "FULL",
|
||||
Metrics: MetricsConfig{},
|
||||
Log: LogConfig{
|
||||
Level: "info",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load 从路径加载 YAML 配置;空字段保留默认值。
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Default()
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &cfg); err != nil {
|
||||
return Config{}, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
if cfg.Listen == "" {
|
||||
cfg.Listen = Default().Listen
|
||||
}
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = Default().DataDir
|
||||
}
|
||||
if cfg.SQLiteSynchronous == "" {
|
||||
cfg.SQLiteSynchronous = Default().SQLiteSynchronous
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// PathFromEnv 返回 NIXMSG_CONFIG 或默认 ./config.yaml。
|
||||
func PathFromEnv() string {
|
||||
if p := os.Getenv("NIXMSG_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
return "./config.yaml"
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
// OpenWriter 按 DEVELOPMENT 7.7 打开写连接(带 _txlock=immediate)。
|
||||
func OpenWriter(dataDir, synchronous string) (*sql.DB, error) {
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("mkdir data_dir: %w", err)
|
||||
}
|
||||
dsn, err := writeDSN(dataDir, synchronous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
db.SetMaxOpenConns(1)
|
||||
db.SetMaxIdleConns(1)
|
||||
if err := db.Ping(); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("ping sqlite: %w", err)
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func writeDSN(dataDir, synchronous string) (string, error) {
|
||||
sync := strings.ToUpper(strings.TrimSpace(synchronous))
|
||||
if sync == "" {
|
||||
sync = "FULL"
|
||||
}
|
||||
if sync != "FULL" && sync != "NORMAL" {
|
||||
return "", fmt.Errorf("invalid sqlite_synchronous: %s", synchronous)
|
||||
}
|
||||
dbPath := filepath.ToSlash(filepath.Join(dataDir, "nixmsg.db"))
|
||||
return fmt.Sprintf(
|
||||
"file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(%s)&_pragma=foreign_keys(ON)&_pragma=secure_delete(ON)&_txlock=immediate",
|
||||
dbPath,
|
||||
sync,
|
||||
), nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestOpenAndMigrate(t *testing.T) {
|
||||
t.Parallel()
|
||||
dir := t.TempDir()
|
||||
db, err := OpenWriter(dir, "FULL")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = db.Close() }()
|
||||
|
||||
if migErr := Migrate(db); migErr != nil {
|
||||
t.Fatal(migErr)
|
||||
}
|
||||
if migErr := Migrate(db); migErr != nil {
|
||||
t.Fatal(migErr)
|
||||
}
|
||||
|
||||
var n int
|
||||
if scanErr := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations`).Scan(&n); scanErr != nil {
|
||||
t.Fatal(scanErr)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("want 1 migration row, got %d", n)
|
||||
}
|
||||
nested, openErr := OpenWriter(filepath.Join(dir, "nested"), "NORMAL")
|
||||
if openErr != nil {
|
||||
t.Fatal(openErr)
|
||||
}
|
||||
_ = nested.Close()
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationFS embed.FS
|
||||
|
||||
// Migrate 应用尚未执行的迁移。T0.1 仅为空执行器加 0001 占位;完整备份与表结构见 T0.3。
|
||||
func Migrate(db *sql.DB) error {
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||
version INTEGER PRIMARY KEY,
|
||||
applied_at INTEGER NOT NULL
|
||||
)`); err != nil {
|
||||
return fmt.Errorf("ensure schema_migrations: %w", err)
|
||||
}
|
||||
|
||||
entries, err := fs.ReadDir(migrationFS, "migrations")
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migrations: %w", err)
|
||||
}
|
||||
var names []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
|
||||
continue
|
||||
}
|
||||
names = append(names, e.Name())
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
version, err := parseMigrationVersion(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var exists int
|
||||
err = db.QueryRow(`SELECT 1 FROM schema_migrations WHERE version = ?`, version).Scan(&exists)
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if err != sql.ErrNoRows {
|
||||
return fmt.Errorf("check migration %d: %w", version, err)
|
||||
}
|
||||
|
||||
body, err := migrationFS.ReadFile("migrations/" + name)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read migration %s: %w", name, err)
|
||||
}
|
||||
sqlText := strings.TrimSpace(string(body))
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin migration %d: %w", version, err)
|
||||
}
|
||||
if sqlText != "" {
|
||||
if _, err := tx.Exec(sqlText); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("apply migration %d: %w", version, err)
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(
|
||||
`INSERT INTO schema_migrations(version, applied_at) VALUES(?, ?)`,
|
||||
version,
|
||||
time.Now().UnixMilli(),
|
||||
); err != nil {
|
||||
_ = tx.Rollback()
|
||||
return fmt.Errorf("record migration %d: %w", version, err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit migration %d: %w", version, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseMigrationVersion(name string) (int, error) {
|
||||
base := strings.TrimSuffix(name, ".sql")
|
||||
parts := strings.SplitN(base, "_", 2)
|
||||
if len(parts) < 1 {
|
||||
return 0, fmt.Errorf("invalid migration name: %s", name)
|
||||
}
|
||||
v, err := strconv.Atoi(parts[0])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid migration version in %s: %w", name, err)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
SELECT 1;
|
||||
Reference in New Issue
Block a user