@mark1russell7/harness 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +74 -5
- package/build/index.d.ts +595 -13
- package/build/index.js +1249 -25
- package/build/index.js.map +1 -1
- package/image/go-http/Dockerfile +14 -0
- package/image/go-http/go.mod +3 -0
- package/image/go-http/main.go +324 -0
- package/image/native/src/bin/inject.rs +182 -4
- package/image/node/Dockerfile +3 -2
- package/image/node/client-sentinel.mjs +106 -0
- package/image/node/observer.mjs +39 -0
- package/image/node-http/Dockerfile +12 -0
- package/image/node-http/client-agent.mjs +163 -0
- package/image/node-http/client-undici.mjs +141 -0
- package/image/node-http/lines.mjs +59 -0
- package/image/node-http/npm-lock.json +22 -0
- package/image/node-http/package.json +8 -0
- package/package.json +13 -2
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# The Go HTTP client of the harness: the http.Transport of Go 1.27.2 at its defaults, with the
|
|
2
|
+
# settings of a cell over them. It runs as a container in the client's network namespace, as the
|
|
3
|
+
# harness's own TCP client does, and prints the same JSON lines. The program uses the standard
|
|
4
|
+
# library only, thus the digest of the builder pins all of its code.
|
|
5
|
+
# lab.ts stages the build context: this folder, under harness/image/go-http.
|
|
6
|
+
FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
|
|
7
|
+
WORKDIR /src
|
|
8
|
+
COPY harness/image/go-http/go.mod harness/image/go-http/main.go ./
|
|
9
|
+
# the format and the vet of the source come first, as the cargo test of the harness image does
|
|
10
|
+
RUN test -z "$(gofmt -l .)" || { gofmt -d .; exit 1; }
|
|
11
|
+
RUN CGO_ENABLED=0 go vet ./... && CGO_ENABLED=0 go build -trimpath -o /client-go .
|
|
12
|
+
|
|
13
|
+
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
|
|
14
|
+
COPY --from=build /client-go /opt/harness/client-go
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Command client-go is the Go client of the HTTP cells: the http.Transport of Go 1.27.2 at its
|
|
2
|
+
// defaults, with each setting that a cell gives over them. Each event is a JSON line with `ev` and
|
|
3
|
+
// `t`, the CLOCK_MONOTONIC time in nanoseconds, as the TCP client of the harness writes them.
|
|
4
|
+
//
|
|
5
|
+
// client-go HOST PORT --mode idle|inflight|gap [--gap-ms N] [--method GET|POST] [--hold-ms N]
|
|
6
|
+
// [--bytes N] [--go PATH] [--max-s N] [--linger-ms N]
|
|
7
|
+
// [--idle-conn-timeout-ms N] [--max-idle-per-host N] [--dial-timeout-ms N]
|
|
8
|
+
// [--keepalive-ms N] [--keepalive-interval-ms N] [--keepalive-count N] [--timeout-ms N]
|
|
9
|
+
//
|
|
10
|
+
// The first request is GET /, and the end of its reply is the ready line. Then the client holds
|
|
11
|
+
// the connection idle (idle), sends the second request when the file --go appears (inflight), or
|
|
12
|
+
// sends it after the gap (gap).
|
|
13
|
+
//
|
|
14
|
+
// A wrapper on each connection writes what the read loop of the transport reads: an end of file,
|
|
15
|
+
// or an error. The trace of the transport writes which connection each request goes on, whether
|
|
16
|
+
// the transport reused it, and each new try of a request on a new connection.
|
|
17
|
+
package main
|
|
18
|
+
|
|
19
|
+
import (
|
|
20
|
+
"context"
|
|
21
|
+
"encoding/json"
|
|
22
|
+
"errors"
|
|
23
|
+
"flag"
|
|
24
|
+
"fmt"
|
|
25
|
+
"io"
|
|
26
|
+
"net"
|
|
27
|
+
"net/http"
|
|
28
|
+
"net/http/httptrace"
|
|
29
|
+
"os"
|
|
30
|
+
"runtime"
|
|
31
|
+
"strings"
|
|
32
|
+
"sync"
|
|
33
|
+
"syscall"
|
|
34
|
+
"time"
|
|
35
|
+
"unsafe"
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
var out sync.Mutex
|
|
39
|
+
|
|
40
|
+
// now gives the time on CLOCK_MONOTONIC in nanoseconds: the clock of the prober.
|
|
41
|
+
func now() int64 {
|
|
42
|
+
var ts syscall.Timespec
|
|
43
|
+
syscall.Syscall(syscall.SYS_CLOCK_GETTIME, 1, uintptr(unsafe.Pointer(&ts)), 0)
|
|
44
|
+
return ts.Nano()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// emitAt writes one line with the time t.
|
|
48
|
+
func emitAt(ev string, t int64, fields map[string]any) {
|
|
49
|
+
line := map[string]any{"ev": ev, "t": t}
|
|
50
|
+
for key, value := range fields {
|
|
51
|
+
line[key] = value
|
|
52
|
+
}
|
|
53
|
+
bytes, _ := json.Marshal(line)
|
|
54
|
+
out.Lock()
|
|
55
|
+
os.Stdout.Write(append(bytes, '\n'))
|
|
56
|
+
out.Unlock()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// emit writes one line with the time now.
|
|
60
|
+
func emit(ev string, fields map[string]any) { emitAt(ev, now(), fields) }
|
|
61
|
+
|
|
62
|
+
var names = map[syscall.Errno]string{
|
|
63
|
+
syscall.ECONNRESET: "ECONNRESET",
|
|
64
|
+
syscall.ECONNREFUSED: "ECONNREFUSED",
|
|
65
|
+
syscall.ETIMEDOUT: "ETIMEDOUT",
|
|
66
|
+
syscall.EHOSTUNREACH: "EHOSTUNREACH",
|
|
67
|
+
syscall.ENETUNREACH: "ENETUNREACH",
|
|
68
|
+
syscall.EPIPE: "EPIPE",
|
|
69
|
+
syscall.ECONNABORTED: "ECONNABORTED",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// errnoOf gives the name of the error of a socket, EOF for an end of file, or TIMEOUT for a
|
|
73
|
+
// deadline of Go.
|
|
74
|
+
func errnoOf(err error) string {
|
|
75
|
+
var errno syscall.Errno
|
|
76
|
+
if errors.As(err, &errno) {
|
|
77
|
+
if name, ok := names[errno]; ok {
|
|
78
|
+
return name
|
|
79
|
+
}
|
|
80
|
+
return fmt.Sprintf("E%d", int(errno))
|
|
81
|
+
}
|
|
82
|
+
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
83
|
+
return "EOF"
|
|
84
|
+
}
|
|
85
|
+
var timeout interface{ Timeout() bool }
|
|
86
|
+
if errors.As(err, &timeout) && timeout.Timeout() {
|
|
87
|
+
return "TIMEOUT"
|
|
88
|
+
}
|
|
89
|
+
return "ERROR"
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// traced is a connection that writes what its reads end with, and its close.
|
|
93
|
+
type traced struct {
|
|
94
|
+
net.Conn
|
|
95
|
+
local string
|
|
96
|
+
mu sync.Mutex
|
|
97
|
+
closed bool
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
func (c *traced) Read(p []byte) (int, error) {
|
|
101
|
+
n, err := c.Conn.Read(p)
|
|
102
|
+
if err != nil {
|
|
103
|
+
c.mu.Lock()
|
|
104
|
+
closed := c.closed
|
|
105
|
+
c.mu.Unlock()
|
|
106
|
+
// a read after the close of the client itself is not an event of the peer
|
|
107
|
+
if !closed {
|
|
108
|
+
if errors.Is(err, io.EOF) {
|
|
109
|
+
emit("eof", map[string]any{"local": c.local})
|
|
110
|
+
} else {
|
|
111
|
+
emit("conn-error", map[string]any{"local": c.local, "errno": errnoOf(err), "message": err.Error()})
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return n, err
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func (c *traced) Close() error {
|
|
119
|
+
c.mu.Lock()
|
|
120
|
+
first := !c.closed
|
|
121
|
+
c.closed = true
|
|
122
|
+
c.mu.Unlock()
|
|
123
|
+
if first {
|
|
124
|
+
emit("closed", map[string]any{"local": c.local})
|
|
125
|
+
}
|
|
126
|
+
return c.Conn.Close()
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// dial gives the DialContext of the transport: the dialer, and a wrapper on each connection.
|
|
130
|
+
func dial(d *net.Dialer) func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
131
|
+
var mu sync.Mutex
|
|
132
|
+
count := 0
|
|
133
|
+
return func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
134
|
+
start := now()
|
|
135
|
+
conn, err := d.DialContext(ctx, network, addr)
|
|
136
|
+
if err != nil {
|
|
137
|
+
emit("dial-error", map[string]any{"errno": errnoOf(err), "message": err.Error(), "ns": now() - start})
|
|
138
|
+
return nil, err
|
|
139
|
+
}
|
|
140
|
+
mu.Lock()
|
|
141
|
+
count++
|
|
142
|
+
n := count
|
|
143
|
+
mu.Unlock()
|
|
144
|
+
local := conn.LocalAddr().String()
|
|
145
|
+
emit("conn", map[string]any{"n": n, "local": local})
|
|
146
|
+
if n == 1 {
|
|
147
|
+
emit("connected", map[string]any{"local": local})
|
|
148
|
+
}
|
|
149
|
+
return &traced{Conn: conn, local: local}, nil
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// request sends request i and waits for its outcome. It tells whether a reply came.
|
|
154
|
+
func request(client *http.Client, base string, i int, method, path string) bool {
|
|
155
|
+
attempts := 0
|
|
156
|
+
trace := &httptrace.ClientTrace{
|
|
157
|
+
GotConn: func(info httptrace.GotConnInfo) {
|
|
158
|
+
attempts++
|
|
159
|
+
ev := "request"
|
|
160
|
+
if attempts > 1 {
|
|
161
|
+
ev = "retry"
|
|
162
|
+
}
|
|
163
|
+
emit(ev, map[string]any{
|
|
164
|
+
"i": i,
|
|
165
|
+
"local": info.Conn.LocalAddr().String(),
|
|
166
|
+
"reused": info.Reused,
|
|
167
|
+
"idle_ns": info.IdleTime.Nanoseconds(),
|
|
168
|
+
})
|
|
169
|
+
},
|
|
170
|
+
WroteRequest: func(info httptrace.WroteRequestInfo) {
|
|
171
|
+
fields := map[string]any{"i": i}
|
|
172
|
+
if info.Err != nil {
|
|
173
|
+
fields["errno"] = errnoOf(info.Err)
|
|
174
|
+
}
|
|
175
|
+
emit("wrote", fields)
|
|
176
|
+
},
|
|
177
|
+
GotFirstResponseByte: func() { emit("first-byte", map[string]any{"i": i}) },
|
|
178
|
+
}
|
|
179
|
+
var body io.Reader
|
|
180
|
+
if method == "POST" {
|
|
181
|
+
body = strings.NewReader("x")
|
|
182
|
+
}
|
|
183
|
+
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), method, base+path, body)
|
|
184
|
+
if err != nil {
|
|
185
|
+
emit("fail", map[string]any{"why": err.Error()})
|
|
186
|
+
os.Exit(1)
|
|
187
|
+
}
|
|
188
|
+
// the stamp comes before the call: the transport writes the request after it
|
|
189
|
+
at := now()
|
|
190
|
+
emitAt("sent", at, map[string]any{"i": i})
|
|
191
|
+
resp, err := client.Do(req)
|
|
192
|
+
if err != nil {
|
|
193
|
+
emit("error", map[string]any{"i": i, "errno": errnoOf(err), "message": err.Error()})
|
|
194
|
+
return false
|
|
195
|
+
}
|
|
196
|
+
emit("response", map[string]any{"i": i, "status": resp.StatusCode})
|
|
197
|
+
n, err := io.Copy(io.Discard, resp.Body)
|
|
198
|
+
resp.Body.Close()
|
|
199
|
+
if err != nil {
|
|
200
|
+
emit("error", map[string]any{"i": i, "errno": errnoOf(err), "message": err.Error()})
|
|
201
|
+
return false
|
|
202
|
+
}
|
|
203
|
+
if i == 1 {
|
|
204
|
+
emit("ready", map[string]any{"reply": resp.Status})
|
|
205
|
+
} else {
|
|
206
|
+
emit("data", map[string]any{"i": i, "n": n})
|
|
207
|
+
emit("replied", map[string]any{"i": i, "n": map[string]any{"value": n, "unit": "B"}})
|
|
208
|
+
}
|
|
209
|
+
return true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func milliseconds(ms float64) time.Duration { return time.Duration(ms * float64(time.Millisecond)) }
|
|
213
|
+
|
|
214
|
+
func main() {
|
|
215
|
+
usage := "client-go HOST PORT --mode idle|inflight|gap"
|
|
216
|
+
if len(os.Args) < 3 {
|
|
217
|
+
emit("fail", map[string]any{"why": usage})
|
|
218
|
+
os.Exit(1)
|
|
219
|
+
}
|
|
220
|
+
host, port := os.Args[1], os.Args[2]
|
|
221
|
+
flags := flag.NewFlagSet("client-go", flag.ExitOnError)
|
|
222
|
+
mode := flags.String("mode", "", "idle, inflight or gap")
|
|
223
|
+
gapMs := flags.Float64("gap-ms", 0, "the gap after the first reply, in gap mode")
|
|
224
|
+
method := flags.String("method", "GET", "the method of the second request")
|
|
225
|
+
holdMs := flags.Int("hold-ms", 0, "the second request asks the server to hold its reply")
|
|
226
|
+
size := flags.Int("bytes", 0, "the second request asks for a body of this length")
|
|
227
|
+
goPath := flags.String("go", "/tmp/go", "the file that starts the second request, in inflight mode")
|
|
228
|
+
maxS := flags.Float64("max-s", 60, "the deadline of the client")
|
|
229
|
+
lingerMs := flags.Float64("linger-ms", 500, "the wait after the second request, before the exit")
|
|
230
|
+
idleMs := flags.Float64("idle-conn-timeout-ms", 0, "Transport.IdleConnTimeout")
|
|
231
|
+
maxIdle := flags.Int("max-idle-per-host", 0, "Transport.MaxIdleConnsPerHost")
|
|
232
|
+
dialMs := flags.Float64("dial-timeout-ms", 0, "Dialer.Timeout")
|
|
233
|
+
keepAliveMs := flags.Float64("keepalive-ms", 0, "Dialer.KeepAlive, negative for off")
|
|
234
|
+
intervalMs := flags.Float64("keepalive-interval-ms", 0, "KeepAliveConfig.Interval")
|
|
235
|
+
probes := flags.Int("keepalive-count", 0, "KeepAliveConfig.Count")
|
|
236
|
+
timeoutMs := flags.Float64("timeout-ms", 0, "Client.Timeout")
|
|
237
|
+
flags.Parse(os.Args[3:])
|
|
238
|
+
given := map[string]bool{}
|
|
239
|
+
flags.Visit(func(f *flag.Flag) { given[f.Name] = true })
|
|
240
|
+
if *mode != "idle" && *mode != "inflight" && *mode != "gap" {
|
|
241
|
+
emit("fail", map[string]any{"why": usage})
|
|
242
|
+
os.Exit(1)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// the dialer of http.DefaultTransport (src/net/http/transport.go:47-53), with the settings of the cell
|
|
246
|
+
dialer := &net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}
|
|
247
|
+
if given["dial-timeout-ms"] {
|
|
248
|
+
dialer.Timeout = milliseconds(*dialMs)
|
|
249
|
+
}
|
|
250
|
+
if given["keepalive-ms"] {
|
|
251
|
+
dialer.KeepAlive = milliseconds(*keepAliveMs)
|
|
252
|
+
}
|
|
253
|
+
if given["keepalive-interval-ms"] || given["keepalive-count"] {
|
|
254
|
+
dialer.KeepAliveConfig = net.KeepAliveConfig{
|
|
255
|
+
Enable: dialer.KeepAlive >= 0,
|
|
256
|
+
Idle: dialer.KeepAlive,
|
|
257
|
+
Interval: milliseconds(*intervalMs),
|
|
258
|
+
Count: *probes,
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
transport := http.DefaultTransport.(*http.Transport).Clone()
|
|
262
|
+
transport.DialContext = dial(dialer)
|
|
263
|
+
if given["idle-conn-timeout-ms"] {
|
|
264
|
+
transport.IdleConnTimeout = milliseconds(*idleMs)
|
|
265
|
+
}
|
|
266
|
+
if given["max-idle-per-host"] {
|
|
267
|
+
transport.MaxIdleConnsPerHost = *maxIdle
|
|
268
|
+
}
|
|
269
|
+
client := &http.Client{Transport: transport}
|
|
270
|
+
if given["timeout-ms"] {
|
|
271
|
+
client.Timeout = milliseconds(*timeoutMs)
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
bootID, _ := os.ReadFile("/proc/sys/kernel/random/boot_id")
|
|
275
|
+
settings := map[string]any{}
|
|
276
|
+
settings["IdleConnTimeout_ms"] = transport.IdleConnTimeout.Milliseconds()
|
|
277
|
+
settings["MaxIdleConnsPerHost"] = transport.MaxIdleConnsPerHost
|
|
278
|
+
settings["DialTimeout_ms"] = dialer.Timeout.Milliseconds()
|
|
279
|
+
settings["KeepAlive_ms"] = dialer.KeepAlive.Milliseconds()
|
|
280
|
+
settings["KeepAliveInterval_ms"] = dialer.KeepAliveConfig.Interval.Milliseconds()
|
|
281
|
+
settings["KeepAliveCount"] = dialer.KeepAliveConfig.Count
|
|
282
|
+
settings["ClientTimeout_ms"] = client.Timeout.Milliseconds()
|
|
283
|
+
start := map[string]any{}
|
|
284
|
+
start["boot_id"] = strings.TrimSpace(string(bootID))
|
|
285
|
+
start["mode"] = *mode
|
|
286
|
+
start["client"] = "go-nethttp"
|
|
287
|
+
start["go"] = runtime.Version()
|
|
288
|
+
start["settings"] = settings
|
|
289
|
+
emit("start", start)
|
|
290
|
+
go func() {
|
|
291
|
+
time.Sleep(time.Duration(*maxS * float64(time.Second)))
|
|
292
|
+
emit("deadline", nil)
|
|
293
|
+
os.Exit(0)
|
|
294
|
+
}()
|
|
295
|
+
|
|
296
|
+
base := "http://" + net.JoinHostPort(host, port)
|
|
297
|
+
second := "/"
|
|
298
|
+
if *holdMs > 0 {
|
|
299
|
+
second = fmt.Sprintf("/hold?ms=%d", *holdMs)
|
|
300
|
+
} else if *size > 0 {
|
|
301
|
+
second = fmt.Sprintf("/big?bytes=%d", *size)
|
|
302
|
+
}
|
|
303
|
+
if !request(client, base, 1, "GET", "/") {
|
|
304
|
+
os.Exit(1)
|
|
305
|
+
}
|
|
306
|
+
switch *mode {
|
|
307
|
+
case "idle":
|
|
308
|
+
// the connection stays in the pool until it ends, or until the deadline
|
|
309
|
+
select {}
|
|
310
|
+
case "inflight":
|
|
311
|
+
for {
|
|
312
|
+
if _, err := os.Stat(*goPath); err == nil {
|
|
313
|
+
break
|
|
314
|
+
}
|
|
315
|
+
time.Sleep(2 * time.Millisecond)
|
|
316
|
+
}
|
|
317
|
+
case "gap":
|
|
318
|
+
time.Sleep(milliseconds(*gapMs))
|
|
319
|
+
}
|
|
320
|
+
request(client, base, 2, *method, second)
|
|
321
|
+
emit("done", map[string]any{"i": 2})
|
|
322
|
+
time.Sleep(milliseconds(*lingerMs))
|
|
323
|
+
os.Exit(0)
|
|
324
|
+
}
|
|
@@ -27,6 +27,17 @@
|
|
|
27
27
|
//! toxic of Toxiproxy. The stamps bracket the request and its answer, after the connect, so the
|
|
28
28
|
//! commit of the toxic is known on the same clock as the kernel's records.
|
|
29
29
|
//!
|
|
30
|
+
//! - `signal NAME SIGNAL`: SIGNAL (KILL, STOP, CONT or TERM) to each process whose name is NAME,
|
|
31
|
+
//! in the process namespace this sidecar shares with a member of a group. STOP and CONT freeze
|
|
32
|
+
//! a server and thaw it while its kernel stays up; KILL stops it, and its kernel closes its
|
|
33
|
+
//! sockets. The stamps bracket the kill(2) calls.
|
|
34
|
+
//! - `resp ADDR:PORT ARG...`: one command to a Redis-protocol server, for example
|
|
35
|
+
//! `SENTINEL FAILOVER mymaster` to a Sentinel. The stamps bracket the write of the command and
|
|
36
|
+
//! the first line of its reply, so a planned failover starts at a known instant.
|
|
37
|
+
//!
|
|
38
|
+
//! A filter rule's PEER can be a list, `A,B,C`: the rules for each peer go in the same commit, so
|
|
39
|
+
//! a part of a group loses all of its peers at one instant.
|
|
40
|
+
//!
|
|
30
41
|
//! `--at NS` holds the commit until that instant on CLOCK_MONOTONIC, the clock every stamp is on.
|
|
31
42
|
//! A stall's end is timed that way, from inside the VM: the exec only has to arrive before it, and
|
|
32
43
|
//! its 190 ms never reach the stall's length.
|
|
@@ -234,12 +245,139 @@ fn http(args: &[String]) -> Result<(), Box<dyn Error>> {
|
|
|
234
245
|
Ok(())
|
|
235
246
|
}
|
|
236
247
|
|
|
248
|
+
/// the number of a signal that `signal` sends, by its name
|
|
249
|
+
fn signal_number(name: &str) -> Result<libc::c_int, Box<dyn Error>> {
|
|
250
|
+
Ok(match name {
|
|
251
|
+
"KILL" => libc::SIGKILL,
|
|
252
|
+
"STOP" => libc::SIGSTOP,
|
|
253
|
+
"CONT" => libc::SIGCONT,
|
|
254
|
+
"TERM" => libc::SIGTERM,
|
|
255
|
+
other => return Err(format!("not a signal this injector sends: {other}").into()),
|
|
256
|
+
})
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/// a process name: letters, digits, dots, dashes and underscores, as /proc/PID/comm holds it
|
|
260
|
+
fn process_name(name: &str) -> Result<&str, Box<dyn Error>> {
|
|
261
|
+
if !name.is_empty()
|
|
262
|
+
&& name.len() <= 15
|
|
263
|
+
&& name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
|
|
264
|
+
{
|
|
265
|
+
Ok(name)
|
|
266
|
+
} else {
|
|
267
|
+
Err(format!("not a process name: {name}").into())
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/// tells if a line of /proc/PID/stat is a zombie's: its state, after the name in parentheses, is Z
|
|
272
|
+
fn zombie(stat: &str) -> bool {
|
|
273
|
+
stat.rfind(')').and_then(|end| stat[end + 1..].split_whitespace().next()) == Some("Z")
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/// the processes of this process namespace whose name is `name`
|
|
277
|
+
fn processes(name: &str) -> Result<Vec<libc::pid_t>, Box<dyn Error>> {
|
|
278
|
+
let mut found = Vec::new();
|
|
279
|
+
for entry in std::fs::read_dir("/proc")? {
|
|
280
|
+
let entry = entry?;
|
|
281
|
+
let Some(pid) = entry.file_name().to_str().and_then(|text| text.parse::<libc::pid_t>().ok()) else {
|
|
282
|
+
continue;
|
|
283
|
+
};
|
|
284
|
+
// a process can end between the listing and the read. A zombie keeps its name until its
|
|
285
|
+
// parent reaps it: a server that a fin killed is one, and no signal reaches it
|
|
286
|
+
let Ok(comm) = std::fs::read_to_string(entry.path().join("comm")) else { continue };
|
|
287
|
+
let Ok(stat) = std::fs::read_to_string(entry.path().join("stat")) else { continue };
|
|
288
|
+
if comm.trim_end() == name && !zombie(&stat) {
|
|
289
|
+
found.push(pid);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
found.sort_unstable();
|
|
293
|
+
Ok(found)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/// `signal NAME SIGNAL [--at NS]`: the signal to each process of that name, bracketed by stamps
|
|
297
|
+
fn signal(args: &[String]) -> Result<(), Box<dyn Error>> {
|
|
298
|
+
let name = process_name(args.get(1).ok_or("signal NAME SIGNAL [--at NS]")?)?;
|
|
299
|
+
let sig = signal_number(args.get(2).ok_or("and the signal: KILL, STOP, CONT or TERM")?)?;
|
|
300
|
+
let opts = options(&args[3..])?;
|
|
301
|
+
let pids = processes(name)?;
|
|
302
|
+
if pids.is_empty() {
|
|
303
|
+
return Err(format!("no process {name} in this process namespace").into());
|
|
304
|
+
}
|
|
305
|
+
if let Some(at) = opts.at {
|
|
306
|
+
until(at)?;
|
|
307
|
+
}
|
|
308
|
+
let t0 = monotonic_ns();
|
|
309
|
+
for pid in &pids {
|
|
310
|
+
// SAFETY: kill(2) with a pid that this namespace listed, and a signal from the table above
|
|
311
|
+
if unsafe { libc::kill(*pid, sig) } != 0 {
|
|
312
|
+
return Err(format!("kill {pid} failed: {}", std::io::Error::last_os_error()).into());
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
let t1 = monotonic_ns();
|
|
316
|
+
println!("{}", json!({ "action": "signal", "name": name, "signal": args[2], "pids": pids, "t0": t0, "t1": t1 }));
|
|
317
|
+
Ok(())
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/// a command in the Redis protocol: an array of bulk strings
|
|
321
|
+
fn resp_command(args: &[String]) -> Vec<u8> {
|
|
322
|
+
let mut out = format!("*{}\r\n", args.len()).into_bytes();
|
|
323
|
+
for arg in args {
|
|
324
|
+
out.extend_from_slice(format!("${}\r\n", arg.len()).as_bytes());
|
|
325
|
+
out.extend_from_slice(arg.as_bytes());
|
|
326
|
+
out.extend_from_slice(b"\r\n");
|
|
327
|
+
}
|
|
328
|
+
out
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/// `resp ADDR:PORT ARG...`: one command, and the first line of its reply, bracketed by stamps
|
|
332
|
+
fn resp(args: &[String]) -> Result<(), Box<dyn Error>> {
|
|
333
|
+
use std::io::Read;
|
|
334
|
+
use std::net::{SocketAddrV4, TcpStream};
|
|
335
|
+
let addr: SocketAddrV4 = args.get(1).ok_or("resp ADDR:PORT ARG...")?.parse()?;
|
|
336
|
+
let command = &args[2..];
|
|
337
|
+
if command.is_empty() {
|
|
338
|
+
return Err("and the command".into());
|
|
339
|
+
}
|
|
340
|
+
let mut stream = TcpStream::connect(addr)?;
|
|
341
|
+
let t0 = monotonic_ns();
|
|
342
|
+
stream.write_all(&resp_command(command))?;
|
|
343
|
+
let mut line = Vec::new();
|
|
344
|
+
let mut byte = [0u8; 1];
|
|
345
|
+
while !line.ends_with(b"\r\n") {
|
|
346
|
+
if stream.read(&mut byte)? == 0 {
|
|
347
|
+
return Err("the server closed before its reply".into());
|
|
348
|
+
}
|
|
349
|
+
line.push(byte[0]);
|
|
350
|
+
}
|
|
351
|
+
let t1 = monotonic_ns();
|
|
352
|
+
let reply = String::from_utf8_lossy(&line[..line.len() - 2]).to_string();
|
|
353
|
+
if reply.starts_with('-') {
|
|
354
|
+
return Err(format!("{} answered {reply}", command.join(" ")).into());
|
|
355
|
+
}
|
|
356
|
+
println!("{}", json!({ "action": "resp", "command": command, "reply": reply, "t0": t0, "t1": t1 }));
|
|
357
|
+
Ok(())
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/// the peers of a filter rule: one address, or a list `A,B,C`
|
|
361
|
+
fn peers(text: &str) -> Result<Vec<Ipv4Addr>, Box<dyn Error>> {
|
|
362
|
+
let list: Vec<Ipv4Addr> = text.split(',').map(str::parse).collect::<Result<_, _>>()?;
|
|
363
|
+
if list.is_empty() {
|
|
364
|
+
return Err("no peer".into());
|
|
365
|
+
}
|
|
366
|
+
Ok(list)
|
|
367
|
+
}
|
|
368
|
+
|
|
237
369
|
fn run() -> Result<(), Box<dyn Error>> {
|
|
238
370
|
let args: Vec<String> = std::env::args().skip(1).collect();
|
|
239
371
|
let action = args.first().ok_or("say an action: blackhole, clear, rst, reject, mtu, netem, offload, http …")?.as_str();
|
|
240
372
|
if action == "http" {
|
|
241
373
|
return http(&args);
|
|
242
374
|
}
|
|
375
|
+
if action == "signal" {
|
|
376
|
+
return signal(&args);
|
|
377
|
+
}
|
|
378
|
+
if action == "resp" {
|
|
379
|
+
return resp(&args);
|
|
380
|
+
}
|
|
243
381
|
match action {
|
|
244
382
|
"netem" | "clear-netem" | "offload" => {
|
|
245
383
|
let dev = device(args.get(1).ok_or("and a device")?)?;
|
|
@@ -279,10 +417,14 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|
|
279
417
|
Ok(())
|
|
280
418
|
}
|
|
281
419
|
_ => {
|
|
282
|
-
// parsed, never pasted: only
|
|
283
|
-
let
|
|
420
|
+
// parsed, never pasted: only addresses reach the rules
|
|
421
|
+
let list = peers(args.get(1).ok_or("and the peer's address")?)?;
|
|
284
422
|
let opts = options(&args[2..])?;
|
|
285
|
-
let
|
|
423
|
+
let mut body = String::new();
|
|
424
|
+
for peer in &list {
|
|
425
|
+
body.push_str(&rules(action, *peer, &opts)?);
|
|
426
|
+
}
|
|
427
|
+
let script = format!("*filter\n{body}COMMIT\n");
|
|
286
428
|
if let Some(at) = opts.at {
|
|
287
429
|
until(at)?;
|
|
288
430
|
}
|
|
@@ -295,7 +437,8 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|
|
295
437
|
if !status.success() {
|
|
296
438
|
return Err(format!("iptables-restore exited {status}").into());
|
|
297
439
|
}
|
|
298
|
-
|
|
440
|
+
let named: Vec<String> = list.iter().map(ToString::to_string).collect();
|
|
441
|
+
println!("{}", json!({ "action": action, "peer": named.join(","), "t0": t0, "t1": t1 }));
|
|
299
442
|
Ok(())
|
|
300
443
|
}
|
|
301
444
|
}
|
|
@@ -365,6 +508,41 @@ mod tests {
|
|
|
365
508
|
assert_eq!(options(&["--via".into(), "eth1".into()]).unwrap().via.as_deref(), Some("eth1"));
|
|
366
509
|
}
|
|
367
510
|
|
|
511
|
+
#[test]
|
|
512
|
+
fn a_list_of_peers_is_addresses_only() {
|
|
513
|
+
assert_eq!(peers("10.0.0.2").unwrap().len(), 1);
|
|
514
|
+
assert_eq!(peers("10.0.0.2,10.0.0.3,10.0.0.4").unwrap()[2].to_string(), "10.0.0.4");
|
|
515
|
+
for text in ["", "10.0.0.2,", "10.0.0.2;x", "a,b", "10.0.0.2 10.0.0.3"] {
|
|
516
|
+
assert!(peers(text).is_err(), "{text}");
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
#[test]
|
|
521
|
+
fn signals_and_process_names_come_from_a_short_list() {
|
|
522
|
+
assert_eq!(signal_number("STOP").unwrap(), libc::SIGSTOP);
|
|
523
|
+
assert_eq!(signal_number("CONT").unwrap(), libc::SIGCONT);
|
|
524
|
+
assert_eq!(signal_number("KILL").unwrap(), libc::SIGKILL);
|
|
525
|
+
assert!(signal_number("HUP").is_err());
|
|
526
|
+
assert!(process_name("valkey-server").is_ok());
|
|
527
|
+
assert!(process_name("valkey-sentinel").is_ok());
|
|
528
|
+
for name in ["", "a b", "x;y", "much-too-long-a-name"] {
|
|
529
|
+
assert!(process_name(name).is_err(), "{name}");
|
|
530
|
+
}
|
|
531
|
+
// the state is the field after the name, whose parentheses can hold spaces
|
|
532
|
+
assert!(zombie("812 (valkey-server) Z 1 812 812 0 -1"));
|
|
533
|
+
assert!(!zombie("813 (valkey-server) S 1 813 813 0 -1"));
|
|
534
|
+
assert!(!zombie("814 (a) Z) S 1"));
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
#[test]
|
|
538
|
+
fn a_resp_command_is_an_array_of_bulk_strings() {
|
|
539
|
+
let args: Vec<String> = ["SENTINEL", "FAILOVER", "mymaster"].iter().map(|a| a.to_string()).collect();
|
|
540
|
+
assert_eq!(
|
|
541
|
+
resp_command(&args),
|
|
542
|
+
b"*3\r\n$8\r\nSENTINEL\r\n$8\r\nFAILOVER\r\n$8\r\nmymaster\r\n".to_vec()
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
|
|
368
546
|
#[test]
|
|
369
547
|
fn http_requests_take_only_a_method_a_path_and_a_length() {
|
|
370
548
|
assert!(http_path("/proxies/redis/toxics").is_ok());
|
package/image/node/Dockerfile
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# The harness's Node client: ioredis 6.0.0 on Node 24.21.0, the releases the corpus's claims about
|
|
2
2
|
# ioredis and Node's keepalive name. It runs as a container in the client's
|
|
3
|
-
# network namespace, as the harness's own TCP client does, and prints the same JSON lines.
|
|
3
|
+
# network namespace, as the harness's own TCP client does, and prints the same JSON lines. The same
|
|
4
|
+
# image holds a group's client in Sentinel mode and the observer of its Sentinels.
|
|
4
5
|
# lab.ts stages the build context: this folder, under harness/image/node. The lockfile is
|
|
5
6
|
# npm-lock.json here, because npm leaves each package-lock.json out of a published tarball.
|
|
6
7
|
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1
|
|
@@ -8,4 +9,4 @@ WORKDIR /opt/harness/node
|
|
|
8
9
|
COPY harness/image/node/package.json ./
|
|
9
10
|
COPY harness/image/node/npm-lock.json ./package-lock.json
|
|
10
11
|
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund
|
|
11
|
-
COPY harness/image/node/client-ioredis.mjs ./
|
|
12
|
+
COPY harness/image/node/client-ioredis.mjs harness/image/node/client-sentinel.mjs harness/image/node/observer.mjs ./
|