Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ build/format: go.mod $(all_sources)
@$(go) mod tidy
@$(go) run $(nwa) add --mute -t .licenseheader -T raw "**/*.go"
@$(go) run $(gofumpt) -l -w .
# gofumpt organizes imports, but does not handle local grouping.
@# gofumpt organizes imports, but does not handle local grouping.
@$(go) run $(gosimports) -local github.com/tetratelabs/ -w $(shell find . -name '*.go' -type f)
@mkdir -p $(@D) && touch $@

Expand Down
4 changes: 2 additions & 2 deletions USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ To run Envoy, execute `func-e run -c your_envoy_config.yaml`. This
downloads and installs the latest version of Envoy for you.

To list versions of Envoy you can use, execute `func-e versions -a`. To
choose one, invoke `func-e use 1.34.2`. This installs into
`$FUNC_E_HOME/versions/1.34.2`, if not already present. You may also use
choose one, invoke `func-e use 1.34.3`. This installs into
`$FUNC_E_HOME/versions/1.34.3`, if not already present. You may also use
minor version, such as `func-e use 1.34`.

You may want to override `$ENVOY_VERSIONS_URL` to supply custom builds or
Expand Down
4 changes: 2 additions & 2 deletions api/func-e.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,8 @@ type runOpts struct {
// Run downloads Envoy and runs it as a process with the arguments
// passed to it. Use RunOption for configuration options.
//
// This blocks until the context is done or the process exits. The error might be
// context.Canceled if the context is done or an error from the process.
// On success, this blocks and returns nil when either `ctx` is done, or the
// process exits with status zero.
func Run(ctx context.Context, args []string, options ...RunOption) error {
// TODO: we need a real API and it being an interface in this package, initialized in the root
// directory like wazero does. That this stitches the impl makes it not an API package and causes
Expand Down
11 changes: 8 additions & 3 deletions api/func-e_run_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025 Tetrate
// Copyright func-e contributors
// SPDX-License-Identifier: Apache-2.0

package api
Expand All @@ -14,8 +14,8 @@ func TestRun(t *testing.T) {
e2e.TestRun(context.Background(), t, fakeFuncEFactory{})
}

func TestRun_MinimalListener(t *testing.T) {
e2e.TestRun_MinimalListener(context.Background(), t, fakeFuncEFactory{})
func TestRun_RunDirectory(t *testing.T) {
e2e.TestRun_RunDirectory(context.Background(), t, fakeFuncEFactory{})
}

func TestRun_InvalidConfig(t *testing.T) {
Expand All @@ -25,3 +25,8 @@ func TestRun_InvalidConfig(t *testing.T) {
func TestRun_StaticFile(t *testing.T) {
e2e.TestRun_StaticFile(context.Background(), t, fakeFuncEFactory{})
}

func TestRun_CtrlCs(t *testing.T) {
// This doesn't call ctrl-c, rather cancels the context multiple times
e2e.TestRun_CtrlCs(context.Background(), t, fakeFuncEFactory{})
}
8 changes: 5 additions & 3 deletions api/func-e_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025 Tetrate
// Copyright func-e contributors
// SPDX-License-Identifier: Apache-2.0

package api
Expand Down Expand Up @@ -48,8 +48,10 @@ type fakeFuncE struct {

// Interrupt cancels the context created in Run as we don't want to actually interrupt the calling test!
func (f *fakeFuncE) Interrupt(context.Context) error {
f.cancelFunc()
f.cancelFunc = nil
if f.cancelFunc != nil {
f.cancelFunc()
// Don't set to nil in case interrupt is called multiple times (ctrl+c twice)
}
return nil
}

Expand Down
10 changes: 7 additions & 3 deletions e2e/func-e_run_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Copyright 2025 Tetrate
// Copyright func-e contributors
// SPDX-License-Identifier: Apache-2.0

package e2e
Expand All @@ -14,8 +14,8 @@ func TestRun(t *testing.T) {
e2e.TestRun(context.Background(), t, funcEFactory{})
}

func TestRun_MinimalListener(t *testing.T) {
e2e.TestRun_MinimalListener(context.Background(), t, funcEFactory{})
func TestRun_RunDirectory(t *testing.T) {
e2e.TestRun_RunDirectory(context.Background(), t, funcEFactory{})
}

func TestRun_InvalidConfig(t *testing.T) {
Expand All @@ -25,3 +25,7 @@ func TestRun_InvalidConfig(t *testing.T) {
func TestRun_StaticFile(t *testing.T) {
e2e.TestRun_StaticFile(context.Background(), t, funcEFactory{})
}

func TestRun_CtrlCs(t *testing.T) {
e2e.TestRun_CtrlCs(context.Background(), t, funcEFactory{})
}
1 change: 0 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ require (
github.com/stretchr/testify v1.10.0
github.com/ulikunitz/xz v0.5.12
github.com/urfave/cli/v2 v2.27.7
golang.org/x/sync v0.16.0
gopkg.in/yaml.v3 v3.0.1
)

Expand Down
2 changes: 0 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,6 @@ github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342 h1:FnBeRrxr7OU4VvAz
github.com/xrash/smetrics v0.0.0-20250705151800-55b8f293f342/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
10 changes: 3 additions & 7 deletions internal/api/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"time"

"github.com/tetratelabs/func-e/internal/envoy"
"github.com/tetratelabs/func-e/internal/envoy/shutdown"
"github.com/tetratelabs/func-e/internal/globals"
"github.com/tetratelabs/func-e/internal/version"
)
Expand Down Expand Up @@ -53,7 +52,9 @@ func EnsurePatchVersion(ctx context.Context, o *globals.GlobalOpts, v version.Ve
return vv, nil
}

// Run runs Envoy with the given arguments
// Run runs Envoy with the given arguments.
// Returns nil when Envoy exits cleanly, including when interrupted by signals (SIGINT/SIGTERM).
// This matches Envoy's behavior of returning exit code 0 on graceful shutdown.
func Run(ctx context.Context, o *globals.GlobalOpts, args []string) error {
if err := initializeRunOpts(ctx, o); err != nil {
return err
Expand All @@ -77,11 +78,6 @@ func Run(ctx context.Context, o *globals.GlobalOpts, args []string) error {
r.ErrFile = stderrLog
r.Err = io.MultiWriter(o.EnvoyErr, stderrLog)

for _, hook := range shutdown.DefaultShutdownHooks {
if err := hook(r); err != nil {
fmt.Fprintf(r.Out, "failed to enable shutdown hook: %s\n", err) //nolint:errcheck
}
}
return r.Run(ctx, args)
}

Expand Down
4 changes: 1 addition & 3 deletions internal/cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,7 @@ Envoy interprets the '[arguments...]' and runs in the current working
directory (aka $PWD) until func-e is interrupted (ex Ctrl+C, Ctrl+Break).

Envoy's process ID and console output write to "envoy.pid", stdout.log" and
"stderr.log" in the run directory (` + fmt.Sprintf("`%s`", runDirectoryExpression) + `).
When interrupted, shutdown hooks write files including network and process
state. On exit, these archive into ` + fmt.Sprintf("`%s.tar.gz`", runDirectoryExpression),
"stderr.log" in the run directory (` + fmt.Sprintf("`%s`", runDirectoryExpression) + `).`,
Before: func(c *cli.Context) error {
if err := api.EnsureEnvoyVersion(c.Context, o); err != nil {
return NewValidationError(err.Error())
Expand Down
82 changes: 43 additions & 39 deletions internal/cmd/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
package cmd_test

import (
"bufio"
"bytes"
"context"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"testing"
"time"

"github.com/stretchr/testify/require"
"github.com/urfave/cli/v2"
Expand All @@ -24,53 +26,50 @@ import (
"github.com/tetratelabs/func-e/internal/version"
)

func init() {
// Don't let urfave quit the current test process on cancel!
cli.OsExiter = func(code int) { log.Printf("urfave called exit: %d", code) }

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm sure @mathetake and @anuraaga could guess the rage level when I finally figured out what was quitting the whole test process.

}

// TestFuncERun takes care to not duplicate test/e2e/testrun.go, but still give some coverage.
func TestFuncERun(t *testing.T) {
o := setupTest(t)

// Override the Envoy path to use fake Envoy
// Use a fake Envoy binary
o.EnvoyPath = fakeEnvoyBin

stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)
c := rootcmd.NewApp(o)
c.Writer = stdout
c.ErrWriter = stderr

args := []string{"func-e", "run", "--config-yaml", "admin: {address: {socket_address: {address: '127.0.0.1', port_value: 0}}}"}

// Create a context that can be canceled
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // Ensure context is canceled when test completes
c.Name = "func-e"
// Create a pipe to capture stderr
stderrReader, stderrWriter := io.Pipe()
c.ErrWriter = stderrWriter

// Create a buffer-based reader that implements io.ReadSeeker
stderrBuf := new(bytes.Buffer)
c.ErrWriter = io.MultiWriter(stderr, stderrBuf)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()

// Run Envoy in a goroutine
errCh := make(chan error, 1)
// Start a goroutine to scan stderr until it reaches "starting main dispatch loop" written by envoy
go func() {
errCh <- c.RunContext(ctx, args)
scanner := bufio.NewScanner(stderrReader)
for scanner.Scan() {
if strings.Contains(scanner.Text(), "starting main dispatch loop") {
cancel() // interrupts the child func-e process
return
}
}
}()

// Wait for Envoy to output that it's started
require.Eventually(t, func() bool {
return strings.Contains(stderr.String(), "starting main dispatch loop")
}, 5*time.Second, 100*time.Millisecond, "Envoy didn't start within the expected time")

// Cancel the context to stop Envoy
cancel()
// When interrupted, func-e should return nil to match Envoy's behavior of exit code 0
args := []string{"func-e", "run", "--config-yaml", "admin: {address: {socket_address: {address: '127.0.0.1', port_value: 0}}}"}
require.NoError(t, c.RunContext(ctx, args))

// Wait for the command to complete
err := <-errCh
// TestFuncERun_TeesConsoleToLogs proves we can read Envoy logs
stderrBytes, err := os.ReadFile(filepath.Join(o.RunDir, "stderr.log"))
stderr := string(stderrBytes)
require.NoError(t, err)

// Verify all key messages from fake Envoy appear in the correct order using regex
stderrOutput := stderr.String()
pattern := `(?s).*initializing epoch 0.*admin address:.*starting main dispatch loop.*`
matched, err := regexp.MatchString(pattern, stderrOutput)
matched, err := regexp.MatchString(pattern, stderr)
require.NoError(t, err)
require.True(t, matched, "Expected fake Envoy output sequence not found in stderr")
require.True(t, matched, "Didn't find %s in Envoy stderr: %s", pattern, stderr)
}

func TestFuncERun_TeesConsoleToLogs(t *testing.T) {
Expand All @@ -79,17 +78,19 @@ func TestFuncERun_TeesConsoleToLogs(t *testing.T) {
c, stdout, stderr := newApp(o)
// ignore messages from func-e we only care about envoy
o.Out = io.Discard
o.DontArchiveRunDir = true // we need to read-back the log files
runWithInvalidConfig(t, c)

actual, err := os.ReadFile(filepath.Join(o.RunDir, "stdout.log"))
stdoutStream := stdout.String()
require.Empty(t, stdoutStream, "envoy doesn't write to stdout by default")
stdoutLogBytes, err := os.ReadFile(filepath.Join(o.RunDir, "stdout.log"))
require.NoError(t, err)
require.Contains(t, stdout.String(), string(actual))
require.Empty(t, stdoutLogBytes)

actual, err = os.ReadFile(filepath.Join(o.RunDir, "stderr.log"))
stderrStream := stderr.String()
require.Contains(t, stderrStream, "At least one of --config-path or --config-yaml or Options::configProto() should be non-empty")
stderrLogBytes, err := os.ReadFile(filepath.Join(o.RunDir, "stderr.log"))
require.NoError(t, err)
require.NotEmpty(t, stderr.String()) // sanity check
require.Equal(t, stderr.String(), string(actual))
require.Equal(t, stderrStream, string(stderrLogBytes))
}

func TestFuncERun_ReadsHomeVersionFile(t *testing.T) {
Expand Down Expand Up @@ -134,7 +135,10 @@ func TestFuncERun_CreatesHomeVersionFile(t *testing.T) {

// runWithInvalidConfig intentionally has envoy quit. This allows tests to not have to interrupt envoy to proceed
func runWithInvalidConfig(t *testing.T, c *cli.App) {
require.EqualError(t, c.Run([]string{"func-e", "run"}), "envoy exited with status: 1")
err := c.Run([]string{"func-e", "run"})
var exitErr *exec.ExitError
require.ErrorAs(t, err, &exitErr)
require.Equal(t, 1, exitErr.ExitCode())
}

func TestFuncERun_ValidatesHomeVersion(t *testing.T) {
Expand Down
4 changes: 2 additions & 2 deletions internal/cmd/testdata/func-e_help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ USAGE:
downloads and installs the latest version of Envoy for you.

To list versions of Envoy you can use, execute `func-e versions -a`. To
choose one, invoke `func-e use 1.34.2`. This installs into
`$FUNC_E_HOME/versions/1.34.2`, if not already present. You may also use
choose one, invoke `func-e use 1.34.3`. This installs into
`$FUNC_E_HOME/versions/1.34.3`, if not already present. You may also use
minor version, such as `func-e use 1.34`.

You may want to override `$ENVOY_VERSIONS_URL` to supply custom builds or
Expand Down
2 changes: 0 additions & 2 deletions internal/cmd/testdata/func-e_run_help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,3 @@ DESCRIPTION:

Envoy's process ID and console output write to "envoy.pid", stdout.log" and
"stderr.log" in the run directory (`$FUNC_E_HOME/runs/$epochtime`).
When interrupted, shutdown hooks write files including network and process
state. On exit, these archive into `$FUNC_E_HOME/runs/$epochtime.tar.gz`
2 changes: 1 addition & 1 deletion internal/cmd/testdata/func-e_use_help.txt
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,5 @@ DESCRIPTION:
depending on which is present.

Example:
$ func-e use 1.34.2
$ func-e use 1.34.3
$ func-e use 1.34
36 changes: 29 additions & 7 deletions internal/envoy/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestParseListeners(t *testing.T) {
adminLocalhostPath := filepath.Join(sourceDir, "testdata", "admin_localhost.yaml")
adminEphemeralPath := filepath.Join(sourceDir, "testdata", "admin_ephemeral.yaml")
noAdminPath := filepath.Join(sourceDir, "testdata", "no_admin.yaml")
minimalPath := filepath.Join(sourceDir, "testdata", "minimal.yaml")
accessLogPath := filepath.Join(sourceDir, "testdata", "access_log.yaml")
staticFilePath := filepath.Join(sourceDir, "testdata", "static_file.yaml")
udpProxyPath := filepath.Join(sourceDir, "testdata", "udp_proxy.yaml")

Expand Down Expand Up @@ -75,17 +75,39 @@ func TestParseListeners(t *testing.T) {
},
},
{
name: "minimal",
args: []string{"-c", minimalPath},
name: "access_log",
args: []string{"-c", accessLogPath},
expect: &Config{
Admin: "",
Admin: "127.0.0.1:0",
StaticListeners: []Listener{{
Name: "main",
Address: "127.0.0.1:0",
Filters: []filterInfo{{
Name: "envoy.filters.network.http_connection_manager",
Type: "type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager",
Config: internal.MinimalTypedConfigYaml,
Name: "envoy.filters.network.http_connection_manager",
Type: "type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager",
Config: `"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
stat_prefix: ingress_http
access_log:
- name: envoy.access_loggers.stdout
typed_config:
"@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
route_config:
name: local_route
virtual_hosts:
- name: direct_response_service
domains: ["*"]
routes:
- match:
prefix: "/"
direct_response:
status: 200
body:
inline_string: "Hello, World!"
http_filters:
- name: envoy.filters.http.router
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
`,
}},
}},
},
Expand Down
Loading
Loading