Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds Container Runtime Binary Autodetection #1738

Merged
merged 6 commits into from
Nov 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 122 additions & 0 deletions airflow/container_runtime.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
package airflow

import (
"os"
"path/filepath"
"strings"
"sync"

"github.com/astronomer/astro-cli/config"
"github.com/astronomer/astro-cli/pkg/fileutil"
"github.com/astronomer/astro-cli/pkg/util"
"github.com/pkg/errors"
)

// FileChecker interface defines a method to check if a file exists.
// This is here mostly for testing purposes. This allows us to mock
// around actually checking for binaries on a live system as that
// would create inconsistencies across developer machines when
// working with the unit tests.
type FileChecker interface {
Exists(path string) bool
}

// OSFileChecker is a concrete implementation of FileChecker.
type OSFileChecker struct{}

// Exists checks if the file exists in the file system.
func (f OSFileChecker) Exists(path string) bool {
exists, _ := fileutil.Exists(path, nil)
return exists
}

// FindBinary searches for the specified binary name in the provided $PATH directories,
// using the provided FileChecker. It searches each specific path within the systems
// $PATH environment variable for the binary concurrently and returns a boolean result
// indicating if the binary was found or not.
func FindBinary(pathEnv, binaryName string, checker FileChecker) bool {
// Split the $PATH variable into it's individual paths,
// using the OS specific path separator character.
paths := strings.Split(pathEnv, string(os.PathListSeparator))

// Although programs can be called without the .exe extension,
// we need to append it here when searching the file system.
if isWindows() {
binaryName += ".exe"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have we tested the changes in this branch on a Windows machine?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hah, was just doing that this morning. Just posted some screenshots on the PR description.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels risky that we rely on Docker Desktop and Podman continuing to call their executable "docker.exe" and "podman.exe" but I guess the CLI already does when building the image?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yea that's true. If something breaks in the wild, I think we'll always have our config file to manually override the binary name, until we have a chance to cut an updated release with a new binary name in the search array here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just curious, if we would run into this code path if running in WSL2 on Windows, I doubt it, but just in case you have already tried out that option

Copy link
Member Author

@schnie schnie Nov 1, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We run into this code path when running astro on Windows, with podman managing the WSL2 VM. The podman binary is podman.exe in Windows. If we were running purely within WSL2 on something like an Ubuntu machine where astro and podman were both installed in Linux, then this path shouldn't be hit. But, I wasn't actually able to get that scenario to work at all, and I don't believe we have that documented as an option yet. Do we have customers operating purely within WSL2?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We may have customers operating purely within WSl2 on the Software side - it's hard to know. I also would expect IsWindows to be false under WSL2.

}

// Create a wait group to allow all binary search goroutines
// to finish before we return from this function.
var wg sync.WaitGroup
found := make(chan string, 1)

// Search each individual path concurrently.
for _, dir := range paths {
wg.Add(1)
go func(dir string) {
defer wg.Done()
binaryPath := filepath.Join(dir, binaryName)
if exists := checker.Exists(binaryPath); exists {
select {
// If the channel is open, send the path in, indicating a found binary.
case found <- binaryPath:
// If another goroutine has already sent a path into the channel
// we'd be blocked. The default clause will run instead and effectively
// skip sending the path into the channel, doing nothing, but allowing the
// goroutine to complete without blocking.
default:
}
}
}(dir)
}

// Wait for the concurrent checks to finish and close the channel.
wg.Wait()
close(found)

// If we found the binary in one of the paths, return true.
if _, ok := <-found; ok {
return true
}

// Otherwise the binary was not found, return false.
return false
}

// GetContainerRuntimeBinary will return the manually configured container runtime,
// or search the $PATH for an acceptable runtime binary to use. This allows users
// to use alternative container runtimes without needing to explicitly configure it.
// Manual configuration should only be needed when both runtimes are installed and
// need to override to use one or the other and not use the auto-detection.
func GetContainerRuntimeBinary() (string, error) {
// Supported container runtime binaries
binaries := []string{dockerCmd, podmanCmd}

// If the binary is manually configured to an acceptable runtime, return it directly.
// If a manual configuration exists, but it's not an appropriate runtime, we'll still
// search the $PATH for an acceptable one before completely bailing out.
configuredBinary := config.CFG.DockerCommand.GetString()
if util.Contains(binaries, configuredBinary) {
return configuredBinary, nil
}

// Get the $PATH environment variable.
pathEnv := os.Getenv("PATH")
for _, binary := range binaries {
if found := FindBinary(pathEnv, binary, OSFileChecker{}); found {
return binary, nil
}
}

// If we made it here, no runtime was found, so we show a helpful error message
// and halt the command execution.
return "", errors.New("Failed to find a container runtime. " +
"See the Astro CLI prerequisites for more information. " +
"https://www.astronomer.io/docs/astro/cli/install-cli")
}

// isWindows is a utility function to determine if the CLI host machine
// is running on Microsoft Windows OS.
func isWindows() bool {
return strings.Contains(strings.ToLower(os.Getenv("OS")), "windows")
}
91 changes: 91 additions & 0 deletions airflow/container_runtime_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package airflow

// MockFileChecker is a mock implementation of FileChecker for tests.
type MockFileChecker struct {
existingFiles map[string]bool
}

// Exists is just a mock for os.Stat(). In our test implementation, we just check
// if the file exists in the list of mocked files for a given test.
func (m MockFileChecker) Exists(path string) bool {
return m.existingFiles[path]
}

// TestGetContainerRuntimeBinary runs a suite of tests against GetContainerRuntimeBinary,
// using the MockFileChecker defined above.
func (s *Suite) TestGetContainerRuntimeBinary() {
tests := []struct {
name string
pathEnv string
binary string
mockFiles map[string]bool
expected bool
}{
{
name: "Find docker",
pathEnv: "/usr/local/bin:/usr/bin:/bin",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assumes we won't run these tests on Windows, but that's probably safe enough?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tests should run on windows because the MockFileChecker just looks for a match in the mockFiles map. I originally had the os.Stat call directly in the function here, but that wouldn't work even across unixy machines since it was actually checking the filesystem which could differ drastically.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While we're on the topic though, we should probably think about some automated way to run the tests on Windows (a future PR). @sam-awezec was digging into the traffic coming through our onboarding flow and we're seeing a pretty even 50/50 split between mac and windows. Seems like the winget release process could use some love too.

binary: "docker",
mockFiles: map[string]bool{
"/usr/local/bin/docker": true,
},
expected: true,
},
{
name: "Find docker - doesn't exist",
pathEnv: "/usr/local/bin:/usr/bin:/bin",
binary: "docker",
mockFiles: map[string]bool{},
expected: false,
},
{
name: "Find podman",
pathEnv: "/usr/local/bin:/usr/bin:/bin",
binary: "podman",
mockFiles: map[string]bool{
"/usr/local/bin/podman": true,
},
expected: true,
},
{
name: "Find podman - doesn't exist",
pathEnv: "/usr/local/bin:/usr/bin:/bin",
binary: "podman",
mockFiles: map[string]bool{},
expected: false,
},
{
name: "Binary not found",
pathEnv: "/usr/local/bin:/usr/bin:/bin",
binary: "notarealbinary",
mockFiles: map[string]bool{
"/usr/local/bin/docker": true,
"/usr/local/bin/podman": true,
},
expected: false,
},
{
name: "Duplicated paths in $PATH, binary exists",
pathEnv: "/usr/local/bin:/usr/local/bin:/usr/local/bin",
binary: "docker",
mockFiles: map[string]bool{
"/usr/local/bin/docker": true,
},
expected: true,
},
{
name: "Duplicated paths in $PATH, binary does not exist",
pathEnv: "/usr/local/bin:/usr/local/bin:/usr/local/bin",
binary: "docker",
mockFiles: map[string]bool{},
expected: false,
},
}

for _, tt := range tests {
s.Run(tt.name, func() {
mockChecker := MockFileChecker{existingFiles: tt.mockFiles}
result := FindBinary(tt.pathEnv, tt.binary, mockChecker)
s.Equal(tt.expected, result)
})
}
}
41 changes: 31 additions & 10 deletions airflow/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ const (
RuntimeImageLabel = "io.astronomer.docker.runtime.version"
AirflowImageLabel = "io.astronomer.docker.airflow.version"
componentName = "airflow"
podman = "podman"
podmanCmd = "podman"
Copy link
Member Author

@schnie schnie Oct 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just renamed this to be consistent with dockerCmd. dockerCmd is not simply docker because it conflicts with our imports of the docker go libs. This just makes them more similar.

dockerStateUp = "running"
dockerExitState = "exited"
defaultAirflowVersion = uint64(0x2) //nolint:gomnd
Expand Down Expand Up @@ -206,7 +206,11 @@ func DockerComposeInit(airflowHome, envFile, dockerfile, imageName string) (*Doc
//nolint:gocognit
func (d *DockerCompose) Start(imageName, settingsFile, composeFile, buildSecretString string, noCache, noBrowser bool, waitTime time.Duration, envConns map[string]astrocore.EnvironmentObjectConnection) error {
// check if docker is up for macOS
if runtime.GOOS == "darwin" && config.CFG.DockerCommand.GetString() == dockerCmd {
containerRuntime, err := GetContainerRuntimeBinary()
if err != nil {
return err
}
if runtime.GOOS == "darwin" && containerRuntime == dockerCmd {
err := startDocker()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we feel about the CLI taking it upon itself to try to start Docker if it's not running? And only for macOS? But not for Podman?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think as we work deeper into this doc, we'll add this support for podman. For podman, we need to manage the underlying podman machine as well, basically automating the steps we document manually today here - https://www.astronomer.io/docs/astro/cli/use-podman#configure-the-astro-cli-to-use-podman.

The goal would be that a fresh user doesn't need to understand anything about containers to get started. It won't stay "hidden" forever, but at least it won't be something that stands in the way of getting something running in airflow ASAP.

if err != nil {
return err
Expand Down Expand Up @@ -1108,8 +1112,11 @@ func (d *DockerCompose) Bash(container string) error {
}
}
// exec into container
dockerCommand := config.CFG.DockerCommand.GetString()
err = cmdExec(dockerCommand, os.Stdout, os.Stderr, "exec", "-it", containerName, "bash")
containerRuntime, err := GetContainerRuntimeBinary()
if err != nil {
return err
}
err = cmdExec(containerRuntime, os.Stdout, os.Stderr, "exec", "-it", containerName, "bash")
if err != nil {
return err
}
Expand Down Expand Up @@ -1350,7 +1357,11 @@ var createDockerProject = func(projectName, airflowHome, envFile, buildImage, se
}

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 {
containerRuntime, err := GetContainerRuntimeBinary()
if err != nil {
return err
}
if containerRuntime == podmanCmd {
err := printStatus(settingsFile, envConns, project, composeService, airflowDockerVersion, noBrowser)
if err != nil {
if !errors.Is(err, errComposeProjectRunning) {
Expand Down Expand Up @@ -1416,7 +1427,11 @@ func printStatus(settingsFile string, envConns map[string]astrocore.EnvironmentO
}
}
}
if config.CFG.DockerCommand.GetString() == podman {
containerRuntime, err := GetContainerRuntimeBinary()
if err != nil {
return err
}
if containerRuntime == podmanCmd {
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.")
Expand Down Expand Up @@ -1484,10 +1499,13 @@ func checkServiceState(serviceState, expectedState string) bool {
}

func startDocker() error {
dockerCommand := config.CFG.DockerCommand.GetString()
containerRuntime, err := GetContainerRuntimeBinary()
if err != nil {
return err
}

buf := new(bytes.Buffer)
err := cmdExec(dockerCommand, buf, buf, "ps")
err = cmdExec(containerRuntime, buf, buf, "ps")
if err != nil {
// open docker
fmt.Println("\nDocker is not running. Starting up the Docker engine…")
Expand All @@ -1508,7 +1526,10 @@ func startDocker() error {
}

func waitForDocker() error {
dockerCommand := config.CFG.DockerCommand.GetString()
containerRuntime, err := GetContainerRuntimeBinary()
if err != nil {
return err
}

buf := new(bytes.Buffer)
timeout := time.After(time.Duration(timeoutNum) * time.Second)
Expand All @@ -1521,7 +1542,7 @@ func waitForDocker() error {
// Got a tick, we should check if docker is up & running
case <-ticker.C:
buf.Reset()
err := cmdExec(dockerCommand, buf, buf, "ps")
err := cmdExec(containerRuntime, buf, buf, "ps")
if err != nil {
continue
} else {
Expand Down
Loading