feat: bootstrap control plane app skeleton
This commit is contained in:
28
cmd/cli/main.go
Normal file
28
cmd/cli/main.go
Normal 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
79
cmd/cli/main_test.go
Normal 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
31
cmd/server/main.go
Normal 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
77
cmd/server/main_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
77
internal/app/app.go
Normal file
77
internal/app/app.go
Normal 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
128
internal/app/app_test.go
Normal 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
16
internal/app/bootstrap.go
Normal 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
82
internal/config/config.go
Normal 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
|
||||
}
|
||||
85
tests/integration/config_bootstrap_test.go
Normal file
85
tests/integration/config_bootstrap_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user