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 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 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
138 changes: 138 additions & 0 deletions internal/moreos/moreos_func-e_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
// 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"
"embed"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"

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

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

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
// TODO: replace wildcard with {{goos}} after https://github.com/golang/go/issues/48348.
//go:embed moreos.go
dio marked this conversation as resolved.
Show resolved Hide resolved
//go:embed proc_*.go
moreosSrcDir embed.FS
)

// TestProcessGroupAttr_Kill tests sending SIGKILL to fake func-e.
// On linux, we propagate SIGKILL to the child process as the configured SysProcAttr.Pdeathsig
// in proc_linux.go.
func TestProcessGroupAttr_Kill(t *testing.T) {
// This works only for linux, sending kill -9 on darwin will not kill the process, we need to kill
// via pgid or kill the child first.
if runtime.GOOS != "linux" {
t.Skip()
}
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"
require.Eventually(t, func() bool {
b, _ := reader.Peek(512) // the error value is always EOF.
return strings.HasPrefix(string(b), waitFor)
}, 5*time.Second, 100*time.Millisecond, "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)}

// Kill the fake func-e process.
require.NoError(t, cmd.Process.Kill())

// 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))
}

// 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
}

func requireFindProcessError(t *testing.T, proc *os.Process, expectedErr error) {
// Wait until the operating system removes or adds the scheduled process.
require.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, "timeout waiting for expected error %v", expectedErr)
}
5 changes: 3 additions & 2 deletions internal/moreos/proc_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,9 @@ import (
const exe = ""

func processGroupAttr() *syscall.SysProcAttr {
// Pdeathsig aims to ensure the process group is cleaned up even if this process dies
return &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGTERM}
// Pdeathsig aims to ensure the process group is cleaned up even if this process dies. When func-e
// dies, the process (envoy) will get SIGKILL.
return &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL}
}

func interrupt(p *os.Process) error {
Expand Down
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