package infra import ( "bytes" "context" "fmt" "os/exec" "strings" "time" ) const ( defaultTimeoutS = 60 defaultSeverity = "critical" maxStdoutBytes = 4096 healthIntervalMs = 500 ) // E2ERunChecks executes a list of E2ECheck declarations in order and returns // one CheckResult per check. The slice is always len(checks) long; individual // check failures do not abort the run. // // For each check: // - If Cmd is non-empty, it is executed via "bash -c". Commands ending with // "&" are launched in background; the function does not wait for exit and // proceeds to Health (if any) or records an immediate pass. // - If Health is non-empty (after any Cmd), HealthCheckHTTP polls the URL // until status 200 or timeout. // - ExpectExit (default 0) and ExpectStdoutContains are evaluated after Cmd. // - Ref is not yet implemented: the check is recorded as skip with a // descriptive error. // - Checks with no Cmd, Health, or Ref are skipped. // // workDir sets the working directory for subprocesses. Pass "" to inherit the // current process directory. // // Returns an error only for setup failures (e.g. bad workDir), not for // individual check failures. func E2ERunChecks(checks []E2ECheck, workDir string) ([]CheckResult, error) { results := make([]CheckResult, 0, len(checks)) for _, ch := range checks { result := runSingleCheck(ch, workDir) results = append(results, result) } return results, nil } func runSingleCheck(ch E2ECheck, workDir string) CheckResult { sev := ch.Severity if sev == "" { sev = defaultSeverity } timeoutS := ch.TimeoutS if timeoutS <= 0 { timeoutS = defaultTimeoutS } base := CheckResult{ ID: ch.ID, Severity: sev, } // Skip: nothing to do. if ch.Cmd == "" && ch.Ref == "" && ch.Health == "" { base.Status = "skip" return base } // Ref: not implemented yet. if ch.Ref != "" && ch.Cmd == "" && ch.Health == "" { base.Status = "skip" base.Error = "ref handler not implemented" return base } start := time.Now() // Run Cmd if present. if ch.Cmd != "" { background := strings.HasSuffix(strings.TrimSpace(ch.Cmd), "&") if background { // Launch background process, do not wait. bgCmd := exec.Command("bash", "-c", ch.Cmd) if workDir != "" { bgCmd.Dir = workDir } if err := bgCmd.Start(); err != nil { base.DurationMs = time.Since(start).Milliseconds() base.Status = "fail" base.Error = fmt.Sprintf("background start failed: %v", err) return base } // Do not wait; proceed to Health or pass. } else { ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutS)*time.Second) defer cancel() fgCmd := exec.CommandContext(ctx, "bash", "-c", ch.Cmd) if workDir != "" { fgCmd.Dir = workDir } var stdout, stderr bytes.Buffer fgCmd.Stdout = &stdout fgCmd.Stderr = &stderr runErr := fgCmd.Run() base.DurationMs = time.Since(start).Milliseconds() stdoutStr := truncate(stdout.String(), maxStdoutBytes) stderrStr := truncate(stderr.String(), maxStdoutBytes) base.Stdout = stdoutStr base.Stderr = stderrStr // Exit code. exitCode := 0 if fgCmd.ProcessState != nil { exitCode = fgCmd.ProcessState.ExitCode() } base.ExitCode = exitCode if ctx.Err() == context.DeadlineExceeded { base.Status = "fail" base.Error = fmt.Sprintf("command timed out after %ds", timeoutS) return base } expectedExit := 0 if ch.ExpectExit != nil { expectedExit = *ch.ExpectExit } if exitCode != expectedExit { base.Status = "fail" if runErr != nil { base.Error = runErr.Error() } else { base.Error = fmt.Sprintf("exit code %d, expected %d", exitCode, expectedExit) } return base } if ch.ExpectStdoutContains != "" && !strings.Contains(stdoutStr, ch.ExpectStdoutContains) { base.Status = "fail" base.Error = fmt.Sprintf("stdout does not contain %q", ch.ExpectStdoutContains) return base } } } // Health check (after Cmd or standalone). if ch.Health != "" { if err := HealthCheckHTTP(ch.Health, timeoutS, healthIntervalMs); err != nil { base.DurationMs = time.Since(start).Milliseconds() base.Status = "fail" base.Error = fmt.Sprintf("health check failed: %v", err) return base } } base.DurationMs = time.Since(start).Milliseconds() base.Status = "pass" return base } func truncate(s string, max int) string { if len(s) <= max { return s } return s[:max] }