Refactor Docker Init with ContainerRuntime Interface#1748
Conversation
| package runtimes | ||
|
|
||
| type PodmanRuntime struct{} | ||
|
|
||
| func (p PodmanRuntime) Initialize() error { | ||
| return nil | ||
| } |
| func InitializeDocker(d DockerInitializer, timeoutSeconds int) error { | ||
| // Initialize spinner. | ||
| timeout := time.After(time.Duration(timeoutSeconds) * time.Second) | ||
| ticker := time.NewTicker(time.Duration(tickNum) * time.Millisecond) | ||
| s := spinner.New(spinnerCharSet, spinnerRefresh) | ||
| s.Suffix = containerRuntimeInitMessage | ||
| defer s.Stop() | ||
|
|
||
| // Execute `docker ps` to check if Docker is running. | ||
| _, err := d.CheckDockerCmd() | ||
|
|
||
| // If we didn't get an error, Docker is running, so we can return. | ||
| if err == nil { | ||
| return nil | ||
| } | ||
|
|
||
| // If we got an error, Docker is not running, so we attempt to start it. | ||
| _, err = d.OpenDockerCmd() | ||
| if err != nil { | ||
| return fmt.Errorf(dockerOpenNotice) | ||
| } | ||
|
|
||
| // Wait for Docker to start. | ||
| s.Start() | ||
| for { | ||
| select { | ||
| case <-timeout: | ||
| return fmt.Errorf(timeoutErrMsg) | ||
| case <-ticker.C: | ||
| _, err := d.CheckDockerCmd() | ||
| if err != nil { | ||
| continue | ||
| } | ||
| return nil | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This function is a refactored, more testable version of the previous startDocker() and waitForDocker() functions that we've deleted above.
| func (s *Suite) TestStartDocker() { | ||
| s.Run("start docker success", func() { | ||
| counter := 0 | ||
| cmdExec = func(cmd string, stdout, stderr io.Writer, args ...string) error { | ||
| switch cmd { | ||
| case "open": | ||
| return nil | ||
| case "docker": | ||
| if counter == 0 { | ||
| counter++ | ||
| return errExecMock | ||
| } | ||
| return nil | ||
| default: | ||
| return errExecMock | ||
| } | ||
| } | ||
|
|
||
| err := startDocker() | ||
| s.NoError(err) | ||
| }) | ||
|
|
||
| s.Run("start docker fail", func() { | ||
| timeoutNum = 5 | ||
|
|
||
| cmdExec = func(cmd string, stdout, stderr io.Writer, args ...string) error { | ||
| switch cmd { | ||
| case "open": | ||
| return nil | ||
| case "docker": | ||
| return errExecMock | ||
| default: | ||
| return errExecMock | ||
| } | ||
| } | ||
| err := startDocker() | ||
| s.Contains(err.Error(), "timed out waiting for docker") | ||
| }) | ||
| } | ||
|
|
There was a problem hiding this comment.
Refactored and replaced below.
|
|
||
| func startDocker() error { | ||
| containerRuntime, err := GetContainerRuntimeBinary() | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| buf := new(bytes.Buffer) | ||
| err = cmdExec(containerRuntime, buf, buf, "ps") | ||
| if err != nil { | ||
| // open docker | ||
| fmt.Println("\nDocker is not running. Starting up the Docker engine…") | ||
| err = cmdExec(OpenCmd, buf, os.Stderr, "-a", dockerCmd) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| fmt.Println("\nIf you don't see Docker Desktop starting, exit this command and start it manually.") | ||
| fmt.Println("If you don't have Docker Desktop installed, install it (https://www.docker.com/products/docker-desktop/) and try again.") | ||
| fmt.Println("If you are using Colima or another Docker alternative, start the engine manually.") | ||
| // poll for docker | ||
| err = waitForDocker() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func waitForDocker() error { | ||
| containerRuntime, err := GetContainerRuntimeBinary() | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| buf := new(bytes.Buffer) | ||
| timeout := time.After(time.Duration(timeoutNum) * time.Second) | ||
| ticker := time.NewTicker(time.Duration(tickNum) * time.Millisecond) | ||
| for { | ||
| select { | ||
| // Got a timeout! fail with a timeout error | ||
| case <-timeout: | ||
| return errors.New("timed out waiting for docker") | ||
| // Got a tick, we should check if docker is up & running | ||
| case <-ticker.C: | ||
| buf.Reset() | ||
| err := cmdExec(containerRuntime, buf, buf, "ps") | ||
| if err != nil { | ||
| continue | ||
| } else { | ||
| return nil | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
This block of code (startDocker()) was previously called directly by the astro dev start handler. It's now been moved and ultimately called from our new cobra hooks - EnsureRuntime. This hook then ultimately calls the docker initialization code which now does this setup work. The refactored version is below.
| // ContainerRuntime interface defines the methods that manage | ||
| // the container runtime lifecycle. | ||
| type ContainerRuntime interface { | ||
| Initialize() error | ||
| } |
There was a problem hiding this comment.
This interface will grow to include more lifecycle functions when we follow up with podman support in the next PR.
| // Most astro dev sub-commands require the container runtime, | ||
| // so we set that configuration in this persistent pre-run hook. | ||
| // A few sub-commands don't require this, so they explicitly | ||
| // clobber it with a no-op function. | ||
| PersistentPreRunE: ConfigureContainerRuntime, |
There was a problem hiding this comment.
Here we insert a new PersistentPreRunE function to setup the runtime for all the sub commands. This is a quick function and doesn't actually run any initialization steps that take time. When needed, the value set from this function is referenced and we run any lifecycle hooks that interact with the runtime.
With this PR, we're only running this on the astro dev start command, as the other commands explicitly clobber this hook. In the following PR for podman support, we'll utilize this much more.
The methods that are not yet using this functionality explicitly define their own PersistentPreRunE function of DoNothing. This was already happening previously with an inline function definiton. This is really just cleaning that up and defining all our hooks in a single place.
| // ignore PersistentPreRunE of root command | ||
| PersistentPreRunE: func(cmd *cobra.Command, args []string) error { | ||
| return nil | ||
| }, |
There was a problem hiding this comment.
All of these blocks are have been replaced with the same function - DoNothing.
| package runtimes | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "os/exec" | ||
| ) | ||
|
|
||
| // Command represents a command to be executed. | ||
| type Command struct { | ||
| Command string | ||
| Args []string | ||
| } | ||
|
|
||
| // Execute runs the Podman command and returns the output. | ||
| func (p *Command) Execute() (string, error) { | ||
| cmd := exec.Command(p.Command, p.Args...) //nolint:gosec | ||
| var out bytes.Buffer | ||
| cmd.Stdout = &out | ||
| cmd.Stderr = &out | ||
| err := cmd.Run() | ||
| return out.String(), err | ||
| } |
There was a problem hiding this comment.
Just a generic command execution structure we'll use for shelling out docker, podman, etc commands.
ec87aef to
98016bf
Compare
98016bf to
0ad0bbc
Compare
| // check if docker is up for macOS | ||
| containerRuntime, err := GetContainerRuntimeBinary() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if runtime.GOOS == "darwin" && containerRuntime == dockerCmd { | ||
| err := startDocker() | ||
| if err != nil { | ||
| return err | ||
| } | ||
| } | ||
|
|
There was a problem hiding this comment.
This has been moved to a cobra PersistentPreRunE function named ConfiguredContainerRuntime and a PreRunE function named EnsureRuntime. These will be rolled out more broadly across the astro dev commands in the next PR.
| "github.com/astronomer/astro-cli/airflow/runtimes" | ||
|
|
There was a problem hiding this comment.
Just bumped all this new container runtime management stuff into a sub package runtimes, so that's just getting updated below.
588de54 to
a5c314d
Compare
There was a problem hiding this comment.
This is just being moved and expanded within the sub-package runtimes.
a5c314d to
da610b6
Compare
da610b6 to
5097786
Compare
| func (s *AirflowSuite) TestNewAirflowInitCmd() { | ||
| cmd := newAirflowInitCmd() | ||
| func (s *AirflowSuite) TestNewAirflowDevRootCmd() { | ||
| cmd := newDevRootCmd(nil, nil) | ||
| s.Nil(cmd.PersistentPreRunE(new(cobra.Command), []string{})) | ||
| } | ||
|
|
||
| func (s *AirflowSuite) TestNewAirflowStartCmd() { | ||
| cmd := newAirflowStartCmd(nil) | ||
| func (s *AirflowSuite) TestNewAirflowInitCmd() { | ||
| cmd := newAirflowInitCmd() |
There was a problem hiding this comment.
Weird looking change here, but I've:
- removed the test for
TestNewAirflowStartRootCmd, thePersistentPreRunEfunction has been moved to the parent command so this test fails and really isn't necessary any more. - added the test for the
TestNewAirflowDevRootCmdsince it now holds the sharedPersistentPreRunEfunction. - left the test for
TestNewAirflowInitCmdin place.
ContainerRuntime Interface
ContainerRuntime InterfaceContainerRuntime Interface
jeremybeard
left a comment
There was a problem hiding this comment.
This is excellent, I really like the new structure as a way of treating the runtime options equally. Left a couple of small comments.
| "github.com/pkg/errors" | ||
| ) | ||
|
|
||
| var spinnerCharSet = spinner.CharSets[14] |
There was a problem hiding this comment.
Do we maybe want to match (or reuse? or change?) the charset in pkg/ansi/spinner.go for consistency?
There was a problem hiding this comment.
Looks like we created a custom one for login, etc. I think standardizing would be good but I'm trying to keep my changes to a minimum and keep them highly correlated. I've started and stopped this PR a few times because I've gone too far down the refactor path. I do plan to keep going, and intend to standardize as much as possible as we go it'll just be in targeted chunks.
This particular spinner charset actually mimics the ones that docker-compose uses so it looks somewhat intentional at the moment since we're using it for the messages where we're dealing with containers and the runtimes, etc.
There was a problem hiding this comment.
Ok fair enough. I appreciate the refactor considering it can feel like a bit of a never-ending rabbit hole.
| var spinnerCharSet = spinner.CharSets[14] | ||
|
|
||
| const ( | ||
| docker = "docker" |
There was a problem hiding this comment.
Do we need these verbatim constants? I'd think if the value changed we'd probably just rename the constant anyway.
There was a problem hiding this comment.
What are you thinking? Change them back to dockerCmd, etc?
There was a problem hiding this comment.
Could just use the literal string.
airflow/runtimes/docker.go
Outdated
| // DockerInitializer is a struct that contains the functions needed to initialize Docker. | ||
| // The concrete implementation that we use is DefaultDockerInitializer below. | ||
| // When running the tests, we substitute the default implementation with a mock implementation. | ||
| type DockerInitializer struct { |
There was a problem hiding this comment.
This looks like it should be an interface, what do you think?
There was a problem hiding this comment.
Ah yea good call, meant to do that. It's now an interface and I've updated the mock implementation and tests accordingly.
634a68e to
43c8a93
Compare

Description
Functionally, this really doesn't change much of anything. It is really just refactoring the
startDocker()command to a new place. We've defined a newContainerRuntimeinterface that will grow a bit in the next PR to cover container runtime lifecycle management functions. This places the original, directly in-line code into this new lifecycle management structure. We then call these functions from cobra pre-run hooks on ourastro devcommands.My original podman PR grew too large, so this is part 1 to get some of the basic structure in place for expansion, while not changing any behavior quite yet. A follow up PR will expand on the
ContainerRuntimeinterface and add podman support.🎟 Issue(s)
Related to https://github.com/astronomer/astro/issues/24344
🧪 Functional Testing
astro devsubcommands have been tested manually and confirmed to be working as expected. (we should automate this as one of our next steps)📸 Screenshots
📋 Checklist
make testbefore taking out of draftmake lintbefore taking out of draft