From d088cd228d1b74ce9bb152c3e6e37cd2ff89d380 Mon Sep 17 00:00:00 2001 From: Greg Neiheisel <1036482+schnie@users.noreply.github.com> Date: Thu, 31 Oct 2024 20:27:44 -0400 Subject: [PATCH] Replaces webserver healthcheck with one that is runtime agnostic --- airflow/docker.go | 64 ++--------- airflow/docker_test.go | 193 ++++----------------------------- airflow/health_check.go | 71 ++++++++++++ airflow/include/composeyml.yml | 6 - airflow/suite_test.go | 4 +- 5 files changed, 103 insertions(+), 235 deletions(-) create mode 100644 airflow/health_check.go diff --git a/airflow/docker.go b/airflow/docker.go index 1c590e8db..a4b22af0f 100644 --- a/airflow/docker.go +++ b/airflow/docker.go @@ -4,7 +4,6 @@ import ( "bufio" "bytes" "context" - "encoding/json" "fmt" "io/fs" "os" @@ -82,7 +81,6 @@ const ( var ( errNoFile = errors.New("file specified does not exist") errSettingsPath = "error looking for settings.yaml" - errComposeProjectRunning = errors.New("project is up and running") errCustomImageDoesNotExist = errors.New("The custom image provided either does not exist or Docker is unable to connect to the repository") initSettings = settings.ConfigSettings @@ -301,10 +299,19 @@ func (d *DockerCompose) Start(imageName, settingsFile, composeFile, buildSecretS startupTimeout = 5 * time.Minute } - err = checkWebserverHealth(settingsFile, envConns, project, d.composeService, airflowDockerVersion, noBrowser, startupTimeout) + // Check the health of the webserver, up to the timeout. + // If we fail to get a 200 status code, we'll return an error message. + err = checkWebserverHealth(startupTimeout) if err != nil { return err } + + // If we've successfully gotten a healthcheck response, print the status. + err = printStatus(settingsFile, envConns, project, d.composeService, airflowDockerVersion, noBrowser) + if err != nil { + return err + } + return nil } @@ -1349,49 +1356,6 @@ var createDockerProject = func(projectName, airflowHome, envFile, buildImage, se return project, err } -var checkWebserverHealth = func(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool, timeout time.Duration) error { - if config.CFG.DockerCommand.GetString() == podman { - err := printStatus(settingsFile, envConns, project, composeService, airflowDockerVersion, noBrowser) - if err != nil { - if !errors.Is(err, errComposeProjectRunning) { - return err - } - } - } else { - ctx, cancel := context.WithTimeout(context.Background(), timeout) - defer cancel() - // check if webserver is healthy for user - err := composeService.Events(ctx, project.Name, api.EventsOptions{ - Services: []string{WebserverDockerContainerName}, Consumer: func(event api.Event) error { - marshal, err := json.Marshal(map[string]interface{}{ - "action": event.Status, - }) - if err != nil { - return err - } - if string(marshal) == `{"action":"health_status: healthy"}` { - err := printStatus(settingsFile, envConns, project, composeService, airflowDockerVersion, noBrowser) - if err != nil { - return err - } - } - - return nil - }, - }) - if err != nil { - if errors.Is(err, context.DeadlineExceeded) { - fmt.Printf("\n") - return fmt.Errorf("there might be a problem with your project starting up. The webserver health check timed out after %s but your project will continue trying to start. Run 'astro dev logs --webserver | --scheduler' for details.\n\nTry again or use the --wait flag to increase the time out", timeout) - } - if !errors.Is(err, errComposeProjectRunning) { - return err - } - } - } - return nil -} - func printStatus(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool) error { psInfo, err := composeService.Ps(context.Background(), project.Name, api.PsOptions{ All: true, @@ -1416,11 +1380,7 @@ func printStatus(settingsFile string, envConns map[string]astrocore.EnvironmentO } } } - if config.CFG.DockerCommand.GetString() == podman { - fmt.Println("\nComponents will be available soon. If they are not running in the next few minutes, run 'astro dev logs --webserver | --scheduler' for details.") - } else { - fmt.Println("\nProject is running! All components are now available.") - } + fmt.Println("\nProject is running! All components are now available.") parts := strings.Split(config.CFG.WebserverPort.GetString(), ":") webserverURL := "http://localhost:" + parts[len(parts)-1] fmt.Printf("\n"+composeLinkWebserverMsg+"\n", ansi.Bold(webserverURL)) @@ -1433,7 +1393,7 @@ func printStatus(settingsFile string, envConns map[string]astrocore.EnvironmentO fmt.Println("\nUnable to open the webserver URL, please visit the following link: " + webserverURL) } } - return errComposeProjectRunning + return nil } // CheckTriggererEnabled checks if the airflow triggerer component should be enabled. diff --git a/airflow/docker_test.go b/airflow/docker_test.go index 7ff1f306f..c2a018160 100644 --- a/airflow/docker_test.go +++ b/airflow/docker_test.go @@ -5,7 +5,6 @@ import ( "bytes" "context" _ "embed" - "fmt" "io" "net/http" "os" @@ -188,12 +187,6 @@ services: - airflow_logs:/usr/local/airflow/logs - healthcheck: - test: curl --fail http://webserver:8080/health || exit 1 - interval: 2s - retries: 15 - start_period: 5s - timeout: 60s ` @@ -305,12 +298,6 @@ services: - airflow_logs:/usr/local/airflow/logs - healthcheck: - test: curl --fail http://webserver:8080/health || exit 1 - interval: 2s - retries: 15 - start_period: 5s - timeout: 60s triggerer: @@ -390,10 +377,10 @@ func (s *Suite) TestDockerComposeStart() { imageHandler.On("TagLocalImage", mock.Anything).Return(nil).Once() composeMock := new(mocks.DockerComposeAPI) - composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Twice() + composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Times(4) composeMock.On("Up", mock.Anything, mock.Anything, api.UpOptions{Create: api.CreateOptions{}}).Return(nil).Twice() - checkWebserverHealth = func(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool, timeout time.Duration) error { + checkWebserverHealth = func(timeout time.Duration) error { return nil } @@ -418,7 +405,7 @@ func (s *Suite) TestDockerComposeStart() { imageHandler.On("ListLabels").Return(map[string]string{airflowVersionLabelName: airflowVersionLabel}, nil).Times(2) composeMock := new(mocks.DockerComposeAPI) - composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Once() + composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Times(2) composeMock.On("Up", mock.Anything, mock.Anything, api.UpOptions{Create: api.CreateOptions{}}).Return(nil).Once() mockIsM1 := func(myOS, myArch string) bool { @@ -426,7 +413,7 @@ func (s *Suite) TestDockerComposeStart() { } isM1 = mockIsM1 - checkWebserverHealth = func(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool, timeout time.Duration) error { + checkWebserverHealth = func(timeout time.Duration) error { s.Equal(defaultTimeOut, timeout) return nil } @@ -450,7 +437,7 @@ func (s *Suite) TestDockerComposeStart() { imageHandler.On("ListLabels").Return(map[string]string{airflowVersionLabelName: airflowVersionLabel}, nil).Times(2) composeMock := new(mocks.DockerComposeAPI) - composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Once() + composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Times(2) composeMock.On("Up", mock.Anything, mock.Anything, api.UpOptions{Create: api.CreateOptions{}}).Return(nil).Once() mockIsM1 := func(myOS, myArch string) bool { @@ -458,7 +445,7 @@ func (s *Suite) TestDockerComposeStart() { } isM1 = mockIsM1 - checkWebserverHealth = func(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool, timeout time.Duration) error { + checkWebserverHealth = func(timeout time.Duration) error { s.Equal(expectedTimeout, timeout) return nil } @@ -481,10 +468,10 @@ func (s *Suite) TestDockerComposeStart() { imageHandler.On("ListLabels").Return(map[string]string{airflowVersionLabelName: airflowVersionLabel}, nil).Times(2) composeMock := new(mocks.DockerComposeAPI) - composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Once() + composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Times(2) composeMock.On("Up", mock.Anything, mock.Anything, api.UpOptions{Create: api.CreateOptions{}}).Return(nil).Once() - checkWebserverHealth = func(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool, timeout time.Duration) error { + checkWebserverHealth = func(timeout time.Duration) error { s.Equal(userProvidedTimeOut, timeout) return nil } @@ -507,10 +494,10 @@ func (s *Suite) TestDockerComposeStart() { imageHandler.On("TagLocalImage", mock.Anything).Return(nil).Once() composeMock := new(mocks.DockerComposeAPI) - composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Twice() + composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Times(4) composeMock.On("Up", mock.Anything, mock.Anything, api.UpOptions{Create: api.CreateOptions{}}).Return(nil).Twice() - checkWebserverHealth = func(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool, timeout time.Duration) error { + checkWebserverHealth = func(timeout time.Duration) error { return nil } @@ -559,7 +546,7 @@ func (s *Suite) TestDockerComposeStart() { composeMock := new(mocks.DockerComposeAPI) composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Once() - checkWebserverHealth = func(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool, timeout time.Duration) error { + checkWebserverHealth = func(timeout time.Duration) error { return nil } @@ -582,7 +569,7 @@ func (s *Suite) TestDockerComposeStart() { composeMock := new(mocks.DockerComposeAPI) composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Once() - checkWebserverHealth = func(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool, timeout time.Duration) error { + checkWebserverHealth = func(timeout time.Duration) error { return nil } @@ -606,7 +593,7 @@ func (s *Suite) TestDockerComposeStart() { composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Once() composeMock.On("Up", mock.Anything, mock.Anything, api.UpOptions{Create: api.CreateOptions{}}).Return(errMockDocker).Once() - checkWebserverHealth = func(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool, timeout time.Duration) error { + checkWebserverHealth = func(timeout time.Duration) error { return nil } @@ -630,7 +617,7 @@ func (s *Suite) TestDockerComposeStart() { composeMock.On("Ps", mock.Anything, mockDockerCompose.projectName, api.PsOptions{All: true}).Return([]api.ContainerSummary{}, nil).Once() composeMock.On("Up", mock.Anything, mock.Anything, api.UpOptions{Create: api.CreateOptions{}}).Return(nil).Once() - checkWebserverHealth = func(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool, timeout time.Duration) error { + checkWebserverHealth = func(timeout time.Duration) error { return errMockDocker } @@ -1725,153 +1712,11 @@ func (s *Suite) TestDockerComposeRunDAG() { } func (s *Suite) TestCheckWebserverHealth() { - s.Run("success", func() { - settingsFile := "docker_test.go" // any file which exists - composeMock := new(mocks.DockerComposeAPI) - composeMock.On("Ps", mock.Anything, mock.AnythingOfType("string"), api.PsOptions{All: true}).Return([]api.ContainerSummary{{ID: "test-webserver-id", Name: fmt.Sprintf("test-%s", WebserverDockerContainerName), State: "running"}}, nil).Once() - mockEventsCall := composeMock.On("Events", mock.AnythingOfType("*context.timerCtx"), "test", mock.Anything) - mockEventsCall.RunFn = func(args mock.Arguments) { - consumer := args.Get(2).(api.EventsOptions).Consumer - err := consumer(api.Event{Status: "exec_create"}) - s.NoError(err) - err = consumer(api.Event{Status: "exec_start"}) - s.NoError(err) - err = consumer(api.Event{Status: "exec_die"}) - s.NoError(err) - err = consumer(api.Event{Status: "health_status: healthy"}) - s.ErrorIs(err, errComposeProjectRunning) - mockEventsCall.ReturnArguments = mock.Arguments{err} - } - - openURL = func(url string) error { - return nil - } - - initSettings = func(id, settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, version uint64, connections, variables, pools bool) error { - return nil - } - - r, w, _ := os.Pipe() - os.Stdout = w - - err := checkWebserverHealth(settingsFile, nil, &types.Project{Name: "test"}, composeMock, 2, false, 1*time.Second) - s.NoError(err) - - w.Close() - out, _ := io.ReadAll(r) - s.Contains(string(out), "Project is running! All components are now available.") - }) - - s.Run("success with podman", func() { - settingsFile := "docker_test.go" // any file which exists - composeMock := new(mocks.DockerComposeAPI) - composeMock.On("Ps", mock.Anything, mock.AnythingOfType("string"), api.PsOptions{All: true}).Return([]api.ContainerSummary{{ID: "test-webserver-id", Name: fmt.Sprintf("test-%s", WebserverDockerContainerName), State: "running"}}, nil).Once() - - openURL = func(url string) error { - return nil - } - - initSettings = func(id, settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, version uint64, connections, variables, pools bool) error { - return nil - } - r, w, _ := os.Pipe() - os.Stdout = w - - // set config to podman - config.CFG.DockerCommand.SetHomeString("podman") - err := checkWebserverHealth(settingsFile, nil, &types.Project{Name: "test"}, composeMock, 2, false, 1*time.Second) - s.NoError(err) - - w.Close() - out, _ := io.ReadAll(r) - s.Contains(string(out), "Components will be available soon.") - }) - - // set config to docker - config.CFG.DockerCommand.SetHomeString("docker") - - s.Run("compose ps failure", func() { - settingsFile := "./testfiles/test_dag_inegrity_file.py" // any file which exists - composeMock := new(mocks.DockerComposeAPI) - composeMock.On("Ps", mock.Anything, mock.AnythingOfType("string"), api.PsOptions{All: true}).Return([]api.ContainerSummary{}, errMockDocker).Once() - mockEventsCall := composeMock.On("Events", mock.AnythingOfType("*context.timerCtx"), "test", mock.Anything) - mockEventsCall.RunFn = func(args mock.Arguments) { - consumer := args.Get(2).(api.EventsOptions).Consumer - err := consumer(api.Event{Status: "exec_create"}) - s.NoError(err) - err = consumer(api.Event{Status: "exec_start"}) - s.NoError(err) - err = consumer(api.Event{Status: "exec_die"}) - s.NoError(err) - err = consumer(api.Event{Status: "health_status: healthy"}) - s.ErrorIs(err, errMockDocker) - mockEventsCall.ReturnArguments = mock.Arguments{err} - } - - openURL = func(url string) error { - return nil - } - - err := checkWebserverHealth(settingsFile, nil, &types.Project{Name: "test"}, composeMock, 2, false, 1*time.Second) - s.ErrorIs(err, errMockDocker) - }) - - s.Run("timeout waiting for webserver to get to healthy with short timeout", func() { - settingsFile := "./testfiles/test_dag_inegrity_file.py" // any file which exists - composeMock := new(mocks.DockerComposeAPI) - composeMock.On("Ps", mock.Anything, mock.AnythingOfType("string"), api.PsOptions{All: true}).Return([]api.ContainerSummary{{ID: "test-webserver-id", Name: fmt.Sprintf("test-%s", WebserverDockerContainerName), State: "exec_die"}}, nil).Once() - mockEventsCall := composeMock.On("Events", mock.AnythingOfType("*context.timerCtx"), "test", mock.Anything) - mockEventsCall.RunFn = func(args mock.Arguments) { - consumer := args.Get(2).(api.EventsOptions).Consumer - err := consumer(api.Event{Status: "exec_create"}) - s.NoError(err) - err = consumer(api.Event{Status: "exec_start"}) - s.NoError(err) - err = consumer(api.Event{Status: "exec_die"}) - s.NoError(err) - err = context.DeadlineExceeded - mockEventsCall.ReturnArguments = mock.Arguments{err} - } - - openURL = func(url string) error { - return nil - } - mockIsM1 := func(myOS, myArch string) bool { - return false - } - isM1 = mockIsM1 - - err := checkWebserverHealth(settingsFile, nil, &types.Project{Name: "test"}, composeMock, 2, false, 1*time.Second) - s.ErrorContains(err, "The webserver health check timed out after 1s") - }) - s.Run("timeout waiting for webserver to get to healthy with long timeout", func() { - settingsFile := "./testfiles/test_dag_inegrity_file.py" // any file which exists - composeMock := new(mocks.DockerComposeAPI) - composeMock.On("Ps", mock.Anything, mock.AnythingOfType("string"), api.PsOptions{All: true}).Return([]api.ContainerSummary{{ID: "test-webserver-id", Name: fmt.Sprintf("test-%s", WebserverDockerContainerName), State: "exec_die"}}, nil).Once() - mockEventsCall := composeMock.On("Events", mock.AnythingOfType("*context.timerCtx"), "test", mock.Anything) - mockEventsCall.RunFn = func(args mock.Arguments) { - consumer := args.Get(2).(api.EventsOptions).Consumer - err := consumer(api.Event{Status: "exec_create"}) - s.NoError(err) - err = consumer(api.Event{Status: "exec_start"}) - s.NoError(err) - err = consumer(api.Event{Status: "exec_die"}) - s.NoError(err) - err = context.DeadlineExceeded - mockEventsCall.ReturnArguments = mock.Arguments{err} - } - - openURL = func(url string) error { - return nil - } - mockIsM1 := func(myOS, myArch string) bool { - return true - } - isM1 = mockIsM1 - - err := checkWebserverHealth(settingsFile, nil, &types.Project{Name: "test"}, composeMock, 2, false, 1*time.Second) - s.ErrorContains(err, "The webserver health check timed out after 1s") - }) + //s.Run("success", func() { + // err := checkWebserverHealth(1 * time.Second) + // s.NoError(err) + // //s.Contains(string(out), "Project is running! All components are now available.") + //}) } var errExecMock = errors.New("docker is not running") diff --git a/airflow/health_check.go b/airflow/health_check.go new file mode 100644 index 000000000..22048b6c1 --- /dev/null +++ b/airflow/health_check.go @@ -0,0 +1,71 @@ +package airflow + +import ( + "context" + "fmt" + "net/http" + "time" +) + +// checkWebserverHealth is a container runtime agnostic way to check if +// the webserver is healthy. +var checkWebserverHealth = func(timeout time.Duration) error { + // Airflow webserver should be hosted at localhost + // from the perspective of the CLI running on the host machine. + url := "http://localhost:8080/health" + + // Create a cancellable context with the specified + // timeout for the healthcheck. + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + // Create an HTTP client for healthcheck requests. + client := &http.Client{ + Timeout: 5 * time.Second, + } + + // This ticker represents the interval of our healthcheck. + ticker := time.NewTicker(1 * time.Second) + defer ticker.Stop() + + // Run a loop that we'll exit when we get a 200 status code, + // or when the context is cancelled due to reaching the specified timeout. + for { + select { + // This means the healthcheck has reached its deadline. + // We return an error message to the user. + case <-ctx.Done(): + return fmt.Errorf("There might be a problem with your project starting up. "+ + "The webserver health check timed out after %s but your project will continue trying to start. "+ + "Run 'astro dev logs --webserver | --scheduler' for details.\n"+ + "Try again or use the --wait flag to increase the time out", timeout) + // This fires on every tick of our timer to run the healthcheck. + // We return successfully from this function when we get a 200 status code. + case <-ticker.C: + statusCode, _ := healthCheck(ctx, client, url) + if statusCode == http.StatusOK { + return nil + } + } + } +} + +// healthCheck is a helper function to execute an HTTP request +// and return the status code or an error. +func healthCheck(ctx context.Context, client *http.Client, url string) (int, error) { + // Create a new HTTP GET request with the specified context. + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return 0, err + } + + // Make the request to the healthcheck endpoint. + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + // Return the status code integer. + return resp.StatusCode, nil +} diff --git a/airflow/include/composeyml.yml b/airflow/include/composeyml.yml index ff89f5666..1059b6b2a 100644 --- a/airflow/include/composeyml.yml +++ b/airflow/include/composeyml.yml @@ -109,12 +109,6 @@ services: {{if .DuplicateImageVolumes}} - airflow_logs:/usr/local/airflow/logs {{end}} - healthcheck: - test: curl --fail http://webserver:8080/health || exit 1 - interval: 2s - retries: 15 - start_period: 5s - timeout: 60s {{ .AirflowEnvFile }} {{if .TriggererEnabled}} triggerer: diff --git a/airflow/suite_test.go b/airflow/suite_test.go index 5c712f55a..b410f2aa6 100644 --- a/airflow/suite_test.go +++ b/airflow/suite_test.go @@ -8,8 +8,6 @@ import ( astrocore "github.com/astronomer/astro-cli/astro-client-core" testUtil "github.com/astronomer/astro-cli/pkg/testing" - "github.com/compose-spec/compose-go/types" - "github.com/docker/compose/v2/pkg/api" "github.com/docker/docker/client" "github.com/stretchr/testify/suite" ) @@ -19,7 +17,7 @@ type Suite struct { origCmdExec func(cmd string, stdout, stderr io.Writer, args ...string) error origGetDockerClient func() (client.APIClient, error) origInitSettings func(id, settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, version uint64, connections, variables, pools bool) error - origCheckWebserverHealth func(settingsFile string, envConns map[string]astrocore.EnvironmentObjectConnection, project *types.Project, composeService api.Service, airflowDockerVersion uint64, noBrowser bool, timeout time.Duration) error + origCheckWebserverHealth func(timeout time.Duration) error origStdout *os.File }