feat: bootstrap control plane app skeleton

This commit is contained in:
phamnazage-jpg
2026-05-12 22:44:30 +08:00
parent 1c02fcdaa7
commit 9d52b22b8d
10 changed files with 606 additions and 0 deletions

28
cmd/cli/main.go Normal file
View File

@@ -0,0 +1,28 @@
package main
import (
"context"
"fmt"
"io"
"log"
"sub2api-cn-relay-manager/internal/config"
)
func main() {
if err := execute(context.Background(), log.Writer(), func(context.Context) (config.StartupConfig, error) {
return config.LoadStartupFromEnv()
}); err != nil {
log.Fatalf("run cli: %v", err)
}
}
func execute(ctx context.Context, output io.Writer, loadConfig func(context.Context) (config.StartupConfig, error)) error {
cfg, err := loadConfig(ctx)
if err != nil {
return err
}
_, err = fmt.Fprintf(output, "sub2api-cn-relay-manager cli ready\nlisten_addr=%s\nsqlite_dsn=%s\n", cfg.Server.ListenAddr, cfg.Database.SQLiteDSN)
return err
}

79
cmd/cli/main_test.go Normal file
View File

@@ -0,0 +1,79 @@
package main
import (
"bytes"
"context"
"errors"
"strings"
"testing"
"sub2api-cn-relay-manager/internal/config"
)
type errWriter struct {
err error
}
func (w errWriter) Write([]byte) (int, error) {
return 0, w.err
}
func TestExecuteWritesConfigSummaryAfterBootstrap(t *testing.T) {
var output bytes.Buffer
loadCalled := false
err := execute(context.Background(), &output, func(context.Context) (config.StartupConfig, error) {
loadCalled = true
return config.StartupConfig{
Server: config.ServerConfig{ListenAddr: ":9191"},
Database: config.DatabaseConfig{
SQLiteDSN: "file:test.db?_foreign_keys=on",
},
}, nil
})
if err != nil {
t.Fatalf("execute() returned error: %v", err)
}
if !loadCalled {
t.Fatal("execute() did not load config")
}
got := output.String()
if !strings.Contains(got, "sub2api-cn-relay-manager cli ready") {
t.Fatalf("execute() output = %q, want readiness line", got)
}
if !strings.Contains(got, "listen_addr=:9191") {
t.Fatalf("execute() output = %q, want listen addr summary", got)
}
if !strings.Contains(got, "sqlite_dsn=file:test.db?_foreign_keys=on") {
t.Fatalf("execute() output = %q, want sqlite dsn summary", got)
}
}
func TestExecuteReturnsBootstrapError(t *testing.T) {
wantErr := errors.New("load config failed")
err := execute(context.Background(), &bytes.Buffer{}, func(context.Context) (config.StartupConfig, error) {
return config.StartupConfig{}, wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("execute() error = %v, want %v", err, wantErr)
}
}
func TestExecuteReturnsWriteError(t *testing.T) {
wantErr := errors.New("write failed")
err := execute(context.Background(), errWriter{err: wantErr}, func(context.Context) (config.StartupConfig, error) {
return config.StartupConfig{
Server: config.ServerConfig{ListenAddr: ":9292"},
Database: config.DatabaseConfig{SQLiteDSN: "file:test.db"},
}, nil
})
if !errors.Is(err, wantErr) {
t.Fatalf("execute() error = %v, want %v", err, wantErr)
}
}

31
cmd/server/main.go Normal file
View File

@@ -0,0 +1,31 @@
package main
import (
"context"
"log"
"os"
"os/signal"
"syscall"
"sub2api-cn-relay-manager/internal/app"
)
func main() {
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
if err := run(ctx, app.Bootstrap, func(ctx context.Context, server *app.Server) error {
return server.Run(ctx)
}); err != nil {
log.Fatalf("run server: %v", err)
}
}
func run(ctx context.Context, bootstrap func(context.Context) (*app.Server, error), runServer func(context.Context, *app.Server) error) error {
server, err := bootstrap(ctx)
if err != nil {
return err
}
return runServer(ctx, server)
}

77
cmd/server/main_test.go Normal file
View File

@@ -0,0 +1,77 @@
package main
import (
"context"
"errors"
"testing"
"sub2api-cn-relay-manager/internal/app"
)
func TestRunCallsApplicationServerRunnerAfterBootstrap(t *testing.T) {
serverApp := app.NewServer("127.0.0.1:0", nil)
bootstrapCalled := false
runnerCalled := false
err := run(
context.Background(),
func(context.Context) (*app.Server, error) {
bootstrapCalled = true
return serverApp, nil
},
func(_ context.Context, got *app.Server) error {
runnerCalled = true
if got != serverApp {
t.Fatal("run() passed unexpected server instance to runner")
}
return nil
},
)
if err != nil {
t.Fatalf("run() returned error: %v", err)
}
if !bootstrapCalled {
t.Fatal("run() did not call bootstrap")
}
if !runnerCalled {
t.Fatal("run() did not call server runner")
}
}
func TestRunReturnsBootstrapError(t *testing.T) {
wantErr := errors.New("bootstrap failed")
err := run(
context.Background(),
func(context.Context) (*app.Server, error) {
return nil, wantErr
},
func(context.Context, *app.Server) error {
t.Fatal("run() called runner after bootstrap error")
return nil
},
)
if !errors.Is(err, wantErr) {
t.Fatalf("run() error = %v, want %v", err, wantErr)
}
}
func TestRunReturnsApplicationRunError(t *testing.T) {
wantErr := errors.New("server run failed")
serverApp := app.NewServer("127.0.0.1:0", nil)
err := run(
context.Background(),
func(context.Context) (*app.Server, error) {
return serverApp, nil
},
func(context.Context, *app.Server) error {
return wantErr
},
)
if !errors.Is(err, wantErr) {
t.Fatalf("run() error = %v, want %v", err, wantErr)
}
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module sub2api-cn-relay-manager
go 1.22.2

77
internal/app/app.go Normal file
View File

@@ -0,0 +1,77 @@
package app
import (
"context"
"errors"
"net"
"net/http"
"time"
)
type ListenerFactory func(network, address string) (net.Listener, error)
type Server struct {
server *http.Server
listen ListenerFactory
}
func NewServer(listenAddr string, listenerFactory ListenerFactory) *Server {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
server := &Server{
server: &http.Server{
Addr: listenAddr,
Handler: mux,
},
listen: net.Listen,
}
if listenerFactory != nil {
server.listen = listenerFactory
}
return server
}
func (s *Server) Addr() string {
return s.server.Addr
}
func (s *Server) Run(ctx context.Context) error {
listener, err := s.listen("tcp", s.server.Addr)
if err != nil {
return err
}
return s.Serve(ctx, listener)
}
func (s *Server) Serve(ctx context.Context, listener net.Listener) error {
errCh := make(chan error, 1)
go func() {
err := s.server.Serve(listener)
if errors.Is(err, http.ErrServerClosed) {
err = nil
}
errCh <- err
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.server.Shutdown(shutdownCtx); err != nil {
return err
}
return <-errCh
case err := <-errCh:
return err
}
}

128
internal/app/app_test.go Normal file
View File

@@ -0,0 +1,128 @@
package app
import (
"context"
"errors"
"io"
"net"
"net/http"
"testing"
"time"
)
func TestServeExposesHealthz(t *testing.T) {
server := NewServer("127.0.0.1:0", nil)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- server.Serve(ctx, listener)
}()
response := waitForHealthz(t, "http://"+listener.Addr().String()+"/healthz")
defer response.Body.Close()
body, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
if string(body) != "ok" {
t.Fatalf("healthz body = %q, want %q", string(body), "ok")
}
cancel()
if err := <-errCh; err != nil {
t.Fatalf("Serve() error = %v, want nil", err)
}
}
func TestRunReturnsAfterContextCancellation(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
server := NewServer("127.0.0.1:0", func(string, string) (net.Listener, error) {
return listener, nil
})
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- server.Run(ctx)
}()
response := waitForHealthz(t, "http://"+listener.Addr().String()+"/healthz")
response.Body.Close()
cancel()
select {
case err := <-errCh:
if err != nil {
t.Fatalf("Run() error = %v, want nil", err)
}
case <-time.After(2 * time.Second):
t.Fatal("Run() did not return after context cancellation")
}
}
func TestRunReturnsListenError(t *testing.T) {
wantErr := errors.New("listen failed")
server := NewServer("127.0.0.1:0", func(string, string) (net.Listener, error) {
return nil, wantErr
})
err := server.Run(context.Background())
if !errors.Is(err, wantErr) {
t.Fatalf("Run() error = %v, want %v", err, wantErr)
}
}
func TestServeReturnsListenerError(t *testing.T) {
server := NewServer("127.0.0.1:0", nil)
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("net.Listen() error = %v", err)
}
if err := listener.Close(); err != nil {
t.Fatalf("listener.Close() error = %v", err)
}
err = server.Serve(context.Background(), listener)
if err == nil {
t.Fatal("Serve() error = nil, want listener startup error")
}
}
func waitForHealthz(t *testing.T, url string) *http.Response {
t.Helper()
client := &http.Client{Timeout: 100 * time.Millisecond}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
response, err := client.Get(url)
if err == nil && response.StatusCode == http.StatusOK {
return response
}
if response != nil {
response.Body.Close()
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("health endpoint %q was not reachable before deadline", url)
return nil
}

16
internal/app/bootstrap.go Normal file
View File

@@ -0,0 +1,16 @@
package app
import (
"context"
"sub2api-cn-relay-manager/internal/config"
)
func Bootstrap(_ context.Context) (*Server, error) {
cfg, err := config.LoadStartupFromEnv()
if err != nil {
return nil, err
}
return NewServer(cfg.Server.ListenAddr, nil), nil
}

82
internal/config/config.go Normal file
View File

@@ -0,0 +1,82 @@
package config
import (
"fmt"
"os"
"strings"
)
const (
EnvListenAddr = "SUB2API_CRM_LISTEN_ADDR"
EnvSQLiteDSN = "SUB2API_CRM_SQLITE_DSN"
EnvAdminToken = "SUB2API_CRM_ADMIN_TOKEN"
DefaultListenAddr = ":8080"
DefaultSQLiteDSN = "file:sub2api-cn-relay-manager.db?_foreign_keys=on&_busy_timeout=5000"
)
type ServerConfig struct {
ListenAddr string
}
type DatabaseConfig struct {
SQLiteDSN string
}
type StartupConfig struct {
Server ServerConfig
Database DatabaseConfig
}
func LoadStartupFromEnv() (StartupConfig, error) {
return loadStartupFromLookupEnv(os.LookupEnv)
}
func loadStartupFromLookupEnv(lookup func(string) (string, bool)) (StartupConfig, error) {
cfg := StartupConfig{
Server: ServerConfig{
ListenAddr: readOptionalEnv(lookup, EnvListenAddr, DefaultListenAddr),
},
Database: DatabaseConfig{
SQLiteDSN: readOptionalEnv(lookup, EnvSQLiteDSN, DefaultSQLiteDSN),
},
}
return cfg, nil
}
func LoadAdminTokenFromEnv() (string, error) {
return loadAdminTokenFromLookupEnv(os.LookupEnv)
}
func loadAdminTokenFromLookupEnv(lookup func(string) (string, bool)) (string, error) {
token := strings.TrimSpace(readRequiredEnv(lookup, EnvAdminToken))
if token == "" {
return "", fmt.Errorf("%s is required", EnvAdminToken)
}
return token, nil
}
func readOptionalEnv(lookup func(string) (string, bool), key string, defaultValue string) string {
value, ok := lookup(key)
if !ok {
return defaultValue
}
value = strings.TrimSpace(value)
if value == "" {
return defaultValue
}
return value
}
func readRequiredEnv(lookup func(string) (string, bool), key string) string {
value, ok := lookup(key)
if !ok {
return ""
}
return value
}

View File

@@ -0,0 +1,85 @@
package integration_test
import (
"context"
"testing"
"sub2api-cn-relay-manager/internal/app"
"sub2api-cn-relay-manager/internal/config"
)
func TestLoadStartupFromEnvUsesDefaultsWhenOptionalValuesMissing(t *testing.T) {
t.Setenv("SUB2API_CRM_LISTEN_ADDR", "")
t.Setenv("SUB2API_CRM_SQLITE_DSN", "")
cfg, err := config.LoadStartupFromEnv()
if err != nil {
t.Fatalf("LoadStartupFromEnv() returned error: %v", err)
}
if cfg.Server.ListenAddr != config.DefaultListenAddr {
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, config.DefaultListenAddr)
}
if cfg.Database.SQLiteDSN != config.DefaultSQLiteDSN {
t.Fatalf("SQLiteDSN = %q, want %q", cfg.Database.SQLiteDSN, config.DefaultSQLiteDSN)
}
}
func TestLoadStartupFromEnvAppliesOverrides(t *testing.T) {
t.Setenv("SUB2API_CRM_LISTEN_ADDR", "127.0.0.1:9090")
t.Setenv("SUB2API_CRM_SQLITE_DSN", "file:custom.db?_foreign_keys=on")
cfg, err := config.LoadStartupFromEnv()
if err != nil {
t.Fatalf("LoadStartupFromEnv() returned error: %v", err)
}
if cfg.Server.ListenAddr != "127.0.0.1:9090" {
t.Fatalf("ListenAddr = %q, want %q", cfg.Server.ListenAddr, "127.0.0.1:9090")
}
if cfg.Database.SQLiteDSN != "file:custom.db?_foreign_keys=on" {
t.Fatalf("SQLiteDSN = %q, want %q", cfg.Database.SQLiteDSN, "file:custom.db?_foreign_keys=on")
}
}
func TestLoadAdminTokenFromEnvReturnsToken(t *testing.T) {
t.Setenv("SUB2API_CRM_ADMIN_TOKEN", "admin-token")
token, err := config.LoadAdminTokenFromEnv()
if err != nil {
t.Fatalf("LoadAdminTokenFromEnv() returned error: %v", err)
}
if token != "admin-token" {
t.Fatalf("token = %q, want %q", token, "admin-token")
}
}
func TestLoadAdminTokenFromEnvReturnsErrorWhenMissing(t *testing.T) {
t.Setenv("SUB2API_CRM_ADMIN_TOKEN", "")
_, err := config.LoadAdminTokenFromEnv()
if err == nil {
t.Fatal("LoadAdminTokenFromEnv() error = nil, want validation error")
}
}
func TestBootstrapBuildsServerWithStartupConfigOnly(t *testing.T) {
t.Setenv("SUB2API_CRM_LISTEN_ADDR", ":8181")
t.Setenv("SUB2API_CRM_ADMIN_TOKEN", "")
server, err := app.Bootstrap(context.Background())
if err != nil {
t.Fatalf("Bootstrap() returned error: %v", err)
}
if server == nil {
t.Fatal("Bootstrap() returned nil server")
}
if server.Addr() != ":8181" {
t.Fatalf("Bootstrap Addr = %q, want %q", server.Addr(), ":8181")
}
}