1
16
17 package process_test
18
19 import (
20 "bytes"
21 "net"
22 "net/http"
23 "net/url"
24 "os"
25 "strconv"
26 "time"
27
28 . "github.com/onsi/ginkgo/v2"
29 . "github.com/onsi/gomega"
30 "github.com/onsi/gomega/ghttp"
31 "sigs.k8s.io/controller-runtime/pkg/internal/testing/addr"
32 . "sigs.k8s.io/controller-runtime/pkg/internal/testing/process"
33 )
34
35 const (
36 healthURLPath = "/healthz"
37 )
38
39 var _ = Describe("Start method", func() {
40 var (
41 processState *State
42 server *ghttp.Server
43 )
44 BeforeEach(func() {
45 server = ghttp.NewServer()
46
47 processState = &State{
48 Path: "bash",
49 Args: simpleBashScript,
50 HealthCheck: HealthCheck{
51 URL: getServerURL(server),
52 },
53 }
54 processState.Path = "bash"
55 processState.Args = simpleBashScript
56
57 })
58 AfterEach(func() {
59 server.Close()
60 })
61
62 Context("when process takes too long to start", func() {
63 BeforeEach(func() {
64 server.RouteToHandler("GET", healthURLPath, func(resp http.ResponseWriter, _ *http.Request) {
65 time.Sleep(250 * time.Millisecond)
66 resp.WriteHeader(http.StatusOK)
67 })
68 })
69 It("returns a timeout error", func() {
70 processState.StartTimeout = 200 * time.Millisecond
71
72 err := processState.Start(nil, nil)
73 Expect(err).To(MatchError(ContainSubstring("timeout")))
74
75 Eventually(func() bool { done, _ := processState.Exited(); return done }).Should(BeTrue())
76 })
77 })
78
79 Context("when the healthcheck returns ok", func() {
80 BeforeEach(func() {
81
82 server.RouteToHandler("GET", healthURLPath, ghttp.RespondWith(http.StatusOK, ""))
83 })
84
85 It("can start a process", func() {
86 processState.StartTimeout = 10 * time.Second
87
88 err := processState.Start(nil, nil)
89 Expect(err).NotTo(HaveOccurred())
90
91 Consistently(processState.Exited).Should(BeFalse())
92 })
93
94 It("hits the endpoint, and successfully starts", func() {
95 processState.StartTimeout = 100 * time.Millisecond
96
97 err := processState.Start(nil, nil)
98 Expect(err).NotTo(HaveOccurred())
99 Expect(server.ReceivedRequests()).To(HaveLen(1))
100 Consistently(processState.Exited).Should(BeFalse())
101 })
102
103 Context("when the command cannot be started", func() {
104 var err error
105
106 BeforeEach(func() {
107 processState = &State{}
108 processState.Path = "/nonexistent"
109
110 err = processState.Start(nil, nil)
111 })
112
113 It("propagates the error", func() {
114 Expect(os.IsNotExist(err)).To(BeTrue())
115 })
116
117 Context("but Stop() is called on it", func() {
118 It("does not panic", func() {
119 stoppingFailedProcess := func() {
120 Expect(processState.Stop()).To(Succeed())
121 }
122
123 Expect(stoppingFailedProcess).NotTo(Panic())
124 })
125 })
126 })
127
128 Context("when IO is configured", func() {
129 It("can inspect stdout & stderr", func() {
130 stdout := &bytes.Buffer{}
131 stderr := &bytes.Buffer{}
132
133 processState.Args = []string{
134 "-c",
135 `
136 echo 'this is stderr' >&2
137 echo 'that is stdout'
138 echo 'i started' >&2
139 `,
140 }
141 processState.StartTimeout = 5 * time.Second
142
143 Expect(processState.Start(stdout, stderr)).To(Succeed())
144 Eventually(processState.Exited).Should(BeTrue())
145
146 Expect(stdout.String()).To(Equal("that is stdout\n"))
147 Expect(stderr.String()).To(Equal("this is stderr\ni started\n"))
148 })
149 })
150 })
151
152 Context("when the healthcheck always returns failure", func() {
153 BeforeEach(func() {
154 server.RouteToHandler("GET", healthURLPath, ghttp.RespondWith(http.StatusInternalServerError, ""))
155 })
156 It("returns a timeout error and stops health API checker", func() {
157 processState.HealthCheck.URL = getServerURL(server)
158 processState.HealthCheck.Path = healthURLPath
159 processState.StartTimeout = 500 * time.Millisecond
160
161 err := processState.Start(nil, nil)
162 Expect(err).To(MatchError(ContainSubstring("timeout")))
163
164 nrReceivedRequests := len(server.ReceivedRequests())
165 Expect(nrReceivedRequests).To(Equal(5))
166 time.Sleep(200 * time.Millisecond)
167 Expect(nrReceivedRequests).To(Equal(5))
168 })
169 })
170
171 Context("when the healthcheck isn't even listening", func() {
172 BeforeEach(func() {
173 server.Close()
174 })
175
176 It("returns a timeout error", func() {
177 processState.HealthCheck.Path = healthURLPath
178 processState.StartTimeout = 500 * time.Millisecond
179
180 port, host, err := addr.Suggest("")
181 Expect(err).NotTo(HaveOccurred())
182
183 processState.HealthCheck.URL = url.URL{
184 Scheme: "http",
185 Host: net.JoinHostPort(host, strconv.Itoa(port)),
186 }
187
188 err = processState.Start(nil, nil)
189 Expect(err).To(MatchError(ContainSubstring("timeout")))
190 })
191 })
192
193 Context("when the healthcheck fails initially but succeeds eventually", func() {
194 BeforeEach(func() {
195 server.AppendHandlers(
196 ghttp.RespondWith(http.StatusInternalServerError, ""),
197 ghttp.RespondWith(http.StatusInternalServerError, ""),
198 ghttp.RespondWith(http.StatusInternalServerError, ""),
199 ghttp.RespondWith(http.StatusOK, ""),
200 )
201 })
202
203 It("hits the endpoint repeatedly, and successfully starts", func() {
204 processState.HealthCheck.URL = getServerURL(server)
205 processState.HealthCheck.Path = healthURLPath
206 processState.StartTimeout = 20 * time.Second
207
208 err := processState.Start(nil, nil)
209 Expect(err).NotTo(HaveOccurred())
210 Expect(server.ReceivedRequests()).To(HaveLen(4))
211 Consistently(processState.Exited).Should(BeFalse())
212 })
213
214 Context("when the polling interval is not configured", func() {
215 It("uses the default interval for polling", func() {
216 processState.HealthCheck.URL = getServerURL(server)
217 processState.HealthCheck.Path = "/helathz"
218 processState.StartTimeout = 300 * time.Millisecond
219
220 Expect(processState.Start(nil, nil)).To(MatchError(ContainSubstring("timeout")))
221 Expect(server.ReceivedRequests()).To(HaveLen(3))
222 })
223 })
224
225 Context("when the polling interval is configured", func() {
226 BeforeEach(func() {
227 processState.HealthCheck.URL = getServerURL(server)
228 processState.HealthCheck.Path = healthURLPath
229 processState.HealthCheck.PollInterval = time.Millisecond * 150
230 })
231
232 It("hits the endpoint in the configured interval", func() {
233 processState.StartTimeout = 3 * processState.HealthCheck.PollInterval
234
235 Expect(processState.Start(nil, nil)).To(MatchError(ContainSubstring("timeout")))
236 Expect(server.ReceivedRequests()).To(HaveLen(3))
237 })
238 })
239 })
240 })
241
242 var _ = Describe("Stop method", func() {
243 var (
244 server *ghttp.Server
245 processState *State
246 )
247 BeforeEach(func() {
248 server = ghttp.NewServer()
249 server.RouteToHandler("GET", healthURLPath, ghttp.RespondWith(http.StatusOK, ""))
250 processState = &State{
251 Path: "bash",
252 Args: simpleBashScript,
253 HealthCheck: HealthCheck{
254 URL: getServerURL(server),
255 },
256 }
257 processState.StartTimeout = 10 * time.Second
258 })
259
260 AfterEach(func() {
261 server.Close()
262 })
263 Context("when Stop() is called", func() {
264 BeforeEach(func() {
265 Expect(processState.Start(nil, nil)).To(Succeed())
266 processState.StopTimeout = 10 * time.Second
267 })
268
269 It("stops the process", func() {
270 Expect(processState.Stop()).To(Succeed())
271 })
272
273 Context("multiple times", func() {
274 It("does not error or panic on consecutive calls", func() {
275 stoppingTheProcess := func() {
276 Expect(processState.Stop()).To(Succeed())
277 }
278 Expect(stoppingTheProcess).NotTo(Panic())
279 Expect(stoppingTheProcess).NotTo(Panic())
280 Expect(stoppingTheProcess).NotTo(Panic())
281 })
282 })
283 })
284
285 Context("when the command cannot be stopped", func() {
286 It("returns a timeout error", func() {
287 Expect(processState.Start(nil, nil)).To(Succeed())
288 processState.StopTimeout = 1 * time.Nanosecond
289
290 Expect(processState.Stop()).To(MatchError(ContainSubstring("timeout")))
291 })
292 })
293
294 Context("when the directory needs to be cleaned up", func() {
295 It("removes the directory", func() {
296 var err error
297
298 Expect(processState.Start(nil, nil)).To(Succeed())
299 processState.Dir, err = os.MkdirTemp("", "k8s_test_framework_")
300 Expect(err).NotTo(HaveOccurred())
301 processState.DirNeedsCleaning = true
302 processState.StopTimeout = 400 * time.Millisecond
303
304 Expect(processState.Stop()).To(Succeed())
305 Expect(processState.Dir).NotTo(BeAnExistingFile())
306 })
307 })
308 })
309
310 var _ = Describe("Init", func() {
311 Context("when all inputs are provided", func() {
312 It("passes them through", func() {
313 ps := &State{
314 Dir: "/some/dir",
315 Path: "/some/path/to/some/bin",
316 StartTimeout: 20 * time.Hour,
317 StopTimeout: 65537 * time.Millisecond,
318 }
319
320 Expect(ps.Init("some name")).To(Succeed())
321
322 Expect(ps.Dir).To(Equal("/some/dir"))
323 Expect(ps.DirNeedsCleaning).To(BeFalse())
324 Expect(ps.Path).To(Equal("/some/path/to/some/bin"))
325 Expect(ps.StartTimeout).To(Equal(20 * time.Hour))
326 Expect(ps.StopTimeout).To(Equal(65537 * time.Millisecond))
327 })
328 })
329
330 Context("when inputs are empty", func() {
331 It("ps them", func() {
332 ps := &State{}
333 Expect(ps.Init("some name")).To(Succeed())
334
335 Expect(ps.Dir).To(BeADirectory())
336 Expect(os.RemoveAll(ps.Dir)).To(Succeed())
337 Expect(ps.DirNeedsCleaning).To(BeTrue())
338
339 Expect(ps.Path).NotTo(BeEmpty())
340
341 Expect(ps.StartTimeout).NotTo(BeZero())
342 Expect(ps.StopTimeout).NotTo(BeZero())
343 })
344 })
345
346 Context("when neither name nor path are provided", func() {
347 It("returns an error", func() {
348 ps := &State{}
349 Expect(ps.Init("")).To(MatchError("must have at least one of name or path"))
350 })
351 })
352 })
353
354 var simpleBashScript = []string{
355 "-c", "tail -f /dev/null",
356 }
357
358 func getServerURL(server *ghttp.Server) url.URL {
359 url, err := url.Parse(server.URL())
360 Expect(err).NotTo(HaveOccurred())
361 url.Path = healthURLPath
362 return *url
363 }
364
View as plain text