blob: 9bf17fa20f7091cc7d5f9f8eeb8a475cdb63b9c9 [file] [log] [blame]
giob7df27f2026-07-28 10:36:17 +04001package e2e
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net"
10 "net/http"
11 "os"
12 "os/exec"
13 "path/filepath"
14 "runtime"
15 "strconv"
16 "strings"
17 "sync"
18 "time"
19)
20
21type stackPorts struct {
22 KratosPublic int `json:"kratos_public"`
23 KratosAdmin int `json:"kratos_admin"`
24 HydraPublic int `json:"hydra_public"`
25 HydraAdmin int `json:"hydra_admin"`
26 UI int `json:"ui"`
27 API int `json:"api"`
28 SMTP int `json:"smtp"`
29}
30
31type Stack struct {
32 Repo string
33 Workspace string
34 ArtifactDir string
35 Ports stackPorts
36 KratosURL string
37 KratosAdmin string
38 HydraURL string
39 HydraAdmin string
40 UIURL string
41 APIURL string
42 Kratos *supervisedProcess
43 Hydra *supervisedProcess
44 AuthUI *supervisedProcess
45 startedAt time.Time
46 ready bool
47 lastStatus string
48 metadataMu sync.Mutex
49 redactions []string
50 ownershipMu sync.Mutex
51 cleanupStarted bool
52 stopOnce sync.Once
53 stopErr error
54 startProcessOp func(string, string, string, string, ...string) (*supervisedProcess, error)
55 stopProcessOp func(*supervisedProcess, time.Duration) error
56}
57
58func startStack() (*Stack, error) {
59 return startStackOwned(nil)
60}
61
62func startStackOwned(owner *suiteLifecycle) (*Stack, error) {
63 if _, err := artifactsFor(runtime.GOOS, runtime.GOARCH); err != nil {
64 return nil, err
65 }
66 repo, err := repositoryDir()
67 if err != nil {
68 return nil, err
69 }
70 workspace, err := os.MkdirTemp("", "auth-ui-e2e-*")
71 if err != nil {
72 return nil, err
73 }
74 runDir, err := createRunArtifactDir(repo)
75 if err != nil {
76 _ = os.RemoveAll(workspace)
77 return nil, err
78 }
79 s := &Stack{Repo: repo, Workspace: workspace, ArtifactDir: runDir, startedAt: time.Now().UTC()}
80 if owner != nil && !owner.setStack(s) {
81 _ = s.Close(false)
82 return s, fmt.Errorf("E2E watchdog fired before stack ownership registration")
83 }
84 if err := s.prepare(); err != nil {
85 s.recordRun("setup_failed", err)
86 return s, fmt.Errorf("prepare E2E stack: %w", err)
87 }
88 for attempt := 1; attempt <= 3; attempt++ {
89 err = s.startAttempt()
90 if err == nil {
91 s.metadataMu.Lock()
92 s.ready = true
93 s.metadataMu.Unlock()
94 s.recordRun("ready", nil)
95 return s, nil
96 }
97 cleanupErr := s.stopProcessSet()
98 retry, attemptErr := startupRetryDecision(attempt, err, cleanupErr)
99 if !retry {
100 s.recordRun("setup_failed", attemptErr)
101 return s, attemptErr
102 }
103 }
104 return s, err
105}
106
107func (s *Stack) prepare() error {
108 for _, dir := range []string{"bin", "config", "logs"} {
109 if err := os.MkdirAll(filepath.Join(s.Workspace, dir), 0o700); err != nil {
110 return err
111 }
112 }
113 platform, _ := artifactsFor(runtime.GOOS, runtime.GOARCH)
114 offline := os.Getenv("AUTH_UI_E2E_OFFLINE") == "1"
115 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
116 defer cancel()
117 for _, artifact := range []releaseArtifact{platform.Kratos, platform.Hydra} {
118 archivePath, err := ensureArchive(ctx, downloadClient(), cacheRoot(s.Repo), artifact, offline)
119 if err != nil {
120 return err
121 }
122 binary, err := extractBinary(archivePath, filepath.Join(s.Workspace, "bin"), artifact.Service)
123 if err != nil {
124 return err
125 }
126 if err := verifyBinaryVersion(binary, artifact.Version); err != nil {
127 return err
128 }
129 }
130 ctx, cancel = context.WithTimeout(context.Background(), 2*time.Minute)
131 defer cancel()
132 cmd := exec.CommandContext(ctx, "go", "build", "-o", filepath.Join(s.Workspace, "bin", "auth-ui"), ".")
133 cmd.Dir = s.Repo
134 output, err := cmd.CombinedOutput()
135 if ctx.Err() != nil {
136 return fmt.Errorf("build auth-ui: %w", ctx.Err())
137 }
138 if err != nil {
139 return fmt.Errorf("build auth-ui: %w: %s", err, boundedText(output, 8192))
140 }
141 return nil
142}
143
144func (s *Stack) startAttempt() error {
145 reservations, ports, err := reservePorts(7)
146 if err != nil {
147 return err
148 }
149 defer func() {
150 for _, reservation := range reservations {
151 _ = reservation.Close()
152 }
153 }()
154 s.metadataMu.Lock()
155 s.Ports = stackPorts{ports[0], ports[1], ports[2], ports[3], ports[4], ports[5], ports[6]}
156 s.KratosURL = loopbackURL(s.Ports.KratosPublic)
157 s.KratosAdmin = loopbackURL(s.Ports.KratosAdmin)
158 s.HydraURL = loopbackURL(s.Ports.HydraPublic)
159 s.HydraAdmin = loopbackURL(s.Ports.HydraAdmin)
160 s.UIURL = loopbackURL(s.Ports.UI)
161 s.APIURL = loopbackURL(s.Ports.API)
162 s.metadataMu.Unlock()
163 cookie, err := randomSecret(16)
164 if err != nil {
165 return err
166 }
167 cipher, err := randomSecret(16)
168 if err != nil {
169 return err
170 }
171 hydra, err := randomSecret(32)
172 if err != nil {
173 return err
174 }
175 s.metadataMu.Lock()
176 s.redactions = []string{cookie, cipher, hydra}
177 s.metadataMu.Unlock()
178 values := fixtureValues{
179 KratosPublicURL: s.KratosURL, KratosAdminURL: s.KratosAdmin,
180 KratosPublicPort: s.Ports.KratosPublic, KratosAdminPort: s.Ports.KratosAdmin,
181 HydraPublicURL: s.HydraURL, HydraAdminURL: s.HydraAdmin,
182 HydraPublicPort: s.Ports.HydraPublic, HydraAdminPort: s.Ports.HydraAdmin,
183 UIURL: s.UIURL, SMTPPort: s.Ports.SMTP,
184 CookieSecret: cookie, CipherSecret: cipher, HydraSecret: hydra,
185 }
186 if err := renderFixtures(s.Repo, filepath.Join(s.Workspace, "config"), values); err != nil {
187 return err
188 }
189 _ = reservations[6].Close() // No courier is started; only a valid unused SMTP address is needed.
190
191 _ = reservations[0].Close()
192 _ = reservations[1].Close()
193 process, err := s.startOwnedProcess(&s.Kratos, "kratos", s.logPath("kratos"), s.Workspace, s.binary("kratos"), "serve", "-c", s.config("kratos.yml"), "--dev", "--sqa-opt-out")
194 if err != nil {
195 return err
196 }
197 ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
198 err = waitHTTPReady(ctx, process, s.KratosURL+"/health/ready", s.KratosAdmin+"/health/ready")
199 cancel()
200 if err != nil {
201 return err
202 }
203
204 _ = reservations[2].Close()
205 _ = reservations[3].Close()
206 process, err = s.startOwnedProcess(&s.Hydra, "hydra", s.logPath("hydra"), s.Workspace, s.binary("hydra"), "serve", "-c", s.config("hydra.yml"), "all", "--dev", "--sqa-opt-out")
207 if err != nil {
208 return err
209 }
210 ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
211 err = waitHTTPReady(ctx, process, s.HydraURL+"/health/ready", s.HydraAdmin+"/health/ready")
212 cancel()
213 if err != nil {
214 return err
215 }
216
217 args := []string{"-port", strconv.Itoa(s.Ports.UI), "-api-port", strconv.Itoa(s.Ports.API), "-kratos", s.KratosURL, "-kratos-api", s.KratosAdmin, "-hydra", "127.0.0.1:" + strconv.Itoa(s.Ports.HydraAdmin), "-enable-registration=true", "-email-domain=example.test"}
218 _ = reservations[4].Close()
219 _ = reservations[5].Close()
220 process, err = s.startOwnedProcess(&s.AuthUI, "auth-ui", s.logPath("auth-ui"), s.Repo, s.binary("auth-ui"), args...)
221 if err != nil {
222 return err
223 }
224 ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
225 err = waitTCPReady(ctx, process,
226 "127.0.0.1:"+strconv.Itoa(s.Ports.UI),
227 "127.0.0.1:"+strconv.Itoa(s.Ports.API),
228 )
229 cancel()
230 return err
231}
232
233func reservePorts(count int) ([]net.Listener, []int, error) {
234 listeners := make([]net.Listener, 0, count)
235 ports := make([]int, 0, count)
236 for len(ports) < count {
237 listener, err := net.Listen("tcp", "127.0.0.1:0")
238 if err != nil {
239 for _, reserved := range listeners {
240 _ = reserved.Close()
241 }
242 return nil, nil, err
243 }
244 listeners = append(listeners, listener)
245 ports = append(ports, listener.Addr().(*net.TCPAddr).Port)
246 }
247 return listeners, ports, nil
248}
249
250func loopbackURL(port int) string { return "http://127.0.0.1:" + strconv.Itoa(port) }
251func (s *Stack) binary(name string) string { return filepath.Join(s.Workspace, "bin", name) }
252func (s *Stack) config(name string) string { return filepath.Join(s.Workspace, "config", name) }
253func (s *Stack) logPath(name string) string { return filepath.Join(s.Workspace, "logs", name+".log") }
254
255func isBindConflict(err error) bool {
256 text := strings.ToLower(err.Error())
257 return strings.Contains(text, "address already in use") || strings.Contains(text, "address in use")
258}
259
260func startupRetryDecision(attempt int, startupErr, cleanupErr error) (bool, error) {
261 if cleanupErr != nil {
262 return false, fmt.Errorf("startup attempt %d failed: %v; retry cleanup failed: %w", attempt, startupErr, cleanupErr)
263 }
264 if !isBindConflict(startupErr) || attempt >= 3 {
265 return false, startupErr
266 }
267 return true, nil
268}
269
270func (s *Stack) startOwnedProcess(slot **supervisedProcess, name, logPath, dir, executable string, args ...string) (*supervisedProcess, error) {
271 // Cleanup and process creation share this lock from the terminal-state check
272 // through cmd.Start and slot publication. StopServices can therefore either
273 // prevent the spawn or observe the complete ownership; it cannot snapshot a
274 // nil slot while a process is being created.
275 s.ownershipMu.Lock()
276 defer s.ownershipMu.Unlock()
277 if s.cleanupStarted {
278 return nil, fmt.Errorf("E2E cleanup started before launching %s", name)
279 }
280 start := s.startProcessOp
281 if start == nil {
282 start = startProcess
283 }
284 process, err := start(name, logPath, dir, executable, args...)
285 if err != nil {
286 return process, err
287 }
288 if process == nil {
289 return nil, fmt.Errorf("started %s process ownership is unavailable", name)
290 }
291 process.redactions = append([]string(nil), s.redactions...)
292 *slot = process
293 return process, nil
294}
295
296func (s *Stack) ownedProcessGroups() []int {
297 if s == nil {
298 return nil
299 }
300 s.ownershipMu.Lock()
301 defer s.ownershipMu.Unlock()
302 return s.ownedProcessGroupsLocked()
303}
304
305func (s *Stack) emergencyProcessGroups() []int {
306 if s == nil || !s.ownershipMu.TryLock() {
307 // Descendant discovery remains available when an OS start is holding the
308 // ownership lock, so emergency exit must never block on this mutex.
309 return nil
310 }
311 defer s.ownershipMu.Unlock()
312 return s.ownedProcessGroupsLocked()
313}
314
315func (s *Stack) ownedProcessGroupsLocked() []int {
316 var groups []int
317 for _, process := range []*supervisedProcess{s.AuthUI, s.Hydra, s.Kratos} {
318 if process != nil && process.pgid > 0 {
319 groups = append(groups, process.pgid)
320 }
321 }
322 return groups
323}
324
325func (s *Stack) stopOwnedProcess(process *supervisedProcess, timeout time.Duration) error {
326 if s.stopProcessOp != nil {
327 return s.stopProcessOp(process, timeout)
328 }
329 return process.stopAndWait(timeout)
330}
331
332func (s *Stack) stopProcessSet() error {
333 s.ownershipMu.Lock()
334 processes := []*supervisedProcess{s.AuthUI, s.Hydra, s.Kratos}
335 s.ownershipMu.Unlock()
336 var errs []string
337 for _, process := range processes {
338 if process == nil {
339 continue
340 }
341 stopErr := s.stopOwnedProcess(process, 5*time.Second)
342 if stopErr != nil {
343 errs = append(errs, stopErr.Error())
344 }
345 // Do not discard ownership merely because cleanup returned. Retain the
346 // process until its complete owned group is confirmed absent.
347 if ownedProcessGroupAlive(process.pgid) {
348 if stopErr == nil {
349 errs = append(errs, fmt.Sprintf("%s process group %d survived cleanup", process.name, process.pgid))
350 }
351 } else {
352 s.ownershipMu.Lock()
353 if s.AuthUI == process {
354 s.AuthUI = nil
355 }
356 if s.Hydra == process {
357 s.Hydra = nil
358 }
359 if s.Kratos == process {
360 s.Kratos = nil
361 }
362 s.ownershipMu.Unlock()
363 }
364 }
365 if len(errs) > 0 {
366 return fmt.Errorf("stop service processes: %s", strings.Join(errs, "; "))
367 }
368 return nil
369}
370
371func (s *Stack) StopServices() error {
372 if s == nil {
373 return nil
374 }
375 s.ownershipMu.Lock()
376 s.cleanupStarted = true
377 s.ownershipMu.Unlock()
378 s.stopOnce.Do(func() { s.stopErr = s.stopProcessSet() })
379 return s.stopErr
380}
381
382func (s *Stack) Close(success bool) error {
383 if s == nil {
384 return nil
385 }
386 stopErr := s.StopServices()
387 finalizeErr := s.Finalize(success)
388 if stopErr != nil && finalizeErr != nil {
389 return fmt.Errorf("%v; %w", stopErr, finalizeErr)
390 }
391 if stopErr != nil {
392 return stopErr
393 }
394 return finalizeErr
395}
396
397func (s *Stack) Finalize(success bool) error {
398 s.metadataMu.Lock()
399 ready, lastStatus := s.ready, s.lastStatus
400 redactions := append([]string(nil), s.redactions...)
401 s.metadataMu.Unlock()
402 if ready {
403 if success {
404 s.recordRun("passed", nil)
405 } else if lastStatus == "ready" {
406 s.recordRun("failed", nil)
407 }
408 }
409 var errs []string
410 services := filepath.Join(s.ArtifactDir, "services")
411 if err := os.MkdirAll(services, 0o700); err != nil {
412 errs = append(errs, err.Error())
413 }
414 for _, name := range []string{"auth-ui", "hydra", "kratos"} {
415 if err := copyFileBounded(s.logPath(name), filepath.Join(services, name+".log"), 1<<20, redactions); err != nil && !errors.Is(err, os.ErrNotExist) {
416 errs = append(errs, err.Error())
417 }
418 }
419 keep := !success || os.Getenv("AUTH_UI_E2E_KEEP_TMP") == "1"
420 if keep {
421 fmt.Fprintf(os.Stderr, "E2E workspace retained at %s\nE2E artifacts retained at %s\n", s.Workspace, s.ArtifactDir)
422 } else if err := os.RemoveAll(s.Workspace); err != nil {
423 errs = append(errs, err.Error())
424 }
425 if len(errs) > 0 {
426 return fmt.Errorf("stack cleanup: %s", strings.Join(errs, "; "))
427 }
428 return nil
429}
430
431func createRunArtifactDir(repo string) (string, error) {
432 root := os.Getenv("AUTH_UI_E2E_ARTIFACT_DIR")
433 if root == "" {
434 root = filepath.Join(repo, "e2e", "artifacts")
435 }
436 if err := os.MkdirAll(root, 0o700); err != nil {
437 return "", err
438 }
439 random, err := randomSecret(4)
440 if err != nil {
441 return "", err
442 }
443 name := fmt.Sprintf("%s-%d-%s", time.Now().UTC().Format("20060102T150405.000000000Z"), os.Getpid(), random)
444 path := filepath.Join(root, name)
445 return path, os.Mkdir(path, 0o700)
446}
447
448func (s *Stack) recordRun(status string, setupErr error) {
449 s.metadataMu.Lock()
450 defer s.metadataMu.Unlock()
451 if s.lastStatus == "watchdog_timeout" && status != "watchdog_timeout" {
452 return
453 }
454 s.lastStatus = status
455 metadata := map[string]any{"started_at": s.startedAt, "updated_at": time.Now().UTC(), "status": status, "workspace": s.Workspace, "platform": runtime.GOOS + "/" + runtime.GOARCH, "kratos_version": kratosVersion, "hydra_version": hydraVersion, "playwright_binding_version": playwrightVersion, "playwright_cli_version": playwrightCLIVersion, "chromium_revision": chromiumRevision, "chromium_version": chromiumVersion, "ffmpeg_revision": ffmpegRevision, "ports": s.Ports}
456 if setupErr != nil {
457 metadata["error"] = redactDiagnostic(setupErr.Error())
458 }
459 data, err := json.MarshalIndent(metadata, "", " ")
460 if err == nil {
461 _ = os.WriteFile(filepath.Join(s.ArtifactDir, "run.json"), append(data, '\n'), 0o600)
462 }
463}
464
465func redactDiagnostic(value string) string {
466 // Text logs intentionally omit generated secrets. Strip query strings from URLs
467 // as a final guard against opaque flow/challenge/token values.
468 words := strings.Fields(value)
469 for i, word := range words {
470 if strings.Contains(word, "http://") && strings.Contains(word, "?") {
471 words[i] = strings.SplitN(word, "?", 2)[0]
472 }
473 }
474 return strings.Join(words, " ")
475}
476
477func copyFileBounded(source, destination string, limit int64, redactions []string) error {
478 f, err := os.Open(source)
479 if err != nil {
480 return err
481 }
482 defer f.Close()
483 info, err := f.Stat()
484 if err != nil {
485 return err
486 }
487 if info.Size() > limit {
488 if _, err := f.Seek(info.Size()-limit, io.SeekStart); err != nil {
489 return err
490 }
491 }
492 data, err := io.ReadAll(io.LimitReader(f, limit))
493 if err != nil {
494 return err
495 }
496 text := string(data)
497 for _, secret := range redactions {
498 if secret != "" {
499 text = strings.ReplaceAll(text, secret, "[REDACTED]")
500 }
501 }
502 return os.WriteFile(destination, []byte(text), 0o600)
503}
504
505func (s *Stack) healthy(endpoint string) error {
506 client := &http.Client{Timeout: 2 * time.Second}
507 defer client.CloseIdleConnections()
508 resp, err := client.Get(endpoint)
509 if err != nil {
510 return err
511 }
512 defer resp.Body.Close()
513 if resp.StatusCode < 200 || resp.StatusCode >= 300 {
514 return fmt.Errorf("health status %s", resp.Status)
515 }
516 return nil
517}