Skip to content

Commit 474f0d7

Browse files
committed
feat(app): add service bootstrap package
New app package providing a service lifecycle manager that wires together config, logger, database, telemetry, and server: app.Run( app.WithConfig(&cfg, config.WithFile("config.yaml")), app.WithLogger(logger.NewSlog()), app.WithDB(cfg.DB), app.WithTelemetry(cfg.Telemetry), app.WithHandler(myHandler), app.WithHealthCheck("/ping"), app.WithH2C(), app.WithAddr(cfg.Addr), ) Lifecycle: config → logger → db → telemetry → onStart hooks → server Shutdown: server → onStop hooks → telemetry flush → db close Handles SIGINT/SIGTERM for graceful shutdown.
1 parent b7cda47 commit 474f0d7

3 files changed

Lines changed: 402 additions & 0 deletions

File tree

app/app.go

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
package app
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"os/signal"
7+
"syscall"
8+
9+
"github.com/raystack/salt/db"
10+
"github.com/raystack/salt/logger"
11+
"github.com/raystack/salt/server"
12+
"github.com/raystack/salt/telemetry"
13+
)
14+
15+
// App is a service lifecycle manager that wires together configuration,
16+
// logging, database, telemetry, and HTTP serving with graceful shutdown.
17+
type App struct {
18+
logger logger.Logger
19+
db *db.Client
20+
dbCfg *db.Config
21+
telCfg *telemetry.Config
22+
telClean func()
23+
serverOps []server.Option
24+
onStart []func(context.Context) error
25+
onStop []func(context.Context) error
26+
}
27+
28+
// New creates a new App by applying the given options.
29+
func New(opts ...Option) (*App, error) {
30+
a := &App{
31+
logger: &logger.Noop{},
32+
}
33+
for _, opt := range opts {
34+
if err := opt(a); err != nil {
35+
return nil, fmt.Errorf("app option: %w", err)
36+
}
37+
}
38+
return a, nil
39+
}
40+
41+
// Run is the simplest entry point: creates an App, starts it with signal
42+
// handling (SIGINT, SIGTERM), and blocks until shutdown completes.
43+
func Run(opts ...Option) error {
44+
a, err := New(opts...)
45+
if err != nil {
46+
return err
47+
}
48+
49+
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
50+
defer stop()
51+
52+
return a.Start(ctx)
53+
}
54+
55+
// Start initializes all components and starts the server.
56+
// It blocks until the context is cancelled, then performs graceful shutdown.
57+
func (a *App) Start(ctx context.Context) error {
58+
// Initialize database if configured.
59+
if a.dbCfg != nil {
60+
client, err := db.New(*a.dbCfg)
61+
if err != nil {
62+
return fmt.Errorf("app db: %w", err)
63+
}
64+
a.db = client
65+
a.logger.Info("database connected", "host", client.Host())
66+
}
67+
68+
// Initialize telemetry if configured.
69+
if a.telCfg != nil {
70+
cleanup, err := telemetry.Init(ctx, *a.telCfg, a.logger)
71+
if err != nil {
72+
return fmt.Errorf("app telemetry: %w", err)
73+
}
74+
a.telClean = cleanup
75+
}
76+
77+
// Run onStart hooks.
78+
for _, fn := range a.onStart {
79+
if err := fn(ctx); err != nil {
80+
a.cleanup()
81+
return fmt.Errorf("app on_start: %w", err)
82+
}
83+
}
84+
85+
// Build and start the server.
86+
a.serverOps = append(a.serverOps, server.WithLogger(a.logger))
87+
srv := server.New(a.serverOps...)
88+
89+
err := srv.Start(ctx)
90+
91+
// Shutdown sequence.
92+
a.stop(context.Background())
93+
return err
94+
}
95+
96+
// Logger returns the app's logger.
97+
func (a *App) Logger() logger.Logger {
98+
return a.logger
99+
}
100+
101+
// DB returns the database client, or nil if no database was configured.
102+
func (a *App) DB() *db.Client {
103+
return a.db
104+
}
105+
106+
func (a *App) stop(ctx context.Context) {
107+
for _, fn := range a.onStop {
108+
if err := fn(ctx); err != nil {
109+
a.logger.Error("app on_stop hook error", "error", err)
110+
}
111+
}
112+
a.cleanup()
113+
}
114+
115+
func (a *App) cleanup() {
116+
if a.telClean != nil {
117+
a.telClean()
118+
}
119+
if a.db != nil {
120+
if err := a.db.Close(); err != nil {
121+
a.logger.Error("app db close error", "error", err)
122+
}
123+
}
124+
}

app/app_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package app_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"testing"
9+
"time"
10+
11+
"github.com/raystack/salt/app"
12+
"github.com/raystack/salt/logger"
13+
"github.com/raystack/salt/server"
14+
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
16+
)
17+
18+
func TestNew(t *testing.T) {
19+
t.Run("creates app with defaults", func(t *testing.T) {
20+
a, err := app.New()
21+
require.NoError(t, err)
22+
assert.NotNil(t, a)
23+
assert.NotNil(t, a.Logger())
24+
assert.Nil(t, a.DB())
25+
})
26+
27+
t.Run("sets logger", func(t *testing.T) {
28+
l := logger.NewNoop()
29+
a, err := app.New(app.WithLogger(l))
30+
require.NoError(t, err)
31+
assert.Equal(t, l, a.Logger())
32+
})
33+
34+
t.Run("returns error from option", func(t *testing.T) {
35+
badOpt := func(a *app.App) error {
36+
return fmt.Errorf("bad option")
37+
}
38+
_, err := app.New(badOpt)
39+
assert.Error(t, err)
40+
assert.Contains(t, err.Error(), "bad option")
41+
})
42+
}
43+
44+
func TestAppStartAndShutdown(t *testing.T) {
45+
t.Run("starts and stops cleanly", func(t *testing.T) {
46+
ctx, cancel := context.WithCancel(context.Background())
47+
48+
a, err := app.New(
49+
app.WithLogger(logger.NewNoop()),
50+
app.WithAddr("127.0.0.1:18950"),
51+
app.WithHealthCheck("/ping"),
52+
)
53+
require.NoError(t, err)
54+
55+
errCh := make(chan error, 1)
56+
go func() { errCh <- a.Start(ctx) }()
57+
58+
time.Sleep(100 * time.Millisecond)
59+
60+
// Verify health check works
61+
resp, err := http.Get("http://127.0.0.1:18950/ping")
62+
require.NoError(t, err)
63+
defer resp.Body.Close()
64+
assert.Equal(t, http.StatusOK, resp.StatusCode)
65+
66+
cancel()
67+
68+
select {
69+
case err := <-errCh:
70+
assert.NoError(t, err)
71+
case <-time.After(5 * time.Second):
72+
t.Fatal("shutdown timed out")
73+
}
74+
})
75+
76+
t.Run("runs onStart hooks", func(t *testing.T) {
77+
ctx, cancel := context.WithCancel(context.Background())
78+
79+
var hookRan bool
80+
a, err := app.New(
81+
app.WithAddr("127.0.0.1:18951"),
82+
app.WithOnStart(func(ctx context.Context) error {
83+
hookRan = true
84+
return nil
85+
}),
86+
)
87+
require.NoError(t, err)
88+
89+
go func() {
90+
time.Sleep(100 * time.Millisecond)
91+
cancel()
92+
}()
93+
94+
a.Start(ctx)
95+
assert.True(t, hookRan)
96+
})
97+
98+
t.Run("runs onStop hooks", func(t *testing.T) {
99+
ctx, cancel := context.WithCancel(context.Background())
100+
101+
var hookRan bool
102+
a, err := app.New(
103+
app.WithAddr("127.0.0.1:18952"),
104+
app.WithOnStop(func(ctx context.Context) error {
105+
hookRan = true
106+
return nil
107+
}),
108+
)
109+
require.NoError(t, err)
110+
111+
go func() {
112+
time.Sleep(100 * time.Millisecond)
113+
cancel()
114+
}()
115+
116+
a.Start(ctx)
117+
assert.True(t, hookRan)
118+
})
119+
120+
t.Run("serves custom handler", func(t *testing.T) {
121+
ctx, cancel := context.WithCancel(context.Background())
122+
defer cancel()
123+
124+
a, err := app.New(
125+
app.WithLogger(logger.NewNoop()),
126+
app.WithAddr("127.0.0.1:18953"),
127+
app.WithHandler("/hello", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
128+
fmt.Fprint(w, "world")
129+
})),
130+
app.WithHealthCheck("/ping"),
131+
app.WithH2C(),
132+
)
133+
require.NoError(t, err)
134+
135+
go a.Start(ctx)
136+
time.Sleep(100 * time.Millisecond)
137+
138+
resp, err := http.Get("http://127.0.0.1:18953/hello")
139+
require.NoError(t, err)
140+
defer resp.Body.Close()
141+
142+
body, _ := io.ReadAll(resp.Body)
143+
assert.Equal(t, "world", string(body))
144+
145+
cancel()
146+
})
147+
}
148+
149+
// Verify Option type is compatible (compile-time check).
150+
var _ server.Option = server.WithAddr("")

0 commit comments

Comments
 (0)