feat: bootstrap control plane app skeleton
This commit is contained in:
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
|
||||
}
|
||||
Reference in New Issue
Block a user