-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinit_command_test.go
More file actions
270 lines (243 loc) · 8.75 KB
/
Copy pathinit_command_test.go
File metadata and controls
270 lines (243 loc) · 8.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
// Init-command tests use a real shell script in a temp dir, since
// exec.CommandContext + a real fork/exec is what the helper does in
// production. We can't reasonably mock os/exec without making the
// helper test-shaped instead of operator-shaped, so the tests run on
// Unix only — Windows builds skip via the build tag.
//go:build unix
package scopecache
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
"time"
)
// pidIsRunning returns true iff /proc/<pid>/status reports a live
// (non-zombie) process. kill(pid, 0) is unsuitable: the kernel keeps
// the PID slot until reaping, so an unreaped zombie still answers
// "alive" via that path even though the process is functionally
// dead. The orphan-children regression test specifically needs to
// distinguish "running" from "zombie" because the group-kill fix
// leaves children as zombies — they are killed but not reaped (PID 1
// in a Docker container without a tini-style reaper picks them up
// only on container teardown).
func pidIsRunning(pid int) bool {
data, err := os.ReadFile(filepath.Join("/proc", strconv.Itoa(pid), "status"))
if err != nil {
return false
}
for _, line := range strings.Split(string(data), "\n") {
if strings.HasPrefix(line, "State:") {
fields := strings.Fields(line)
if len(fields) >= 2 && fields[1] == "Z" {
return false
}
return true
}
}
return false
}
// writeInitCommandHelper creates a `chmod +x` script in dir that
// records its environment to outFile. One line per invocation; the
// line includes the SCOPECACHE_SOCKET_PATH env var so tests can
// verify extraEnv was forwarded.
func writeInitCommandHelper(t *testing.T, dir, outFile string) string {
t.Helper()
path := filepath.Join(dir, "init.sh")
body := "#!/bin/sh\necho \"sock=$SCOPECACHE_SOCKET_PATH\" >> " + outFile + "\n"
if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
t.Fatalf("write script: %v", err)
}
return path
}
// Empty command is a no-op: returns nil and never invokes logf.
func TestRunInitCommand_EmptyIsNoOp(t *testing.T) {
gw := NewGateway(Config{})
calls := 0
err := gw.RunInitCommand(context.Background(), "", nil, func(string, ...any) { calls++ })
if err != nil {
t.Errorf("err = %v, want nil for empty command", err)
}
if calls != 0 {
t.Errorf("logf called %d times for empty command, want 0", calls)
}
}
// Happy path: script runs sync, env vars from extraEnv reach it,
// logf sees the running + completed lines, return value is nil.
func TestRunInitCommand_RunsScriptAndForwardsEnv(t *testing.T) {
dir := t.TempDir()
outFile := filepath.Join(dir, "out.log")
command := writeInitCommandHelper(t, dir, outFile)
gw := NewGateway(Config{})
var lines []string
err := gw.RunInitCommand(
context.Background(),
command,
[]string{"SCOPECACHE_SOCKET_PATH=/tmp/foo.sock"},
func(format string, args ...any) {
lines = append(lines, format)
},
)
if err != nil {
t.Fatalf("RunInitCommand: %v", err)
}
out, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("read %s: %v", outFile, err)
}
want := "sock=/tmp/foo.sock"
if !strings.Contains(string(out), want) {
t.Errorf("script output = %q, want substring %q (extraEnv not forwarded?)", string(out), want)
}
// Two log lines on success: "running" + "completed".
if len(lines) != 2 {
t.Errorf("logf calls = %d, want 2 (running + completed); got %v", len(lines), lines)
}
}
// Script that exits non-zero produces an error AND a log line.
func TestRunInitCommand_FailureReturnsError(t *testing.T) {
dir := t.TempDir()
cmdPath := filepath.Join(dir, "fail.sh")
body := "#!/bin/sh\nexit 1\n"
if err := os.WriteFile(cmdPath, []byte(body), 0o755); err != nil {
t.Fatalf("write script: %v", err)
}
gw := NewGateway(Config{})
var lines []string
err := gw.RunInitCommand(context.Background(), cmdPath, nil, func(format string, args ...any) {
lines = append(lines, format)
})
if err == nil {
t.Fatal("expected error from failing script; got nil")
}
// Two log lines on failure: "running" + the error line.
if len(lines) != 2 {
t.Errorf("logf calls = %d, want 2 (running + error); got %v", len(lines), lines)
}
}
// Missing executable: cmd.Run reports an exec error which the helper
// wraps and returns. Pins the contract that we don't pre-stat the
// path; operators who deploy the script after Caddy starts only see
// the failure on the next reload.
func TestRunInitCommand_MissingPathReturnsError(t *testing.T) {
gw := NewGateway(Config{})
missing := filepath.Join(t.TempDir(), "does-not-exist.sh")
err := gw.RunInitCommand(context.Background(), missing, nil, nil)
if err == nil {
t.Fatal("expected error for missing executable; got nil")
}
}
// Nil logf is a supported shape — both adapters pass a non-nil
// logger today, but the helper must not panic if a future caller
// (or a test) passes nil. The "running"/"completed" lines are simply
// dropped.
func TestRunInitCommand_NilLogfDoesNotPanic(t *testing.T) {
dir := t.TempDir()
cmdPath := filepath.Join(dir, "ok.sh")
if err := os.WriteFile(cmdPath, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatalf("write script: %v", err)
}
gw := NewGateway(Config{})
if err := gw.RunInitCommand(context.Background(), cmdPath, nil, nil); err != nil {
t.Errorf("RunInitCommand with nil logf: %v", err)
}
}
// Cancellation via context: a long-running script gets SIGKILL'd
// when the parent context is cancelled, and RunInitCommand returns
// promptly. Pre-fix the helper used exec.Command (no context), so
// SIGINT/SIGTERM during init would leave the parent stuck in
// cmd.Run() until the script exited voluntarily — a hung curl or
// tarpitted DB query could block boot indefinitely.
//
// The asserted bound is generous (3s) so the test cannot flake on
// CI load while still being orders of magnitude below the
// "infinitely-hanging" failure mode the fix targets.
func TestRunInitCommand_ContextCancelStopsHungScript(t *testing.T) {
dir := t.TempDir()
cmdPath := filepath.Join(dir, "hang.sh")
if err := os.WriteFile(cmdPath, []byte("#!/bin/sh\nsleep 60\n"), 0o755); err != nil {
t.Fatalf("write script: %v", err)
}
gw := NewGateway(Config{})
ctx, cancel := context.WithCancel(context.Background())
// Cancel after 200ms — long enough that the script is definitely
// running (the sleep has begun) but short enough that the test
// doesn't drag.
go func() {
time.Sleep(200 * time.Millisecond)
cancel()
}()
t0 := time.Now()
err := gw.RunInitCommand(ctx, cmdPath, nil, nil)
elapsed := time.Since(t0)
if err == nil {
t.Fatal("expected error after cancel; got nil")
}
if elapsed > 3*time.Second {
t.Errorf("RunInitCommand returned after %v; want < 3s after cancel", elapsed)
}
}
// Process-group kill: a script that backgrounds children must have
// its whole process group reaped on cancel. Pre-fix only the direct
// child got SIGKILL'd; a script doing `sleep 60 &` would orphan the
// sleep onto PID 1. configureProcessGroup wraps the cmd in a new
// process group so the cancel kills the negative PID (whole group).
//
// Repro: shell that backgrounds `sleep 60`, writes its PID, waits.
// After cancel, assert the PID is no longer running.
func TestRunInitCommand_CancelReapsBackgroundedChildren(t *testing.T) {
dir := t.TempDir()
cmdPath := filepath.Join(dir, "fork.sh")
pidFile := filepath.Join(dir, "child.pid")
body := "#!/bin/sh\n" +
"sleep 60 &\n" +
"echo $! > " + pidFile + "\n" +
"wait\n"
if err := os.WriteFile(cmdPath, []byte(body), 0o755); err != nil {
t.Fatalf("write script: %v", err)
}
gw := NewGateway(Config{})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- gw.RunInitCommand(ctx, cmdPath, nil, nil)
}()
// Wait until the child PID has been written.
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if data, err := os.ReadFile(pidFile); err == nil && len(strings.TrimSpace(string(data))) > 0 {
break
}
time.Sleep(10 * time.Millisecond)
}
pidBytes, err := os.ReadFile(pidFile)
if err != nil {
cancel()
t.Fatalf("read pid file: %v", err)
}
pid, err := strconv.Atoi(strings.TrimSpace(string(pidBytes)))
if err != nil {
cancel()
t.Fatalf("parse pid %q: %v", string(pidBytes), err)
}
if !pidIsRunning(pid) {
cancel()
t.Fatalf("backgrounded child pid=%d not running before cancel — test setup did not reproduce orphan scenario", pid)
}
cancel()
<-done
// After cancel, the group kill should have hit the sleep too.
deadline = time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if !pidIsRunning(pid) {
return
}
time.Sleep(10 * time.Millisecond)
}
// Best-effort cleanup so a regressed test doesn't leave a 60s sleep.
_ = syscall.Kill(pid, syscall.SIGKILL)
t.Errorf("backgrounded child pid=%d still running 2s after cancel — process group not reaped", pid)
}