Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add fakebinary for acommodating testing the child process management #371

Merged
merged 24 commits into from
Sep 13, 2021
Merged
Show file tree
Hide file tree
Changes from 20 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 internal/envoy/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ func (r *Runtime) Run(ctx context.Context, args []string) (err error) { //nolint
// Block until it exits to ensure file descriptors are closed prior to archival.
// Allow up to 5 seconds for a clean stop, killing if it can't for any reason.
select {
case <-waitCtx.Done():
case <-waitCtx.Done(): // cmd.Wait goroutine finished
case <-time.After(5 * time.Second):
_ = moreos.EnsureProcessDone(r.cmd.Process)
}
Expand Down
3 changes: 2 additions & 1 deletion internal/envoy/run_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import (
"github.com/tetratelabs/func-e/internal/globals"
"github.com/tetratelabs/func-e/internal/moreos"
"github.com/tetratelabs/func-e/internal/test"
"github.com/tetratelabs/func-e/internal/test/fakebinary"
)

func TestRuntime_Run(t *testing.T) {
Expand All @@ -41,7 +42,7 @@ func TestRuntime_Run(t *testing.T) {
adminFlag := fmt.Sprint("--admin-address-path ", filepath.Join(runDir, "admin-address.txt"))

fakeEnvoy := filepath.Join(tempDir, "envoy"+moreos.Exe)
test.RequireFakeEnvoy(t, fakeEnvoy)
fakebinary.RequireFakeEnvoy(t, fakeEnvoy)

tests := []struct {
name string
Expand Down
3 changes: 2 additions & 1 deletion internal/envoy/shutdown/admin_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import (
"github.com/tetratelabs/func-e/internal/globals"
"github.com/tetratelabs/func-e/internal/moreos"
"github.com/tetratelabs/func-e/internal/test"
"github.com/tetratelabs/func-e/internal/test/fakebinary"
)

func TestEnableEnvoyAdminDataCollection(t *testing.T) {
Expand All @@ -45,7 +46,7 @@ func TestEnableEnvoyAdminDataCollection(t *testing.T) {
// runWithShutdownHook is like RequireRun, except invokes the hook on shutdown
func runWithShutdownHook(t *testing.T, runDir string, hook func(r *envoy.Runtime) error) error {
fakeEnvoy := filepath.Join(runDir, "envoy"+moreos.Exe)
test.RequireFakeEnvoy(t, fakeEnvoy)
fakebinary.RequireFakeEnvoy(t, fakeEnvoy)

o := &globals.RunOpts{EnvoyPath: fakeEnvoy, RunDir: runDir, DontArchiveRunDir: true}

Expand Down
116 changes: 116 additions & 0 deletions internal/moreos/moreos_func-e_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// Copyright 2021 Tetrate
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package moreos

import (
"bufio"
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"

"github.com/shirou/gopsutil/v3/process"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/tetratelabs/func-e/internal/test/fakebinary"
"github.com/tetratelabs/func-e/internal/version"
)

// TestProcessGroupAttr_Kill tests sending SIGKILL to fake func-e and makes sure fake envoy receives
// SIGTERM (as defined in Pdeathsig).
func TestProcessGroupAttr_Kill(t *testing.T) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I prefer to use a condition on runtime.GOOS vs _linux because myself and others might refactor something and it will break compilation only on CI.

So, basically handle this in a t.Skip() instead of build condition please? We can do this because there are no linux-specific code called here. When doing the skip, do note why it won't work in darwin or windows in case we can follow-up.

tempDir := t.TempDir()

// Build a fake envoy and pass the ENV hint so that fake func-e uses it
fakeEnvoy := filepath.Join(tempDir, "envoy"+Exe)
fakebinary.RequireFakeEnvoy(t, fakeEnvoy)
t.Setenv("ENVOY_PATH", fakeEnvoy)

fakeFuncE := filepath.Join(tempDir, "func-e"+Exe)
requireFakeFuncE(t, fakeFuncE)

stderr := new(bytes.Buffer)
stdout := new(bytes.Buffer)

// With an arg so fakeFuncE runs fakeEnvoy as its child and doesn't exit.
arg := string(version.LastKnownEnvoy)
cmd := exec.Command(fakeFuncE, "run", arg, "-c")
cmd.Stdout = stdout
cmd.Stderr = stderr

require.NoError(t, cmd.Start())

// Block until we reach an expected line or timeout.
reader := bufio.NewReader(stderr)
waitFor := "initializing epoch 0"
if !assert.Eventually(t, func() bool {
b, e := reader.Peek(512)
return e != nil && strings.Contains(string(b), waitFor)
}, 5*time.Second, 100*time.Millisecond) {
require.FailNowf(t, "timeout waiting for stderr to contain %q", waitFor)
}

require.Equal(t, Sprintf("starting: %s %s -c\n", fakeEnvoy, arg), stdout.String())

fakeFuncEProcess, err := process.NewProcess(int32(cmd.Process.Pid))
require.NoError(t, err)

// Get all fake func-e children processes.
children, err := fakeFuncEProcess.Children()
require.NoError(t, err)
require.Equal(t, len(children), 1) // Should have only one child process i.e. the fake envoy process.
fakeEnvoyProcess := &os.Process{Pid: int(children[0].Pid)}
requireFindProcessNoError(t, fakeEnvoyProcess)
dio marked this conversation as resolved.
Show resolved Hide resolved

// Kill the fake func-e process.
// This works only for linux, sending kill -9 on darwin will not kill the process, we need to kill
dio marked this conversation as resolved.
Show resolved Hide resolved
// via pgid or kill the child first.
require.NoError(t, cmd.Process.Kill())

// The child process is expected to receive ENVOY_SIGTERM.
dio marked this conversation as resolved.
Show resolved Hide resolved
require.Contains(t, "caught ENVOY_SIGTERM\nexiting\n", stderr.String())

// Wait for the process to die; this could error due to the kill signal.
cmd.Wait() //nolint

// Wait and check if fake func-e and envoy processes are killed.
requireFindProcessError(t, cmd.Process, process.ErrorProcessNotRunning)
requireFindProcessError(t, fakeEnvoyProcess, process.ErrorProcessNotRunning)

// Ensure both processes are killed.
require.NoError(t, EnsureProcessDone(cmd.Process))
require.NoError(t, EnsureProcessDone(fakeEnvoyProcess))
}

func requireFindProcessError(t *testing.T, proc *os.Process, expectedErr error) {
// Wait until the operating system removes or adds the scheduled process.
if !assert.Eventually(t, func() bool {
_, err := process.NewProcess(int32(proc.Pid)) // because os.FindProcess is no-op in Linux!
return err == expectedErr
}, 100*time.Millisecond, 5*time.Millisecond) {
if expectedErr == nil {
require.FailNow(t, "timeout waiting for finding process with no error")
}
require.FailNow(t, "timeout waiting for expected error %v", expectedErr)
}
}

func requireFindProcessNoError(t *testing.T, proc *os.Process) {
requireFindProcessError(t, proc, nil) // expect to find the process with no error.
}
84 changes: 84 additions & 0 deletions internal/moreos/moreos_func-e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright 2021 Tetrate
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package moreos

import (
"bytes"
"embed"
"os"
"os/exec"
"path/filepath"
"testing"

"github.com/tetratelabs/func-e/internal/test/fakebinary"

"github.com/stretchr/testify/require"
)

var (
// fakeFuncESrc is a test source file used to simulate how func-e manages its child process
//go:embed testdata/fake_func-e.go
fakeFuncESrc []byte // Embedding the fakeFuncESrc is easier than file I/O and ensures it doesn't skew coverage

// Include the source imported by fakeFuncESrc directly and indirectly
//go:embed moreos.go
dio marked this conversation as resolved.
Show resolved Hide resolved
//go:embed proc_*.go
moreosSrcDir embed.FS
)

// Test_ImmediateExit tests the case when fake func-e fails on running fake envoy.
func Test_ImmediateExit(t *testing.T) {
tempDir := t.TempDir()

// Build a fake envoy and pass the ENV hint so that fake func-e uses it
fakeEnvoy := filepath.Join(tempDir, "envoy"+Exe)
fakebinary.RequireFakeEnvoy(t, fakeEnvoy)
t.Setenv("ENVOY_PATH", fakeEnvoy)

fakeFuncE := filepath.Join(tempDir, "func-e"+Exe)
requireFakeFuncE(t, fakeFuncE)

stdout := new(bytes.Buffer)
stderr := new(bytes.Buffer)

// intentionally no args, as we expect it to not block and also fail
cmd := exec.Command(fakeFuncE, "run")
cmd.Stdout = stdout
cmd.Stderr = stderr

require.Error(t, cmd.Run())
require.Equal(t, Sprintf("starting: %s\n", fakeEnvoy), stdout.String())
require.Contains(t, stderr.String(), Sprintf("envoy exited with status: 1\n"))
}

// requireFakeFuncE builds a func-e binary only depends on fakeFuncESrc and the sources in this package.
// This is used to test integrated use of tools like ProcessGroupAttr without mixing unrelated concerns or dependencies.
func requireFakeFuncE(t *testing.T, path string) {
workDir := t.TempDir()

// Copy the sources needed for fake func-e, but nothing else
moreosDir := filepath.Join(workDir, "internal", "moreos")
require.NoError(t, os.MkdirAll(moreosDir, 0700))
moreosSrcs, err := moreosSrcDir.ReadDir(".")
require.NoError(t, err)
for _, src := range moreosSrcs {
data, err := moreosSrcDir.ReadFile(src.Name())
require.NoError(t, err)
require.NoError(t, os.WriteFile(filepath.Join(moreosDir, src.Name()), data, 0600))
}

fakeFuncEBin := fakebinary.RequireBuildFakeBinary(t, workDir, "func-e", fakeFuncESrc)
require.NoError(t, os.WriteFile(path, fakeFuncEBin, 0700)) //nolint:gosec
}
73 changes: 73 additions & 0 deletions internal/moreos/testdata/fake_func-e.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package main
Copy link
Member

@mathetake mathetake Sep 13, 2021

Choose a reason for hiding this comment

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

nit I would like to name this to fake_func_e.go following the Go convention 😄

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I have a mixed feeling about this since func-e is a name. I will defer this to @codefromthecrypt 🙏🏽.


// only import moreos, as that's what we are testing
import (
"context"
"os"
"os/exec"
"os/signal"
"strings"
"syscall"
"time"

"github.com/tetratelabs/func-e/internal/moreos"
)

// main simulates ../../.../main.go, but only focuses on sub process control style used by envoy.Run.
// This allows us to write unit tests and identify failures more directly than e2e tests.
//
// Notably, this uses a variable ENVOY_PATH instead of envoy.GetHomeVersion, in order to reduce logic.
//
// In the future, some of this process control structure might move to moreos in order to reduce copy/paste between here
// and internal/envoy/run.go (envoy.Run).
func main() {
if len(os.Args) < 2 {
moreos.Fprintf(os.Stderr, "not enough args\n")
os.Exit(1)
}

if os.Args[1] != "run" {
moreos.Fprintf(os.Stderr, "%s not supported\n", os.Args[1])
os.Exit(1)
}

// Like envoy.GetHomeVersion, $FUNC_E_HOME/versions/$(cat $FUNC_E_HOME/version)/bin/envoy$GOEXE.
cmd := exec.Command(os.Getenv("ENVOY_PATH"), os.Args[2:]...)
cmd.SysProcAttr = moreos.ProcessGroupAttr()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr

// Like envoy.Run.
waitCtx, waitCancel := context.WithCancel(context.Background())
sigCtx, _ := signal.NotifyContext(waitCtx, syscall.SIGINT, syscall.SIGTERM)
defer waitCancel()

moreos.Fprintf(os.Stdout, "starting: %s\n", strings.Join(cmd.Args, " ")) //nolint
if err := cmd.Start(); err != nil {
moreos.Fprintf(os.Stderr, "error: unable to start Envoy process: %s\n", err)
os.Exit(1)
}

// Wait in a goroutine. We may need to kill the process if a signal occurs first.
go func() {
defer waitCancel()
_ = cmd.Wait() // Envoy logs like "caught SIGINT" or "caught ENVOY_SIGTERM", so we don't repeat logging here.
}()

// Block until we receive SIGINT or are canceled because Envoy has died.
<-sigCtx.Done()

// Block until it exits to ensure file descriptors are closed prior to archival.
// Allow up to 5 seconds for a clean stop, killing if it can't for any reason.
select {
case <-waitCtx.Done(): // cmd.Wait goroutine finished
case <-time.After(5 * time.Second):
_ = moreos.EnsureProcessDone(cmd.Process)
}

if cmd.ProcessState != nil && cmd.ProcessState.ExitCode() > 0 {
moreos.Fprintf(os.Stderr, "envoy exited with status: %d\n", cmd.ProcessState.ExitCode())
os.Exit(1)
}
os.Exit(0)
}
55 changes: 0 additions & 55 deletions internal/test/envoy.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,11 @@ import (
_ "embed" // Embedding the fakeEnvoySrc is easier than file I/O and ensures it doesn't skew coverage
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/tetratelabs/func-e/internal/moreos"
)

// Runner allows us to not introduce dependency cycles on envoy.Runtime
Expand Down Expand Up @@ -77,50 +69,3 @@ func RequireRun(t *testing.T, shutdown func(), r Runner, stderr io.Reader, args
<-ran // block until the runner finished
return err
}

var (
// fakeEnvoySrc is a test source file used to simulate Envoy console output and signal processing.
//go:embed testdata/fake_envoy.go
fakeEnvoySrc []byte
// fakeEnvoyBin is the compiled code of fakeEnvoySrc which will be runtime.GOOS dependent.
fakeEnvoyBin []byte
builtFakeEnvoy sync.Once
)

// RequireFakeEnvoy writes fakeEnvoyBin to the given path
func RequireFakeEnvoy(t *testing.T, path string) {
builtFakeEnvoy.Do(func() {
fakeEnvoyBin = requireBuildFakeEnvoy(t)
})
require.NoError(t, os.WriteFile(path, fakeEnvoyBin, 0700)) //nolint:gosec
}

// requireBuildFakeEnvoy builds a fake envoy binary and returns its contents.
func requireBuildFakeEnvoy(t *testing.T) []byte {
goBin := requireGoBin(t)
tempDir := t.TempDir()

name := "envoy"
bin := name + moreos.Exe
src := name + ".go"
require.NoError(t, os.WriteFile(filepath.Join(tempDir, src), fakeEnvoySrc, 0600))
cmd := exec.Command(goBin, "build", "-o", bin, src) //nolint:gosec
cmd.Dir = tempDir
out, err := cmd.CombinedOutput()
require.NoError(t, err, "couldn't compile %s: %s", src, string(out))
bytes, err := os.ReadFile(filepath.Join(tempDir, bin))
require.NoError(t, err)
return bytes
}

func requireGoBin(t *testing.T) string {
binName := "go" + moreos.Exe
goBin := filepath.Join(runtime.GOROOT(), "bin", binName)
if _, err := os.Stat(goBin); err == nil {
return goBin
}
// Now, search the path
goBin, err := exec.LookPath(binName)
require.NoError(t, err, "couldn't find %s in the PATH", goBin)
return goBin
}
Loading