| gio | b7df27f | 2026-07-28 10:36:17 +0400 | [diff] [blame^] | 1 | package e2e |
| 2 | |
| 3 | import ( |
| 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 | |
| 21 | type 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 | |
| 31 | type 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 | |
| 58 | func startStack() (*Stack, error) { |
| 59 | return startStackOwned(nil) |
| 60 | } |
| 61 | |
| 62 | func 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 | |
| 107 | func (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 | |
| 144 | func (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 | |
| 233 | func 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 | |
| 250 | func loopbackURL(port int) string { return "http://127.0.0.1:" + strconv.Itoa(port) } |
| 251 | func (s *Stack) binary(name string) string { return filepath.Join(s.Workspace, "bin", name) } |
| 252 | func (s *Stack) config(name string) string { return filepath.Join(s.Workspace, "config", name) } |
| 253 | func (s *Stack) logPath(name string) string { return filepath.Join(s.Workspace, "logs", name+".log") } |
| 254 | |
| 255 | func 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 | |
| 260 | func 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 | |
| 270 | func (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 | |
| 296 | func (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 | |
| 305 | func (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 | |
| 315 | func (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 | |
| 325 | func (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 | |
| 332 | func (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 | |
| 371 | func (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 | |
| 382 | func (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 | |
| 397 | func (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 | |
| 431 | func 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 | |
| 448 | func (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 | |
| 465 | func 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 | |
| 477 | func 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 | |
| 505 | func (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 | } |