Skip to content

Commit a9919f1

Browse files
Refactors to essential admin hook
Signed-off-by: Adrian Cole <adrian@tetrate.io>
1 parent 66fbffe commit a9919f1

8 files changed

Lines changed: 650 additions & 79 deletions

File tree

internal/cmd/run_test.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,17 @@ func TestFuncERun_TeesConsoleToLogs(t *testing.T) {
8080
o.Out = io.Discard
8181
runWithInvalidConfig(t, c)
8282

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

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

9396
func TestFuncERun_ReadsHomeVersionFile(t *testing.T) {

internal/envoy/proc_attr_other.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright func-e contributors
22
// SPDX-License-Identifier: Apache-2.0
3+
//go:build !linux
34

45
package envoy
56

internal/envoy/run.go

Lines changed: 54 additions & 70 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// Copyright func-e contributors
12
// SPDX-License-Identifier: Apache-2.0
23

34
package envoy
@@ -8,13 +9,11 @@ import (
89
"errors"
910
"fmt"
1011
"io"
11-
"net/http"
1212
"os"
1313
"os/exec"
1414
"path/filepath"
1515
"strconv"
1616
"strings"
17-
"time"
1817
)
1918

2019
// Run execs the Envoy binary at the path with the args passed.
@@ -54,91 +53,76 @@ func (r *Runtime) Run(ctx context.Context, args []string) error {
5453
// Warn, but don't fail if we can't write the pid file for some reason
5554
r.maybeWarn(os.WriteFile(filepath.Join(r.o.RunDir, "envoy.pid"), []byte(strconv.Itoa(cmd.Process.Pid)), 0o600))
5655

57-
// Start a goroutine to scan stderr for "starting main dispatch loop"
58-
go r.collectAdminDataOnceRunning(ctx, stderrPipe)
56+
errCh := make(chan error, 1)
5957

60-
// Wait for the process to exit.
61-
err = cmd.Wait()
62-
if err == nil {
63-
return nil
58+
// Process stderr in a goroutine
59+
go r.processStderr(ctx, stderrPipe, errCh)
60+
61+
// Wait for the process to exit
62+
waitErr := cmd.Wait()
63+
64+
// After process exit, check for any stderr processing error
65+
stderrErr := <-errCh
66+
if stderrErr != nil {
67+
return stderrErr
6468
}
65-
if errors.Is(ctx.Err(), context.Canceled) {
66-
return nil // e.g. graceful shutdown via API
69+
70+
if waitErr == nil || errors.Is(ctx.Err(), context.Canceled) {
71+
return nil // don't treat context cancel (graceful shutdown) as an error
6772
}
68-
return err
73+
return waitErr
6974
}
7075

71-
// collectAdminDataOnceRunning scans stderr for the admin address and waits for Envoy to be fully started
72-
// before collecting config_dump to the run directory.
73-
func (r *Runtime) collectAdminDataOnceRunning(ctx context.Context, cmdStderrPipe io.Reader) {
74-
scanner := bufio.NewScanner(cmdStderrPipe)
75-
adminCollected := false
76+
// processStderr scans stderr output and triggers the startup hook when Envoy is ready.
77+
func (r *Runtime) processStderr(ctx context.Context, stderrPipe io.Reader, errCh chan<- error) {
78+
var procErr error
79+
defer func() {
80+
if p := recover(); p != nil {
81+
if procErr == nil {
82+
procErr = fmt.Errorf("processStderr panicked: %v", p)
83+
}
84+
r.logf("processStderr panicked: %v", p)
85+
}
86+
errCh <- procErr
87+
}()
88+
89+
scanner := bufio.NewScanner(stderrPipe)
90+
hookTriggered := false
7691

7792
for scanner.Scan() {
7893
line := scanner.Text()
79-
// copy stderr to the output writer
94+
// Copy stderr line to the output writer
8095
fmt.Fprintln(r.Err, line) //nolint:errcheck
8196

82-
// Collect config dump when ready
83-
if !adminCollected && strings.Contains(line, "starting main dispatch loop") {
84-
adminCollected = true
97+
// Trigger startup hook when admin is ready
98+
if !hookTriggered && strings.Contains(line, "starting main dispatch loop") {
99+
hookTriggered = true
85100
adminAddrBytes, err := os.ReadFile(r.adminAddressPath)
86101
if err != nil {
87-
r.logf("failed to read admin address from %s: %v", r.adminAddressPath, err)
88-
continue
102+
procErr = fmt.Errorf("failed to read admin address from %s: %w", r.adminAddressPath, err)
103+
r.logf(procErr.Error())
104+
break
89105
}
90106
adminAddress := strings.TrimSpace(string(adminAddrBytes))
91107
r.adminAddress = adminAddress
92-
// Use a separate goroutine to avoid blocking stderr scanning
93-
go func(addr string) {
94-
if err := collectConfigDump(ctx, addr, r.GetRunDir()); err != nil {
95-
r.logf("failed to collect config_dump from %s: %v", addr, err)
96-
} else {
97-
r.logf("collected config_dump from: %s", addr)
98-
}
99-
}(adminAddress)
100-
}
101-
}
102-
103-
if err := scanner.Err(); err != nil {
104-
r.logf("error scanning stderr: %v", err)
105-
}
106-
}
107-
108-
// collectConfigDump fetches config_dump from Envoy admin API
109-
func collectConfigDump(ctx context.Context, adminAddress, runDir string) error {
110-
url := fmt.Sprintf("http://%s/config_dump", adminAddress)
111-
file := filepath.Join(runDir, "config_dump.json")
112108

113-
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
114-
defer cancel()
115-
return copyURLToFile(ctx, url, file)
116-
}
117-
118-
func copyURLToFile(ctx context.Context, url, fullPath string) error {
119-
// #nosec -> runDir is allowed to be anywhere
120-
f, err := os.OpenFile(fullPath, os.O_CREATE|os.O_WRONLY, 0o600)
121-
if err != nil {
122-
return fmt.Errorf("could not open %q: %w", fullPath, err)
123-
}
124-
defer f.Close() //nolint:errcheck
125-
126-
// #nosec -> adminAddress is written by Envoy and the paths are hard-coded
127-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
128-
if err != nil {
129-
return fmt.Errorf("could not create request %v: %w", url, err)
130-
}
131-
res, err := http.DefaultClient.Do(req)
132-
if err != nil {
133-
return fmt.Errorf("could not read %v: %w", url, err)
109+
// Call startup hook
110+
if err := r.startupHook(ctx, r.o.RunDir, adminAddress); err != nil {
111+
procErr = err
112+
r.logf(err.Error())
113+
break
114+
}
115+
}
134116
}
135-
defer res.Body.Close() //nolint:errcheck
136117

137-
if res.StatusCode != http.StatusOK {
138-
return fmt.Errorf("received %v from %v", res.StatusCode, url)
139-
}
140-
if _, err := io.Copy(f, res.Body); err != nil {
141-
return fmt.Errorf("could not write response body of %v: %w", url, err)
118+
// Log and propagate unexpected scanner errors, ignoring EOF, closed pipe, or context cancellation.
119+
if err := scanner.Err(); err != nil && ctx.Err() == nil {
120+
// Skip expected errors that indicate normal stream closure
121+
if !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrClosedPipe) {
122+
r.logf("error scanning stderr: %v", err)
123+
if procErr == nil {
124+
procErr = fmt.Errorf("error scanning stderr: %w", err)
125+
}
126+
}
142127
}
143-
return nil
144128
}

0 commit comments

Comments
 (0)