-
Notifications
You must be signed in to change notification settings - Fork 79
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
Changes from all commits
3c600f6
bdb52b7
d6a2c1d
c3121e4
9afa833
29a5e86
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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" | ||
} | ||
|
||
// 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") | ||
} |
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 { | ||
jeremybeard marked this conversation as resolved.
Show resolved
Hide resolved
|
||
name string | ||
pathEnv string | ||
binary string | ||
mockFiles map[string]bool | ||
expected bool | ||
}{ | ||
{ | ||
name: "Find docker", | ||
pathEnv: "/usr/local/bin:/usr/bin:/bin", | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The tests should run on windows because the There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
}) | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -46,7 +46,7 @@ const ( | |
RuntimeImageLabel = "io.astronomer.docker.runtime.version" | ||
AirflowImageLabel = "io.astronomer.docker.airflow.version" | ||
componentName = "airflow" | ||
podman = "podman" | ||
podmanCmd = "podman" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just renamed this to be consistent with |
||
dockerStateUp = "running" | ||
dockerExitState = "exited" | ||
defaultAirflowVersion = uint64(0x2) //nolint:gomnd | ||
|
@@ -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() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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? There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 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 | ||
|
@@ -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 | ||
} | ||
|
@@ -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) { | ||
|
@@ -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.") | ||
|
@@ -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…") | ||
|
@@ -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) | ||
|
@@ -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 { | ||
|
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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, withpodman
managing the WSL2 VM. Thepodman
binary ispodman.exe
in Windows. If we were running purely within WSL2 on something like an Ubuntu machine whereastro
andpodman
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?There was a problem hiding this comment.
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.