From 9d52b22b8d2faa77bd7d588962aca6d0736a1f2a Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Tue, 12 May 2026 22:44:30 +0800 Subject: [PATCH] feat: bootstrap control plane app skeleton --- cmd/cli/main.go | 28 +++++ cmd/cli/main_test.go | 79 +++++++++++++ cmd/server/main.go | 31 +++++ cmd/server/main_test.go | 77 +++++++++++++ go.mod | 3 + internal/app/app.go | 77 +++++++++++++ internal/app/app_test.go | 128 +++++++++++++++++++++ internal/app/bootstrap.go | 16 +++ internal/config/config.go | 82 +++++++++++++ tests/integration/config_bootstrap_test.go | 85 ++++++++++++++ 10 files changed, 606 insertions(+) create mode 100644 cmd/cli/main.go create mode 100644 cmd/cli/main_test.go create mode 100644 cmd/server/main.go create mode 100644 cmd/server/main_test.go create mode 100644 go.mod create mode 100644 internal/app/app.go create mode 100644 internal/app/app_test.go create mode 100644 internal/app/bootstrap.go create mode 100644 internal/config/config.go create mode 100644 tests/integration/config_bootstrap_test.go diff --git a/cmd/cli/main.go b/cmd/cli/main.go new file mode 100644 index 00000000..bb142548 --- /dev/null +++ b/cmd/cli/main.go @@ -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 +} diff --git a/cmd/cli/main_test.go b/cmd/cli/main_test.go new file mode 100644 index 00000000..d4977809 --- /dev/null +++ b/cmd/cli/main_test.go @@ -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) + } +} diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 00000000..144a4432 --- /dev/null +++ b/cmd/server/main.go @@ -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) +} diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go new file mode 100644 index 00000000..d35b78a0 --- /dev/null +++ b/cmd/server/main_test.go @@ -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) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..c9743f90 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module sub2api-cn-relay-manager + +go 1.22.2 diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 00000000..fdfacc62 --- /dev/null +++ b/internal/app/app.go @@ -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 + } +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 00000000..3e1b093a --- /dev/null +++ b/internal/app/app_test.go @@ -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 +} diff --git a/internal/app/bootstrap.go b/internal/app/bootstrap.go new file mode 100644 index 00000000..ec8b15b2 --- /dev/null +++ b/internal/app/bootstrap.go @@ -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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 00000000..411fd76b --- /dev/null +++ b/internal/config/config.go @@ -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 +} diff --git a/tests/integration/config_bootstrap_test.go b/tests/integration/config_bootstrap_test.go new file mode 100644 index 00000000..6787e250 --- /dev/null +++ b/tests/integration/config_bootstrap_test.go @@ -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") + } +}