Skip to content

Commit 1ac9967

Browse files
Adds experimental RunMiddleware and StartupHook (#482)
Signed-off-by: Adrian Cole <[email protected]>
1 parent f42ff95 commit 1ac9967

File tree

8 files changed

+300
-13
lines changed

8 files changed

+300
-13
lines changed

experimental/middleware/doc.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Copyright 2025 Tetrate
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
// Package middleware provides experimental APIs for intercepting func-e's Run lifecycle.
5+
//
6+
// # Experimental
7+
//
8+
// This package is experimental and may change or be removed in future versions.
9+
//
10+
// # Usage Constraints
11+
//
12+
// This package should ONLY be used by CLI entry points (main.go level).
13+
//
14+
// DO NOT use this package in library code because:
15+
//
16+
// 1. Experimental packages in dependencies cause rev lock
17+
// 2. CLI entry points would overwrite library hooks.
18+
//
19+
// # Example Usage
20+
//
21+
// This is intended for CLI entry points that need to intercept enforce runtime
22+
// aspects such as the home directory or Envoy version without requiring
23+
// library dependencies to expose api.RunOption directly.
24+
//
25+
// func main() {
26+
// ctx := context.Background()
27+
//
28+
// // Define a middleware that re-uses the CLI home dir for Envoy runs.
29+
// homeDirMiddleware := func(next api.RunFunc) api.RunFunc {
30+
// return func(ctx context.Context, args []string, options ...api.RunOption) error {
31+
// options = append(options, api.HomeDir(cliHome))
32+
// return next(ctx, args, options...)
33+
// }
34+
// }
35+
//
36+
// // Set the middleware in context, so that it will be used downstream.
37+
// ctx = middleware.WithRunMiddleware(ctx, homeDirMiddleware)
38+
//
39+
// // Use that context when calling a library function that uses func-e.
40+
// if err := library.Main(ctx, os.Args[1:]); err != nil {
41+
// log.Fatal(err)
42+
// }
43+
// }
44+
package middleware
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
// Copyright 2025 Tetrate
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package middleware
5+
6+
import (
7+
"context"
8+
9+
"github.com/tetratelabs/func-e/api"
10+
internalhook "github.com/tetratelabs/func-e/internal/middleware"
11+
"github.com/tetratelabs/func-e/internal/opts"
12+
)
13+
14+
// StartupHook runs just after Envoy logs "starting main dispatch loop".
15+
//
16+
// This provides access to two non-deterministic runtime values:
17+
// 1. The run directory (where stdout, stderr, and pid file are written)
18+
// 2. The admin address (which may be ephemeral)
19+
//
20+
// Startup hooks are considered mandatory and will stop the run with error if
21+
// they fail. If your hook is optional, handle errors internally.
22+
//
23+
// Startup hooks run on the goroutine that consumes Envoy's STDERR. Keep them
24+
// short or run long operations in a separate goroutine.
25+
//
26+
// To use a StartupHook, pass it via hook.WithStartupHook as a RunOption.
27+
type StartupHook = internalhook.StartupHook
28+
29+
// RunMiddleware wraps an api.RunFunc to intercept and modify its behavior.
30+
//
31+
// The middleware can:
32+
// - Modify context, args, or options before calling next
33+
// - Add StartupHooks via hook.WithStartupHook
34+
// - Handle errors from next
35+
// - Perform pre/post processing
36+
//
37+
// See package documentation for usage constraints.
38+
type RunMiddleware func(next api.RunFunc) api.RunFunc
39+
40+
// WithRunMiddleware returns a context that will cause run.Run to use the
41+
// provided middleware to wrap the default RunFunc.
42+
//
43+
// Only the most recently set middleware will be used. If multiple callers
44+
// set middleware, only the last one wins.
45+
//
46+
// This should only be called from CLI entrypoints. See package docs for details.
47+
func WithRunMiddleware(ctx context.Context, middleware RunMiddleware) context.Context {
48+
if middleware == nil {
49+
return ctx
50+
}
51+
// Store as unnamed function type to enable type assertion in internal/run
52+
var mw func(api.RunFunc) api.RunFunc = middleware
53+
return context.WithValue(ctx, internalhook.Key{}, mw)
54+
}
55+
56+
// WithStartupHook returns a RunOption that sets a startup hook.
57+
//
58+
// This is an experimental API that should only be used by CLI entrypoints.
59+
// See package documentation for usage constraints.
60+
//
61+
// If provided, this hook will REPLACE the default config dump hook.
62+
// If you want to preserve default behavior, do not use this option.
63+
func WithStartupHook(hook StartupHook) api.RunOption {
64+
return func(o *opts.RunOpts) {
65+
o.StartupHook = hook
66+
}
67+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright 2025 Tetrate
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package middleware_test
5+
6+
import (
7+
"context"
8+
"io"
9+
"path/filepath"
10+
"strings"
11+
"testing"
12+
13+
"github.com/stretchr/testify/require"
14+
15+
func_e "github.com/tetratelabs/func-e"
16+
"github.com/tetratelabs/func-e/api"
17+
"github.com/tetratelabs/func-e/experimental/middleware"
18+
internalmiddleware "github.com/tetratelabs/func-e/internal/middleware"
19+
"github.com/tetratelabs/func-e/internal/version"
20+
)
21+
22+
type arbitrary struct{}
23+
24+
// testCtx is an arbitrary, non-default context. Non-nil also prevents linter errors.
25+
var testCtx = context.WithValue(context.Background(), arbitrary{}, "arbitrary")
26+
27+
func TestWithRunMiddleware(t *testing.T) {
28+
tests := []struct {
29+
name string
30+
input middleware.RunMiddleware
31+
expected bool
32+
}{
33+
{
34+
name: "returns input when middleware nil",
35+
input: nil,
36+
expected: false,
37+
},
38+
{
39+
name: "decorates with middleware",
40+
input: func(next api.RunFunc) api.RunFunc {
41+
return next
42+
},
43+
expected: true,
44+
},
45+
}
46+
47+
for _, tt := range tests {
48+
t.Run(tt.name, func(t *testing.T) {
49+
actual := middleware.WithRunMiddleware(testCtx, tt.input)
50+
if tt.expected {
51+
val := actual.Value(internalmiddleware.Key{})
52+
mw, ok := val.(func(api.RunFunc) api.RunFunc)
53+
require.NotNil(t, mw)
54+
require.True(t, ok)
55+
} else {
56+
require.Equal(t, testCtx, actual)
57+
}
58+
})
59+
}
60+
}
61+
62+
func TestWithStartupHook(t *testing.T) {
63+
// Test that middleware.WithStartupHook returns a valid RunOption
64+
customHook := func(ctx context.Context, runDir, adminAddress string) error {
65+
return nil
66+
}
67+
68+
actual := middleware.WithStartupHook(customHook)
69+
require.NotNil(t, actual)
70+
}
71+
72+
func TestMiddleware_E2E(t *testing.T) {
73+
ctx, cancel := context.WithCancel(t.Context())
74+
defer cancel()
75+
76+
// Setup: known temp dir
77+
expectedHomeDir := t.TempDir()
78+
var actualRunDir string
79+
var actualAdminAddress string
80+
81+
// Define middleware that:
82+
// 1. Overrides Out/EnvoyOut/EnvoyErr to io.Discard
83+
// 2. Sets HomeDir to known temp dir
84+
// 3. Injects StartupHook to capture runDir
85+
testMiddleware := func(next api.RunFunc) api.RunFunc {
86+
return func(ctx context.Context, args []string, options ...api.RunOption) error {
87+
// Override options
88+
options = append(options,
89+
api.EnvoyVersion(version.LastKnownEnvoy.String()),
90+
api.Out(io.Discard),
91+
api.EnvoyOut(io.Discard),
92+
api.EnvoyErr(io.Discard),
93+
api.HomeDir(expectedHomeDir),
94+
)
95+
96+
// Inject startup hook that captures runDir and adminAddress
97+
startupHook := func(ctx context.Context, runDir, adminAddress string) error {
98+
actualRunDir = runDir
99+
actualAdminAddress = adminAddress
100+
// Cancel immediately to stop Envoy and complete test quickly
101+
cancel()
102+
return nil
103+
}
104+
options = append(options, middleware.WithStartupHook(startupHook))
105+
106+
return next(ctx, args, options...)
107+
}
108+
}
109+
110+
// Inject middleware via context
111+
ctx = middleware.WithRunMiddleware(ctx, testMiddleware)
112+
113+
// Run with minimal Envoy config
114+
err := func_e.Run(ctx, []string{
115+
"--config-yaml",
116+
"admin: {address: {socket_address: {address: '127.0.0.1', port_value: 0}}}",
117+
})
118+
119+
// Expect nil error since Run returns nil on context cancellation (documented behavior)
120+
require.NoError(t, err)
121+
require.Equal(t, filepath.Join(expectedHomeDir, "runs"), filepath.Dir(actualRunDir))
122+
123+
// Should get a real admin address, not the ephemeral input
124+
require.True(t, strings.HasPrefix(actualAdminAddress, "127.0.0.1:"))
125+
require.NotEqual(t, "127.0.0.1:0", actualAdminAddress)
126+
}

internal/envoy/runtime.go

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717

1818
"github.com/tetratelabs/func-e/internal/envoy/config"
1919
"github.com/tetratelabs/func-e/internal/globals"
20+
internalmiddleware "github.com/tetratelabs/func-e/internal/middleware"
2021
)
2122

2223
type LogFunc func(format string, a ...any)
@@ -37,7 +38,7 @@ type LogFunc func(format string, a ...any)
3738
// doesn't write a lot to stderr, so short tasks won't fill up the pipe and
3839
// cause Envoy to block. However, if your hook is long-running, it must be
3940
// run in a goroutine.
40-
type StartupHook func(ctx context.Context, runDir, adminAddress string) error
41+
type StartupHook = internalmiddleware.StartupHook
4142

4243
const (
4344
configYamlFlag = `--config-yaml`
@@ -48,14 +49,21 @@ const (
4849
// NewRuntime creates a new Runtime that runs envoy in globals.RunOpts RunDir
4950
// opts allows a user running envoy to control the working directory by ID or path, allowing explicit cleanup.
5051
func NewRuntime(opts *globals.RunOpts, logf LogFunc) *Runtime {
51-
safeHook := &safeStartupHook{
52-
delegate: func(ctx context.Context, runDir, adminAddress string) error {
53-
return collectConfigDump(ctx, http.DefaultClient, runDir, adminAddress)
54-
},
55-
logf: logf,
56-
timeout: 3 * time.Second,
52+
// Use user-provided hook if set, otherwise use default
53+
var hook StartupHook
54+
if opts.StartupHook != nil {
55+
hook = opts.StartupHook
56+
} else {
57+
safeHook := &safeStartupHook{
58+
delegate: func(ctx context.Context, runDir, adminAddress string) error {
59+
return collectConfigDump(ctx, http.DefaultClient, runDir, adminAddress)
60+
},
61+
logf: logf,
62+
timeout: 3 * time.Second,
63+
}
64+
hook = safeHook.Hook
5765
}
58-
return &Runtime{o: opts, logf: logf, startupHook: safeHook.Hook}
66+
return &Runtime{o: opts, logf: logf, startupHook: hook}
5967
}
6068

6169
// Runtime manages an Envoy lifecycle

internal/globals/globals.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"runtime"
1010
"strings"
1111

12+
internalmiddleware "github.com/tetratelabs/func-e/internal/middleware"
1213
"github.com/tetratelabs/func-e/internal/version"
1314
)
1415

@@ -24,6 +25,8 @@ type RunOpts struct {
2425
// This is not Envoy's working directory, which remains the same as the $PWD of func-e.
2526
// Defaults to "$HomeDir/runs/$epochtime"
2627
RunDir string
28+
// StartupHook is an experimental hook that runs after Envoy starts.
29+
StartupHook internalmiddleware.StartupHook
2730
}
2831

2932
// GlobalOpts represents options that affect more than one func-e commands.

internal/middleware/hook.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// Copyright 2025 Tetrate
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package middleware
5+
6+
import (
7+
"context"
8+
)
9+
10+
// Key is a context.Context Value key. Its associated value should be a RunMiddleware.
11+
type Key struct{}
12+
13+
// StartupHook runs just after Envoy logs "starting main dispatch loop".
14+
//
15+
// This provides access to two non-deterministic runtime values:
16+
// 1. The run directory (where stdout, stderr and pid file are written)
17+
// 2. The admin address (which may be ephemeral)
18+
type StartupHook func(ctx context.Context, runDir, adminAddress string) error

internal/opts/opts.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,11 @@
55
// This is internal and not intended for direct use by external packages.
66
package opts
77

8-
import "io"
8+
import (
9+
"io"
10+
11+
internalmiddleware "github.com/tetratelabs/func-e/internal/middleware"
12+
)
913

1014
// RunOpts holds the configuration set by RunOptions.
1115
type RunOpts struct {
@@ -15,5 +19,6 @@ type RunOpts struct {
1519
Out io.Writer
1620
EnvoyOut io.Writer
1721
EnvoyErr io.Writer
18-
EnvoyPath string // Internal: path to the Envoy binary (for tests).
22+
EnvoyPath string // Internal: path to the Envoy binary (for tests).
23+
StartupHook internalmiddleware.StartupHook // Experimental: custom startup hook
1924
}

internal/run/run.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"github.com/tetratelabs/func-e/api"
1111
internalapi "github.com/tetratelabs/func-e/internal/api"
1212
"github.com/tetratelabs/func-e/internal/globals"
13+
internalmiddleware "github.com/tetratelabs/func-e/internal/middleware"
1314
"github.com/tetratelabs/func-e/internal/opts"
1415
"github.com/tetratelabs/func-e/internal/version"
1516
)
@@ -23,6 +24,20 @@ func EnvoyPath(envoyPath string) api.RunOption {
2324

2425
// Run implements api.RunFunc
2526
func Run(ctx context.Context, args []string, options ...api.RunOption) error {
27+
// Check if middleware is set in context
28+
baseRun := api.RunFunc(runImpl)
29+
if middlewareVal := ctx.Value(internalmiddleware.Key{}); middlewareVal != nil {
30+
// Type assert to function that matches our middleware signature
31+
if middleware, ok := middlewareVal.(func(api.RunFunc) api.RunFunc); ok {
32+
baseRun = middleware(baseRun)
33+
}
34+
}
35+
36+
return baseRun(ctx, args, options...)
37+
}
38+
39+
// runImpl is the default implementation of api.RunFunc
40+
func runImpl(ctx context.Context, args []string, options ...api.RunOption) error {
2641
o, err := initOpts(ctx, options...)
2742
if err != nil {
2843
return err
@@ -44,9 +59,10 @@ func initOpts(ctx context.Context, options ...api.RunOption) (*globals.GlobalOpt
4459
EnvoyVersion: version.PatchVersion(ro.EnvoyVersion),
4560
Out: ro.Out,
4661
RunOpts: globals.RunOpts{
47-
EnvoyPath: ro.EnvoyPath,
48-
EnvoyOut: ro.EnvoyOut,
49-
EnvoyErr: ro.EnvoyErr,
62+
EnvoyPath: ro.EnvoyPath,
63+
EnvoyOut: ro.EnvoyOut,
64+
EnvoyErr: ro.EnvoyErr,
65+
StartupHook: ro.StartupHook,
5066
},
5167
}
5268
if err := internalapi.InitializeGlobalOpts(o, ro.EnvoyVersionsURL, ro.HomeDir, ""); err != nil {

0 commit comments

Comments
 (0)