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

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
}