From 0c585f5a1c70a92f73ba23c577e5a02251e35618 Mon Sep 17 00:00:00 2001 From: Greg Neiheisel <1036482+schnie@users.noreply.github.com> Date: Thu, 5 Dec 2024 17:01:08 -0500 Subject: [PATCH] Major test overhaul for runtimes and podman --- .mockery.yaml | 9 + airflow/runtimes/command_test.go | 13 +- airflow/runtimes/container_runtime.go | 29 +- airflow/runtimes/container_runtime_test.go | 37 +- airflow/runtimes/docker_engine.go | 10 +- airflow/runtimes/docker_runtime.go | 23 +- airflow/runtimes/docker_runtime_test.go | 56 +-- airflow/runtimes/file_checker.go | 21 + airflow/runtimes/mocks/ContainerRuntime.go | 80 ++++ airflow/runtimes/mocks/DockerEngine.go | 72 +++ airflow/runtimes/mocks/FileChecker.go | 13 + airflow/runtimes/mocks/OSChecker.go | 52 +++ airflow/runtimes/mocks/PodmanEngine.go | 176 +++++++ airflow/runtimes/mocks/RuntimeChecker.go | 14 + airflow/runtimes/{utils.go => os_checker.go} | 13 +- airflow/runtimes/podman_engine.go | 55 +-- airflow/runtimes/podman_engine_test.go | 36 ++ airflow/runtimes/podman_runtime.go | 120 ++--- airflow/runtimes/podman_runtime_test.go | 459 ++++++++++++++++++- airflow/runtimes/types/podman.go | 35 ++ cmd/airflow_hooks.go | 3 +- config/config.go | 2 +- config/types.go | 2 +- 23 files changed, 1109 insertions(+), 221 deletions(-) create mode 100644 airflow/runtimes/file_checker.go create mode 100644 airflow/runtimes/mocks/ContainerRuntime.go create mode 100644 airflow/runtimes/mocks/DockerEngine.go create mode 100644 airflow/runtimes/mocks/FileChecker.go create mode 100644 airflow/runtimes/mocks/OSChecker.go create mode 100644 airflow/runtimes/mocks/PodmanEngine.go create mode 100644 airflow/runtimes/mocks/RuntimeChecker.go rename airflow/runtimes/{utils.go => os_checker.go} (50%) create mode 100644 airflow/runtimes/podman_engine_test.go create mode 100644 airflow/runtimes/types/podman.go diff --git a/.mockery.yaml b/.mockery.yaml index 6e1c4ee37..9a267eb16 100644 --- a/.mockery.yaml +++ b/.mockery.yaml @@ -51,3 +51,12 @@ packages: dir: pkg/azure/mocks interfaces: Azure: + github.com/astronomer/astro-cli/airflow/runtimes: + config: + dir: airflow/runtimes/mocks + outpkg: mocks + interfaces: + OSChecker: + ContainerRuntime: + PodmanEngine: + DockerEngine: \ No newline at end of file diff --git a/airflow/runtimes/command_test.go b/airflow/runtimes/command_test.go index 8a4daacdb..b852aece4 100644 --- a/airflow/runtimes/command_test.go +++ b/airflow/runtimes/command_test.go @@ -1,10 +1,21 @@ package runtimes import ( + "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" ) -func (s *ContainerRuntimeSuite) TestCommandExecution() { +type ContainerRuntimeCommandSuite struct { + suite.Suite +} + +func TestContainerRuntimeCommand(t *testing.T) { + suite.Run(t, new(ContainerRuntimeCommandSuite)) +} + +func (s *ContainerRuntimeCommandSuite) TestCommandExecution() { s.Run("Command executes successfully", func() { cmd := &Command{ Command: "echo", diff --git a/airflow/runtimes/container_runtime.go b/airflow/runtimes/container_runtime.go index 9e2277372..c3073328f 100644 --- a/airflow/runtimes/container_runtime.go +++ b/airflow/runtimes/container_runtime.go @@ -10,7 +10,6 @@ import ( "github.com/briandowns/spinner" "github.com/astronomer/astro-cli/config" - "github.com/astronomer/astro-cli/pkg/fileutil" "github.com/astronomer/astro-cli/pkg/util" "github.com/pkg/errors" ) @@ -49,44 +48,26 @@ func GetContainerRuntime() (ContainerRuntime, error) { // Return the appropriate container runtime based on the binary discovered. switch containerRuntime { case docker: - return CreateDockerRuntime(new(DefaultDockerEngine)), nil + return CreateDockerRuntime(new(DefaultDockerEngine), new(DefaultOSChecker)), nil case podman: - return CreatePodmanRuntime(new(DefaultPodmanEngine)), nil + return CreatePodmanRuntime(new(DefaultPodmanEngine), new(DefaultOSChecker)), nil default: return nil, errors.New(containerRuntimeNotFoundErrMsg) } } -// 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 { +func FindBinary(pathEnv, binaryName string, checker FileChecker, osChecker OSChecker) 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() { + if osChecker.IsWindows() { binaryName += ".exe" } @@ -150,7 +131,7 @@ var GetContainerRuntimeBinary = func() (string, error) { // Get the $PATH environment variable. pathEnv := os.Getenv("PATH") for _, binary := range binaries { - if found := FindBinary(pathEnv, binary, OSFileChecker{}); found { + if found := FindBinary(pathEnv, binary, new(DefaultOSFileChecker), new(DefaultOSChecker)); found { return binary, nil } } diff --git a/airflow/runtimes/container_runtime_test.go b/airflow/runtimes/container_runtime_test.go index 0a3199c54..c1e639a7c 100644 --- a/airflow/runtimes/container_runtime_test.go +++ b/airflow/runtimes/container_runtime_test.go @@ -4,9 +4,9 @@ import ( "errors" "testing" - "github.com/stretchr/testify/assert" + "github.com/astronomer/astro-cli/airflow/runtimes/mocks" - "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/suite" ) @@ -15,23 +15,13 @@ type ContainerRuntimeSuite struct { suite.Suite } -func TestConfig(t *testing.T) { +func TestContainerRuntime(t *testing.T) { suite.Run(t, new(ContainerRuntimeSuite)) } -// Mock for GetContainerRuntimeBinary -type MockRuntimeChecker struct { - mock.Mock -} - -func (m *MockRuntimeChecker) GetContainerRuntimeBinary() (string, error) { - args := m.Called() - return args.String(0), args.Error(1) -} - func (s *ContainerRuntimeSuite) TestGetContainerRuntime() { s.Run("GetContainerRuntime_Docker", func() { - mockChecker := new(MockRuntimeChecker) + mockChecker := new(mocks.RuntimeChecker) mockChecker.On("GetContainerRuntimeBinary").Return(docker, nil) // Inject the mock and make sure we restore after the test. @@ -47,7 +37,7 @@ func (s *ContainerRuntimeSuite) TestGetContainerRuntime() { }) s.Run("GetContainerRuntime_Podman", func() { - mockChecker := new(MockRuntimeChecker) + mockChecker := new(mocks.RuntimeChecker) mockChecker.On("GetContainerRuntimeBinary").Return(podman, nil) // Inject the mock and make sure we restore after the test. @@ -63,7 +53,7 @@ func (s *ContainerRuntimeSuite) TestGetContainerRuntime() { }) s.Run("GetContainerRuntime_Error", func() { - mockChecker := new(MockRuntimeChecker) + mockChecker := new(mocks.RuntimeChecker) mockChecker.On("GetContainerRuntimeBinary").Return("", errors.New(containerRuntimeNotFoundErrMsg)) // Inject the mock and make sure we restore after the test. @@ -80,17 +70,6 @@ func (s *ContainerRuntimeSuite) TestGetContainerRuntime() { }) } -// 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 *ContainerRuntimeSuite) TestGetContainerRuntimeBinary() { @@ -163,8 +142,8 @@ func (s *ContainerRuntimeSuite) TestGetContainerRuntimeBinary() { for _, tt := range tests { s.Run(tt.name, func() { - mockChecker := MockFileChecker{existingFiles: tt.mockFiles} - result := FindBinary(tt.pathEnv, tt.binary, mockChecker) + mockChecker := mocks.FileChecker{ExistingFiles: tt.mockFiles} + result := FindBinary(tt.pathEnv, tt.binary, mockChecker, new(DefaultOSChecker)) s.Equal(tt.expected, result) }) } diff --git a/airflow/runtimes/docker_engine.go b/airflow/runtimes/docker_engine.go index abcf7b1b7..91fa5ff92 100644 --- a/airflow/runtimes/docker_engine.go +++ b/airflow/runtimes/docker_engine.go @@ -8,16 +8,8 @@ const ( dockerOpenNotice = "We couldn't start the docker engine automatically. Please start it manually and try again." ) -// DockerEngine is a struct that contains the functions needed to initialize Docker. -// The concrete implementation that we use is DefaultDockerEngine below. -// When running the tests, we substitute the default implementation with a mock implementation. -type DockerEngine interface { - IsRunning() (string, error) - Start() (string, error) -} - // DefaultDockerEngine is the default implementation of DockerEngine. -// The concrete functions defined here are called from the InitializeDocker function below. +// The concrete functions defined here are called from the initializeDocker function below. type DefaultDockerEngine struct{} func (d DefaultDockerEngine) IsRunning() (string, error) { diff --git a/airflow/runtimes/docker_runtime.go b/airflow/runtimes/docker_runtime.go index b9e736bd8..7a09ff80a 100644 --- a/airflow/runtimes/docker_runtime.go +++ b/airflow/runtimes/docker_runtime.go @@ -7,23 +7,32 @@ import ( "github.com/briandowns/spinner" ) +// DockerEngine is a struct that contains the functions needed to initialize Docker. +// The concrete implementation that we use is DefaultDockerEngine below. +// When running the tests, we substitute the default implementation with a mock implementation. +type DockerEngine interface { + IsRunning() (string, error) + Start() (string, error) +} + // DockerRuntime is a concrete implementation of the ContainerRuntime interface. // When the docker binary is chosen, this implementation is used. type DockerRuntime struct { - Engine DockerEngine + Engine DockerEngine + OSChecker OSChecker } -func CreateDockerRuntime(engine DockerEngine) DockerRuntime { - return DockerRuntime{Engine: engine} +func CreateDockerRuntime(engine DockerEngine, osChecker OSChecker) DockerRuntime { + return DockerRuntime{Engine: engine, OSChecker: osChecker} } // Initialize initializes the Docker runtime. // We only attempt to initialize Docker on Mac today. func (rt DockerRuntime) Initialize() error { - if !isMac() { + if !rt.OSChecker.IsMac() { return nil } - return rt.InitializeDocker(defaultTimeoutSeconds) + return rt.initializeDocker(defaultTimeoutSeconds) } func (rt DockerRuntime) Configure() error { @@ -38,9 +47,9 @@ func (rt DockerRuntime) Kill() error { return nil } -// InitializeDocker initializes the Docker runtime. +// initializeDocker initializes the Docker runtime. // It checks if Docker is running, and if it is not, it attempts to start it. -func (rt DockerRuntime) InitializeDocker(timeoutSeconds int) error { +func (rt DockerRuntime) initializeDocker(timeoutSeconds int) error { // Initialize spinner. timeout := time.After(time.Duration(timeoutSeconds) * time.Second) ticker := time.NewTicker(time.Duration(tickNum) * time.Millisecond) diff --git a/airflow/runtimes/docker_runtime_test.go b/airflow/runtimes/docker_runtime_test.go index 87575c00f..471c0a538 100644 --- a/airflow/runtimes/docker_runtime_test.go +++ b/airflow/runtimes/docker_runtime_test.go @@ -2,83 +2,87 @@ package runtimes import ( "fmt" + "testing" + + "github.com/astronomer/astro-cli/airflow/runtimes/mocks" + "github.com/stretchr/testify/suite" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" ) -type MockDockerEngine struct { - mock.Mock +var ( + mockDockerEngine *mocks.DockerEngine + mockDockerOSChecker *mocks.OSChecker +) + +type DockerRuntimeSuite struct { + suite.Suite } -func (d *MockDockerEngine) IsRunning() (string, error) { - args := d.Called() - return args.String(0), args.Error(1) +func TestDockerRuntime(t *testing.T) { + suite.Run(t, new(DockerRuntimeSuite)) } -func (d *MockDockerEngine) Start() (string, error) { - args := d.Called() - return args.String(0), args.Error(1) +func (s *DockerRuntimeSuite) SetupTest() { + // Reset some variables to defaults. + mockDockerEngine = new(mocks.DockerEngine) + mockDockerOSChecker = new(mocks.OSChecker) } -func (s *ContainerRuntimeSuite) TestStartDocker() { +func (s *DockerRuntimeSuite) TestStartDocker() { s.Run("Docker is running, returns nil", func() { - // Create mock initializer. - mockDockerEngine := new(MockDockerEngine) // Simulate that the initial `docker ps` succeeds and we exit early. mockDockerEngine.On("IsRunning").Return("", nil).Once() + mockDockerOSChecker.On("IsMac").Return(true) // Create the runtime with our mock engine. - rt := CreateDockerRuntime(mockDockerEngine) + rt := CreateDockerRuntime(mockDockerEngine, mockDockerOSChecker) // Run our test and assert expectations. - err := rt.InitializeDocker(defaultTimeoutSeconds) + err := rt.Initialize() assert.Nil(s.T(), err, "Expected no error when docker is running") mockDockerEngine.AssertExpectations(s.T()) }) s.Run("Docker is not running, tries to start and waits", func() { - // Create mock initializer. - mockDockerEngine := new(MockDockerEngine) // Simulate that the initial `docker ps` fails. mockDockerEngine.On("IsRunning").Return("", fmt.Errorf("docker not running")).Once() // Simulate that `open -a docker` succeeds. mockDockerEngine.On("Start").Return("", nil).Once() // Simulate that `docker ps` works after trying to open docker. mockDockerEngine.On("IsRunning").Return("", nil).Once() + mockDockerOSChecker.On("IsMac").Return(true) // Create the runtime with our mock engine. - rt := CreateDockerRuntime(mockDockerEngine) + rt := CreateDockerRuntime(mockDockerEngine, mockDockerOSChecker) // Run our test and assert expectations. - err := rt.InitializeDocker(defaultTimeoutSeconds) + err := rt.Initialize() assert.Nil(s.T(), err, "Expected no error when docker starts after retry") mockDockerEngine.AssertExpectations(s.T()) }) s.Run("Docker fails to open", func() { - // Create mock initializer. - mockDockerEngine := new(MockDockerEngine) // Simulate `docker ps` failing. mockDockerEngine.On("IsRunning").Return("", fmt.Errorf("docker not running")).Once() // Simulate `open -a docker` failing. mockDockerEngine.On("Start").Return("", fmt.Errorf("failed to open docker")).Once() + mockDockerOSChecker.On("IsMac").Return(true) // Create the runtime with our mock engine. - rt := CreateDockerRuntime(mockDockerEngine) + rt := CreateDockerRuntime(mockDockerEngine, mockDockerOSChecker) // Run our test and assert expectations. - err := rt.InitializeDocker(defaultTimeoutSeconds) + err := rt.Initialize() assert.Equal(s.T(), fmt.Errorf(dockerOpenNotice), err, "Expected timeout error") mockDockerEngine.AssertExpectations(s.T()) }) s.Run("Docker open succeeds but check times out", func() { - // Create mock initializer. - mockDockerEngine := new(MockDockerEngine) // Simulate `docker ps` failing continuously. mockDockerEngine.On("IsRunning").Return("", fmt.Errorf("docker not running")) // Simulate `open -a docker` failing. mockDockerEngine.On("Start").Return("", nil).Once() // Create the runtime with our mock engine. - rt := CreateDockerRuntime(mockDockerEngine) + rt := CreateDockerRuntime(mockDockerEngine, mockDockerOSChecker) // Run our test and assert expectations. + // Call the helper method directly with custom timeout. // Simulate the timeout after 1 second. - err := rt.InitializeDocker(1) + err := rt.initializeDocker(1) assert.Equal(s.T(), fmt.Errorf(timeoutErrMsg), err, "Expected timeout error") mockDockerEngine.AssertExpectations(s.T()) }) diff --git a/airflow/runtimes/file_checker.go b/airflow/runtimes/file_checker.go new file mode 100644 index 000000000..dc2ff1204 --- /dev/null +++ b/airflow/runtimes/file_checker.go @@ -0,0 +1,21 @@ +package runtimes + +import "github.com/astronomer/astro-cli/pkg/fileutil" + +// 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 +} + +// DefaultOSFileChecker is a concrete implementation of FileChecker. +type DefaultOSFileChecker struct{} + +// Exists checks if the file exists in the file system. +func (f DefaultOSFileChecker) Exists(path string) bool { + exists, _ := fileutil.Exists(path, nil) + return exists +} diff --git a/airflow/runtimes/mocks/ContainerRuntime.go b/airflow/runtimes/mocks/ContainerRuntime.go new file mode 100644 index 000000000..c6453409a --- /dev/null +++ b/airflow/runtimes/mocks/ContainerRuntime.go @@ -0,0 +1,80 @@ +// Code generated by mockery v2.32.0. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// ContainerRuntime is an autogenerated mock type for the ContainerRuntime type +type ContainerRuntime struct { + mock.Mock +} + +// Configure provides a mock function with given fields: +func (_m *ContainerRuntime) Configure() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ConfigureOrKill provides a mock function with given fields: +func (_m *ContainerRuntime) ConfigureOrKill() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Initialize provides a mock function with given fields: +func (_m *ContainerRuntime) Initialize() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Kill provides a mock function with given fields: +func (_m *ContainerRuntime) Kill() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewContainerRuntime creates a new instance of ContainerRuntime. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewContainerRuntime(t interface { + mock.TestingT + Cleanup(func()) +}) *ContainerRuntime { + mock := &ContainerRuntime{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/airflow/runtimes/mocks/DockerEngine.go b/airflow/runtimes/mocks/DockerEngine.go new file mode 100644 index 000000000..491eeaee9 --- /dev/null +++ b/airflow/runtimes/mocks/DockerEngine.go @@ -0,0 +1,72 @@ +// Code generated by mockery v2.32.0. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// DockerEngine is an autogenerated mock type for the DockerEngine type +type DockerEngine struct { + mock.Mock +} + +// IsRunning provides a mock function with given fields: +func (_m *DockerEngine) IsRunning() (string, error) { + ret := _m.Called() + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Start provides a mock function with given fields: +func (_m *DockerEngine) Start() (string, error) { + ret := _m.Called() + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func() (string, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NewDockerEngine creates a new instance of DockerEngine. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewDockerEngine(t interface { + mock.TestingT + Cleanup(func()) +}) *DockerEngine { + mock := &DockerEngine{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/airflow/runtimes/mocks/FileChecker.go b/airflow/runtimes/mocks/FileChecker.go new file mode 100644 index 000000000..662115cef --- /dev/null +++ b/airflow/runtimes/mocks/FileChecker.go @@ -0,0 +1,13 @@ +package mocks + +// FileChecker is a mock implementation of FileChecker for tests. +// This is a manually created mock, not generated by mockery. +type FileChecker 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 FileChecker) Exists(path string) bool { + return m.ExistingFiles[path] +} diff --git a/airflow/runtimes/mocks/OSChecker.go b/airflow/runtimes/mocks/OSChecker.go new file mode 100644 index 000000000..1de522b5a --- /dev/null +++ b/airflow/runtimes/mocks/OSChecker.go @@ -0,0 +1,52 @@ +// Code generated by mockery v2.32.0. DO NOT EDIT. + +package mocks + +import mock "github.com/stretchr/testify/mock" + +// OSChecker is an autogenerated mock type for the OSChecker type +type OSChecker struct { + mock.Mock +} + +// IsMac provides a mock function with given fields: +func (_m *OSChecker) IsMac() bool { + ret := _m.Called() + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// IsWindows provides a mock function with given fields: +func (_m *OSChecker) IsWindows() bool { + ret := _m.Called() + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// NewOSChecker creates a new instance of OSChecker. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewOSChecker(t interface { + mock.TestingT + Cleanup(func()) +}) *OSChecker { + mock := &OSChecker{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/airflow/runtimes/mocks/PodmanEngine.go b/airflow/runtimes/mocks/PodmanEngine.go new file mode 100644 index 000000000..bfadbc701 --- /dev/null +++ b/airflow/runtimes/mocks/PodmanEngine.go @@ -0,0 +1,176 @@ +// Code generated by mockery v2.32.0. DO NOT EDIT. + +package mocks + +import ( + mock "github.com/stretchr/testify/mock" + + types "github.com/astronomer/astro-cli/airflow/runtimes/types" +) + +// PodmanEngine is an autogenerated mock type for the PodmanEngine type +type PodmanEngine struct { + mock.Mock +} + +// InitializeMachine provides a mock function with given fields: name +func (_m *PodmanEngine) InitializeMachine(name string) error { + ret := _m.Called(name) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// InspectMachine provides a mock function with given fields: name +func (_m *PodmanEngine) InspectMachine(name string) (*types.InspectedMachine, error) { + ret := _m.Called(name) + + var r0 *types.InspectedMachine + var r1 error + if rf, ok := ret.Get(0).(func(string) (*types.InspectedMachine, error)); ok { + return rf(name) + } + if rf, ok := ret.Get(0).(func(string) *types.InspectedMachine); ok { + r0 = rf(name) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*types.InspectedMachine) + } + } + + if rf, ok := ret.Get(1).(func(string) error); ok { + r1 = rf(name) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListContainers provides a mock function with given fields: +func (_m *PodmanEngine) ListContainers() ([]types.ListedContainer, error) { + ret := _m.Called() + + var r0 []types.ListedContainer + var r1 error + if rf, ok := ret.Get(0).(func() ([]types.ListedContainer, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []types.ListedContainer); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]types.ListedContainer) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ListMachines provides a mock function with given fields: +func (_m *PodmanEngine) ListMachines() ([]types.ListedMachine, error) { + ret := _m.Called() + + var r0 []types.ListedMachine + var r1 error + if rf, ok := ret.Get(0).(func() ([]types.ListedMachine, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() []types.ListedMachine); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]types.ListedMachine) + } + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RemoveMachine provides a mock function with given fields: name +func (_m *PodmanEngine) RemoveMachine(name string) error { + ret := _m.Called(name) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SetMachineAsDefault provides a mock function with given fields: name +func (_m *PodmanEngine) SetMachineAsDefault(name string) error { + ret := _m.Called(name) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// StartMachine provides a mock function with given fields: name +func (_m *PodmanEngine) StartMachine(name string) error { + ret := _m.Called(name) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// StopMachine provides a mock function with given fields: name +func (_m *PodmanEngine) StopMachine(name string) error { + ret := _m.Called(name) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(name) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewPodmanEngine creates a new instance of PodmanEngine. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPodmanEngine(t interface { + mock.TestingT + Cleanup(func()) +}) *PodmanEngine { + mock := &PodmanEngine{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/airflow/runtimes/mocks/RuntimeChecker.go b/airflow/runtimes/mocks/RuntimeChecker.go new file mode 100644 index 000000000..517de8a3b --- /dev/null +++ b/airflow/runtimes/mocks/RuntimeChecker.go @@ -0,0 +1,14 @@ +package mocks + +import "github.com/stretchr/testify/mock" + +// RuntimeChecker is a mock for GetContainerRuntimeBinary +// This is a manually created mock, not generated by mockery. +type RuntimeChecker struct { + mock.Mock +} + +func (m *RuntimeChecker) GetContainerRuntimeBinary() (string, error) { + args := m.Called() + return args.String(0), args.Error(1) +} diff --git a/airflow/runtimes/utils.go b/airflow/runtimes/os_checker.go similarity index 50% rename from airflow/runtimes/utils.go rename to airflow/runtimes/os_checker.go index f5351ec06..b9346f2a9 100644 --- a/airflow/runtimes/utils.go +++ b/airflow/runtimes/os_checker.go @@ -2,14 +2,21 @@ package runtimes import "runtime" +type OSChecker interface { + IsMac() bool + IsWindows() bool +} + +type DefaultOSChecker struct{} + // IsWindows is a utility function to determine if the CLI host machine // is running on Microsoft Windows OS. -func IsWindows() bool { +func (o DefaultOSChecker) IsWindows() bool { return runtime.GOOS == "windows" } -// isMac is a utility function to determine if the CLI host machine +// IsMac is a utility function to determine if the CLI host machine // is running on Apple macOS. -func isMac() bool { +func (o DefaultOSChecker) IsMac() bool { return runtime.GOOS == "darwin" } diff --git a/airflow/runtimes/podman_engine.go b/airflow/runtimes/podman_engine.go index 8b4aa363c..d1f563e7b 100644 --- a/airflow/runtimes/podman_engine.go +++ b/airflow/runtimes/podman_engine.go @@ -5,6 +5,7 @@ import ( "fmt" "strings" + "github.com/astronomer/astro-cli/airflow/runtimes/types" "github.com/astronomer/astro-cli/config" ) @@ -20,50 +21,12 @@ const ( "Please stop the other machine and try again" ) -// ListedMachine contains information about a Podman machine -// as it is provided from the `podman machine ls --format json` command. -type ListedMachine struct { - Name string - Running bool - Starting bool - LastUp string -} - -// InspectedMachine contains information about a Podman machine -// as it is provided from the `podman machine inspect` command. -type InspectedMachine struct { - Name string - ConnectionInfo struct { - PodmanSocket struct { - Path string - } - } - State string -} - -// ListedContainer contains information about a Podman container -// as it is provided from the `podman ps --format json` command. -type ListedContainer struct { - Name string - Labels map[string]string -} - -type PodmanEngine interface { - InitializeMachine(name string) error - StartMachine(name string) error - StopMachine(name string) error - RemoveMachine(name string) error - InspectMachine(name string) (*InspectedMachine, error) - SetMachineAsDefault(name string) error - ListMachines() ([]ListedMachine, error) - ListContainers() ([]ListedContainer, error) -} - type DefaultPodmanEngine struct{} // InitializeMachine initializes our astro Podman machine. func (e DefaultPodmanEngine) InitializeMachine(name string) error { - podmanMachineMemory := config.CFG.PodmanMEM.GetString() + // Grab some optional configurations from the config file. + podmanMachineMemory := config.CFG.PodmanMemory.GetString() podmanMachineCPU := config.CFG.PodmanCPU.GetString() podmanCmd := Command{ Command: podman, @@ -117,7 +80,7 @@ func (e DefaultPodmanEngine) RemoveMachine(name string) error { } // InspectMachine inspects a given podman machine name. -func (e DefaultPodmanEngine) InspectMachine(name string) (*InspectedMachine, error) { +func (e DefaultPodmanEngine) InspectMachine(name string) (*types.InspectedMachine, error) { podmanCmd := Command{ Command: podman, Args: []string{"machine", "inspect", name}, @@ -127,7 +90,7 @@ func (e DefaultPodmanEngine) InspectMachine(name string) (*InspectedMachine, err return nil, ErrorFromOutput("error inspecting machine: %s", output) } - var machines []InspectedMachine + var machines []types.InspectedMachine err = json.Unmarshal([]byte(output), &machines) if err != nil { return nil, err @@ -153,7 +116,7 @@ func (e DefaultPodmanEngine) SetMachineAsDefault(name string) error { } // ListMachines lists all Podman machines. -func (e DefaultPodmanEngine) ListMachines() ([]ListedMachine, error) { +func (e DefaultPodmanEngine) ListMachines() ([]types.ListedMachine, error) { podmanCmd := Command{ Command: podman, Args: []string{"machine", "ls", "--format", "json"}, @@ -162,7 +125,7 @@ func (e DefaultPodmanEngine) ListMachines() ([]ListedMachine, error) { if err != nil { return nil, ErrorFromOutput("error listing machines: %s", output) } - var machines []ListedMachine + var machines []types.ListedMachine err = json.Unmarshal([]byte(output), &machines) if err != nil { return nil, err @@ -171,7 +134,7 @@ func (e DefaultPodmanEngine) ListMachines() ([]ListedMachine, error) { } // ListContainers lists all pods in the machine. -func (e DefaultPodmanEngine) ListContainers() ([]ListedContainer, error) { +func (e DefaultPodmanEngine) ListContainers() ([]types.ListedContainer, error) { podmanCmd := Command{ Command: podman, Args: []string{"ps", "--format", "json"}, @@ -180,7 +143,7 @@ func (e DefaultPodmanEngine) ListContainers() ([]ListedContainer, error) { if err != nil { return nil, ErrorFromOutput("error listing containers: %s", output) } - var containers []ListedContainer + var containers []types.ListedContainer err = json.Unmarshal([]byte(output), &containers) if err != nil { return nil, err diff --git a/airflow/runtimes/podman_engine_test.go b/airflow/runtimes/podman_engine_test.go new file mode 100644 index 000000000..be3a2dd6f --- /dev/null +++ b/airflow/runtimes/podman_engine_test.go @@ -0,0 +1,36 @@ +package runtimes + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type PodmanEngineSuite struct { + suite.Suite +} + +func TestPodmanEngine(t *testing.T) { + suite.Run(t, new(PodmanEngineSuite)) +} + +func (s *PodmanRuntimeSuite) TestPodmanEngineErrorFromOutput() { + s.Run("returns formatted error when error line is present", func() { + output := "Some output\nError: something went wrong\nMore output" + err := ErrorFromOutput("prefix: %s", output) + assert.EqualError(s.T(), err, "prefix: something went wrong") + }) + + s.Run("returns formatted error when output is empty", func() { + output := "" + err := ErrorFromOutput("prefix: %s", output) + assert.EqualError(s.T(), err, "prefix: ") + }) + + s.Run("returns formatted error when output contains only error line", func() { + output := "Error: something went wrong" + err := ErrorFromOutput("prefix: %s", output) + assert.EqualError(s.T(), err, "prefix: something went wrong") + }) +} diff --git a/airflow/runtimes/podman_runtime.go b/airflow/runtimes/podman_runtime.go index ccd91e98a..8e371d597 100644 --- a/airflow/runtimes/podman_runtime.go +++ b/airflow/runtimes/podman_runtime.go @@ -7,6 +7,8 @@ import ( "strings" "time" + "github.com/astronomer/astro-cli/airflow/runtimes/types" + "github.com/briandowns/spinner" ) @@ -15,15 +17,27 @@ const ( projectNotRunningErrMsg = "this astro project is not running" ) +type PodmanEngine interface { + InitializeMachine(name string) error + StartMachine(name string) error + StopMachine(name string) error + RemoveMachine(name string) error + InspectMachine(name string) (*types.InspectedMachine, error) + SetMachineAsDefault(name string) error + ListMachines() ([]types.ListedMachine, error) + ListContainers() ([]types.ListedContainer, error) +} + type PodmanRuntime struct { - Engine PodmanEngine + Engine PodmanEngine + OSChecker OSChecker } // CreatePodmanRuntime creates a new PodmanRuntime using the provided PodmanEngine. // The engine allows us to interact with the external podman environment. For unit testing, // we provide a mock engine that can be used to simulate the podman environment. -func CreatePodmanRuntime(engine PodmanEngine) PodmanRuntime { - return PodmanRuntime{Engine: engine} +func CreatePodmanRuntime(engine PodmanEngine, osChecker OSChecker) PodmanRuntime { + return PodmanRuntime{Engine: engine, OSChecker: osChecker} } func (rt PodmanRuntime) Initialize() error { @@ -31,10 +45,10 @@ func (rt PodmanRuntime) Initialize() error { // we need to initialize our astro machine. // If DOCKER_HOST is already set, we assume the user already has a // workflow with podman that we don't want to interfere with. - if IsDockerHostSet() { + if isDockerHostSet() { return nil } - return rt.EnsureMachine() + return rt.ensureMachine() } func (rt PodmanRuntime) Configure() error { @@ -42,14 +56,14 @@ func (rt PodmanRuntime) Configure() error { // we need to set things up for our astro machine. // If DOCKER_HOST is already set, we assume the user already has a // workflow with podman that we don't want to interfere with. - if IsDockerHostSet() { + if isDockerHostSet() { return nil } // If the astro machine is running, we just configure it // for usage, so the regular compose commands can carry out. - if rt.AstroMachineIsRunning() { - return rt.GetAndConfigureMachineForUsage(podmanMachineName) + if rt.astroMachineIsRunning() { + return rt.getAndConfigureMachineForUsage(podmanMachineName) } // Otherwise, we return an error indicating that the project isn't running. @@ -61,20 +75,20 @@ func (rt PodmanRuntime) ConfigureOrKill() error { // we need to set things up for our astro machine. // If DOCKER_HOST is already set, we assume the user already has a // workflow with podman that we don't want to interfere with. - if IsDockerHostSet() { + if isDockerHostSet() { return nil } // If the astro machine is running, we just configure it // for usage, so the regular compose kill can carry out. // We follow up with a machine kill in the post run hook. - if rt.AstroMachineIsRunning() { - return rt.GetAndConfigureMachineForUsage(podmanMachineName) + if rt.astroMachineIsRunning() { + return rt.getAndConfigureMachineForUsage(podmanMachineName) } // The machine is already not running, // so we can just ensure its fully killed. - if err := rt.StopAndKillMachine(); err != nil { + if err := rt.stopAndKillMachine(); err != nil { return err } @@ -86,31 +100,31 @@ func (rt PodmanRuntime) ConfigureOrKill() error { func (rt PodmanRuntime) Kill() error { // If we're in podman mode, and DOCKER_HOST is set to the astro machine (in the pre-run hook), // we'll ensure that the machine is killed. - if !IsWindows() { - if !IsDockerHostSetToAstroMachine() { - return nil - } + if rt.OSChecker.IsWindows() || isDockerHostSetToAstroMachine() { + return rt.stopAndKillMachine() } - return rt.StopAndKillMachine() + return nil } -func (rt PodmanRuntime) EnsureMachine() error { +func (rt PodmanRuntime) ensureMachine() error { + // Show a spinner message while we're initializing the machine. s := spinner.New(spinnerCharSet, spinnerRefresh) s.Suffix = containerRuntimeInitMessage defer s.Stop() + // Update the message after a bit if it's still running. go func() { <-time.After(1 * time.Minute) s.Suffix = podmanInitSlowMessage }() // Check if another, non-astro Podman machine is running - nonAstroMachineName := rt.IsAnotherMachineRunning() + nonAstroMachineName := rt.isAnotherMachineRunning() // If there is another machine running, and it has no running containers, stop it. // Otherwise, we assume the user has some other project running that we don't want to interfere with. - if nonAstroMachineName != "" && isMac() { - // First, configure the other running machine for usage. - if err := rt.GetAndConfigureMachineForUsage(nonAstroMachineName); err != nil { + if nonAstroMachineName != "" && rt.OSChecker.IsMac() { + // First, configure the other running machine for usage, so we can check it for containers. + if err := rt.getAndConfigureMachineForUsage(nonAstroMachineName); err != nil { return err } @@ -136,7 +150,7 @@ func (rt PodmanRuntime) EnsureMachine() error { } // Check if our astro Podman machine exists. - machine := rt.GetAstroMachine() + machine := rt.getAstroMachine() // If the machine exists, inspect it and decide what to do. if machine != nil { @@ -149,7 +163,7 @@ func (rt PodmanRuntime) EnsureMachine() error { // If the machine is already running, // just go ahead and configure it for usage. if iMachine.State == podmanStatusRunning { - return rt.ConfigureMachineForUsage(iMachine) + return rt.configureMachineForUsage(iMachine) } // If the machine is stopped, @@ -159,7 +173,7 @@ func (rt PodmanRuntime) EnsureMachine() error { if err := rt.Engine.StartMachine(podmanMachineName); err != nil { return err } - return rt.ConfigureMachineForUsage(iMachine) + return rt.configureMachineForUsage(iMachine) } } @@ -169,21 +183,21 @@ func (rt PodmanRuntime) EnsureMachine() error { return err } - return rt.GetAndConfigureMachineForUsage(podmanMachineName) + return rt.getAndConfigureMachineForUsage(podmanMachineName) } -// StopAndKillMachine attempts to stop and kill the Podman machine. +// stopAndKillMachine attempts to stop and kill the Podman machine. // If other projects are running, it will leave the machine up. -func (rt PodmanRuntime) StopAndKillMachine() error { +func (rt PodmanRuntime) stopAndKillMachine() error { // If the machine doesn't exist, exist early. - if !rt.AstroMachineExists() { + if !rt.astroMachineExists() { return nil } // If the machine exists, and its running, we need to check // if any other projects are running. If other projects are running, // we'll leave the machine up, otherwise we stop and kill it. - if rt.AstroMachineIsRunning() { + if rt.astroMachineIsRunning() { // Get the containers that are running on our machine. containers, err := rt.Engine.ListContainers() if err != nil { @@ -224,17 +238,17 @@ func (rt PodmanRuntime) StopAndKillMachine() error { return nil } -// ConfigureMachineForUsage does two things: +// configureMachineForUsage does two things: // - Sets the DOCKER_HOST environment variable to the machine's socket path // This allows the docker compose library to function as expected. // - Sets the podman default connection to the machine // This allows the podman command to function as expected. -func (rt PodmanRuntime) ConfigureMachineForUsage(machine *InspectedMachine) error { +func (rt PodmanRuntime) configureMachineForUsage(machine *types.InspectedMachine) error { if machine == nil { return fmt.Errorf("machine does not exist") } - if !IsWindows() { + if !rt.OSChecker.IsWindows() { // Set the DOCKER_HOST environment variable for compose. dockerHost := "unix://" + machine.ConnectionInfo.PodmanSocket.Path err := os.Setenv("DOCKER_HOST", dockerHost) @@ -247,36 +261,36 @@ func (rt PodmanRuntime) ConfigureMachineForUsage(machine *InspectedMachine) erro return rt.Engine.SetMachineAsDefault(machine.Name) } -// GetAndConfigureMachineForUsage gets our astro machine +// getAndConfigureMachineForUsage gets our astro machine // then configures the host machine to use it. -func (rt PodmanRuntime) GetAndConfigureMachineForUsage(name string) error { +func (rt PodmanRuntime) getAndConfigureMachineForUsage(name string) error { machine, err := rt.Engine.InspectMachine(name) if err != nil { return err } - return rt.ConfigureMachineForUsage(machine) + return rt.configureMachineForUsage(machine) } -// GetAstroMachine gets our astro podman machine. -func (rt PodmanRuntime) GetAstroMachine() *ListedMachine { +// getAstroMachine gets our astro podman machine. +func (rt PodmanRuntime) getAstroMachine() *types.ListedMachine { machines, _ := rt.Engine.ListMachines() - return FindMachineByName(machines, podmanMachineName) + return findMachineByName(machines, podmanMachineName) } -// AstroMachineExists checks if our astro podman machine exists. -func (rt PodmanRuntime) AstroMachineExists() bool { - machine := rt.GetAstroMachine() +// astroMachineExists checks if our astro podman machine exists. +func (rt PodmanRuntime) astroMachineExists() bool { + machine := rt.getAstroMachine() return machine != nil } -// AstroMachineIsRunning checks if our astro podman machine is running. -func (rt PodmanRuntime) AstroMachineIsRunning() bool { - machine := rt.GetAstroMachine() +// astroMachineIsRunning checks if our astro podman machine is running. +func (rt PodmanRuntime) astroMachineIsRunning() bool { + machine := rt.getAstroMachine() return machine != nil && machine.Running } -// IsAnotherMachineRunning checks if another, non-astro podman machine is running. -func (rt PodmanRuntime) IsAnotherMachineRunning() string { +// isAnotherMachineRunning checks if another, non-astro podman machine is running. +func (rt PodmanRuntime) isAnotherMachineRunning() string { machines, _ := rt.Engine.ListMachines() for _, machine := range machines { if machine.Running && machine.Name != podmanMachineName { @@ -286,8 +300,8 @@ func (rt PodmanRuntime) IsAnotherMachineRunning() string { return "" } -// FindMachineByName finds a machine by name from a list of machines. -func FindMachineByName(items []ListedMachine, name string) *ListedMachine { +// findMachineByName finds a machine by name from a list of machines. +func findMachineByName(items []types.ListedMachine, name string) *types.ListedMachine { for _, item := range items { if item.Name == name { return &item @@ -296,13 +310,13 @@ func FindMachineByName(items []ListedMachine, name string) *ListedMachine { return nil } -// IsDockerHostSet checks if the DOCKER_HOST environment variable is set. -func IsDockerHostSet() bool { +// isDockerHostSet checks if the DOCKER_HOST environment variable is set. +func isDockerHostSet() bool { return os.Getenv("DOCKER_HOST") != "" } -// IsDockerHostSetToAstroMachine checks if the DOCKER_HOST environment variable +// isDockerHostSetToAstroMachine checks if the DOCKER_HOST environment variable // is pointing to the astro machine. -func IsDockerHostSetToAstroMachine() bool { +func isDockerHostSetToAstroMachine() bool { return strings.Contains(os.Getenv("DOCKER_HOST"), podmanMachineName) } diff --git a/airflow/runtimes/podman_runtime_test.go b/airflow/runtimes/podman_runtime_test.go index ac60c55f7..260fedfa1 100644 --- a/airflow/runtimes/podman_runtime_test.go +++ b/airflow/runtimes/podman_runtime_test.go @@ -2,48 +2,467 @@ package runtimes import ( "os" + "testing" + + "github.com/astronomer/astro-cli/airflow/runtimes/mocks" + "github.com/astronomer/astro-cli/airflow/runtimes/types" + "github.com/stretchr/testify/suite" "github.com/stretchr/testify/assert" ) -func (s *ContainerRuntimeSuite) TestIsDockerHostSet() { - s.Run("DOCKER_HOST is set and returns true", func() { - os.Setenv("DOCKER_HOST", "some_value") - defer os.Unsetenv("DOCKER_HOST") +var ( + mockListedMachines []types.ListedMachine + mockListedContainers []types.ListedContainer + mockInspectedAstroMachine *types.InspectedMachine + mockInspectedOtherMachine *types.InspectedMachine + mockPodmanEngine *mocks.PodmanEngine + mockPodmanOSChecker *mocks.OSChecker +) - result := IsDockerHostSet() - assert.True(s.T(), result) +type PodmanRuntimeSuite struct { + suite.Suite +} + +func TestPodmanRuntime(t *testing.T) { + suite.Run(t, new(PodmanRuntimeSuite)) +} + +// Setenv is a helper function to set an environment variable. +// It panics if an error occurs. +func (s *PodmanRuntimeSuite) Setenv(key, value string) { + if err := os.Setenv(key, value); err != nil { + panic(err) + } +} + +// Unsetenv is a helper function to unset an environment variable. +// It panics if an error occurs. +func (s *PodmanRuntimeSuite) Unsetenv(key string) { + if err := os.Unsetenv(key); err != nil { + panic(err) + } +} + +func (s *PodmanRuntimeSuite) SetupTest() { + // Reset some variables to defaults. + s.Unsetenv("DOCKER_HOST") + mockPodmanEngine = new(mocks.PodmanEngine) + mockPodmanOSChecker = new(mocks.OSChecker) + mockListedMachines = []types.ListedMachine{} + mockListedContainers = []types.ListedContainer{} + mockInspectedAstroMachine = &types.InspectedMachine{ + Name: "astro-machine", + ConnectionInfo: types.ConnectionInfo{ + PodmanSocket: types.PodmanSocket{ + Path: "/path/to/astro-machine.sock", + }, + }, + } + mockInspectedOtherMachine = &types.InspectedMachine{ + Name: "other-machine", + ConnectionInfo: types.ConnectionInfo{ + PodmanSocket: types.PodmanSocket{ + Path: "/path/to/other-machine.sock", + }, + }, + } +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeInitializeDockerHostAlreadySet() { + s.Run("DOCKER_HOST is already set, abort initialization", func() { + // Set up mocks. + s.Setenv("DOCKER_HOST", "some_value") + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Initialize() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) }) +} +func (s *PodmanRuntimeSuite) TestPodmanRuntimeInitialize() { + s.Run("No machines running on mac, initialize podman", func() { + // Set up mocks. + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil) + mockPodmanEngine.On("InitializeMachine", podmanMachineName).Return(nil) + mockPodmanEngine.On("InspectMachine", podmanMachineName).Return(mockInspectedAstroMachine, nil) + mockPodmanEngine.On("SetMachineAsDefault", podmanMachineName).Return(nil) + mockPodmanOSChecker.On("IsWindows").Return(false) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Initialize() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeInitializeWindows() { + s.Run("No machines running on windows, initialize podman", func() { + // Set up mocks. + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil) + mockPodmanEngine.On("InitializeMachine", podmanMachineName).Return(nil) + mockPodmanEngine.On("InspectMachine", podmanMachineName).Return(mockInspectedAstroMachine, nil) + mockPodmanEngine.On("SetMachineAsDefault", podmanMachineName).Return(nil) + mockPodmanOSChecker.On("IsWindows").Return(true) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Initialize() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeInitializeWithAnotherMachineRunningOnMac() { + s.Run("Another machine running on mac, stop it and start the astro machine", func() { + // Set up mocks. + mockListedMachines = []types.ListedMachine{ + { + Name: "other-machine", + Running: true, + }, + } + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil).Once() + mockPodmanEngine.On("InspectMachine", mockListedMachines[0].Name).Return(mockInspectedOtherMachine, nil).Once() + mockPodmanEngine.On("SetMachineAsDefault", mockListedMachines[0].Name).Return(nil).Once() + mockPodmanEngine.On("ListContainers").Return(mockListedContainers, nil) + mockPodmanEngine.On("StopMachine", mockListedMachines[0].Name).Return(nil) + mockPodmanEngine.On("ListMachines").Return([]types.ListedMachine{}, nil).Once() + mockPodmanEngine.On("InitializeMachine", podmanMachineName).Return(nil) + mockPodmanEngine.On("InspectMachine", podmanMachineName).Return(mockInspectedAstroMachine, nil).Once() + mockPodmanEngine.On("SetMachineAsDefault", podmanMachineName).Return(nil).Once() + mockPodmanOSChecker.On("IsMac").Return(true) + mockPodmanOSChecker.On("IsWindows").Return(false) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Initialize() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeInitializeWithAnotherMachineRunningWithExistingContainersOnMac() { + s.Run("Another machine running on mac, that has existing containers, return error message", func() { + // Set up mocks. + mockListedMachines = []types.ListedMachine{ + { + Name: "other-machine", + Running: true, + }, + } + mockListedContainers = []types.ListedContainer{ + { + Name: "container1", + Labels: map[string]string{composeProjectLabel: "project1"}, + }, + } + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil).Once() + mockPodmanEngine.On("InspectMachine", mockListedMachines[0].Name).Return(mockInspectedOtherMachine, nil).Once() + mockPodmanEngine.On("SetMachineAsDefault", mockListedMachines[0].Name).Return(nil).Once() + mockPodmanEngine.On("ListContainers").Return(mockListedContainers, nil) + mockPodmanOSChecker.On("IsMac").Return(true) + mockPodmanOSChecker.On("IsWindows").Return(false) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Initialize() + assert.Error(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeInitializeAstroMachineAlreadyRunning() { + s.Run("Astro machine is already running, just configure it for usage", func() { + // Set up mocks. + mockListedMachines = []types.ListedMachine{ + { + Name: "astro-machine", + Running: true, + }, + } + mockInspectedAstroMachine.State = podmanStatusRunning + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil) + mockPodmanEngine.On("InspectMachine", podmanMachineName).Return(mockInspectedAstroMachine, nil) + mockPodmanEngine.On("SetMachineAsDefault", podmanMachineName).Return(nil) + mockPodmanOSChecker.On("IsWindows").Return(false) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Initialize() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeInitializeAstroMachineExistsButStopped() { + s.Run("Astro machine already exists, but is in stopped state, start and configure it for usage", func() { + // Set up mocks. + mockListedMachines = []types.ListedMachine{ + { + Name: "astro-machine", + Running: true, + }, + } + mockInspectedAstroMachine.State = podmanStatusStopped + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil) + mockPodmanEngine.On("InspectMachine", podmanMachineName).Return(mockInspectedAstroMachine, nil) + mockPodmanEngine.On("SetMachineAsDefault", podmanMachineName).Return(nil) + mockPodmanEngine.On("StartMachine", podmanMachineName).Return(nil) + mockPodmanOSChecker.On("IsWindows").Return(false) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Initialize() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeConfigureDockerHostAlreadySet() { + s.Run("DOCKER_HOST is already set, abort configure", func() { + // Set up mocks. + s.Setenv("DOCKER_HOST", "some_value") + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Configure() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeConfigureAstroMachineRunning() { + s.Run("Astro machine is already running, so configure it for usage", func() { + // Set up mocks. + mockListedMachines = []types.ListedMachine{ + { + Name: "astro-machine", + Running: true, + }, + } + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil) + mockPodmanEngine.On("InspectMachine", podmanMachineName).Return(mockInspectedAstroMachine, nil) + mockPodmanEngine.On("SetMachineAsDefault", podmanMachineName).Return(nil) + mockPodmanOSChecker.On("IsWindows").Return(false) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Configure() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeConfigureAstroMachineNotRunning() { + s.Run("Astro machine is not already running, so return error message", func() { + // Set up mocks. + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Configure() + assert.Error(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeConfigureOrKillDockerHostAlreadySet() { + s.Run("Astro machine is not already running, so return error message", func() { + // Set up mocks. + s.Setenv("DOCKER_HOST", "some_value") + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.ConfigureOrKill() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeConfigureOrKillAstroMachineRunning() { + s.Run("Astro machine is already running, so configure it for usage", func() { + // Set up mocks. + mockListedMachines = []types.ListedMachine{ + { + Name: "astro-machine", + Running: true, + }, + } + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil) + mockPodmanEngine.On("InspectMachine", podmanMachineName).Return(mockInspectedAstroMachine, nil) + mockPodmanEngine.On("SetMachineAsDefault", podmanMachineName).Return(nil) + mockPodmanOSChecker.On("IsWindows").Return(false) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.ConfigureOrKill() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeConfigureOrKillAstroMachineStopped() { + s.Run("Astro machine is stopped, proceed to kill it", func() { + // Set up mocks. + mockListedMachines = []types.ListedMachine{ + { + Name: "astro-machine", + Running: false, + }, + } + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil) + mockPodmanEngine.On("RemoveMachine", podmanMachineName).Return(nil) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.ConfigureOrKill() + assert.Error(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeConfigureOrKillAstroMachineNotRunning() { + s.Run("Astro machine is not already running, so return error message", func() { + // Set up mocks. + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.ConfigureOrKill() + assert.Error(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeKillOtherProjectRunning() { + s.Run("Astro machine running, but another project is still running, so do not stop and kill machine", func() { + // Set up mocks. + s.Setenv("DOCKER_HOST", podmanMachineName) + mockListedMachines = []types.ListedMachine{ + { + Name: "astro-machine", + Running: true, + }, + } + mockListedContainers = []types.ListedContainer{ + { + Name: "container1", + Labels: map[string]string{composeProjectLabel: "project1"}, + }, + } + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil) + mockPodmanOSChecker.On("IsWindows").Return(false) + mockPodmanEngine.On("ListContainers").Return(mockListedContainers, nil) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Kill() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestPodmanRuntimeKill() { + s.Run("Astro machine running, no other projects running, so stop and kill the machine", func() { + // Set up mocks. + s.Setenv("DOCKER_HOST", podmanMachineName) + mockListedMachines = []types.ListedMachine{ + { + Name: "astro-machine", + Running: true, + }, + } + mockPodmanEngine.On("ListMachines").Return(mockListedMachines, nil) + mockPodmanOSChecker.On("IsWindows").Return(false) + mockPodmanEngine.On("ListContainers").Return(mockListedContainers, nil) + mockPodmanEngine.On("StopMachine", podmanMachineName).Return(nil) + mockPodmanEngine.On("RemoveMachine", podmanMachineName).Return(nil) + // Create the runtime with our mock engine and os checker. + rt := CreatePodmanRuntime(mockPodmanEngine, mockPodmanOSChecker) + // Run our test and assert expectations. + err := rt.Kill() + assert.Nil(s.T(), err) + mockPodmanEngine.AssertExpectations(s.T()) + mockPodmanOSChecker.AssertExpectations(s.T()) + }) +} + +func (s *PodmanRuntimeSuite) TestFindMachineByName() { + s.Run("Returns machine when name matches", func() { + machines := []types.ListedMachine{ + {Name: "astro-machine"}, + {Name: "other-machine"}, + } + result := findMachineByName(machines, "astro-machine") + assert.NotNil(s.T(), result) + assert.Equal(s.T(), "astro-machine", result.Name) + }) + + s.Run("Returns nil when no match found", func() { + machines := []types.ListedMachine{ + {Name: "astro-machine"}, + {Name: "other-machine"}, + } + result := findMachineByName(machines, "non-existent-machine") + assert.Nil(s.T(), result) + }) + + s.Run("Returns nil when list is empty", func() { + var machines []types.ListedMachine + result := findMachineByName(machines, "astro-machine") + assert.Nil(s.T(), result) + }) +} + +func (s *PodmanRuntimeSuite) TestIsDockerHostSet() { s.Run("DOCKER_HOST is set and returns true", func() { - os.Unsetenv("DOCKER_HOST") + s.Setenv("DOCKER_HOST", "some_value") + result := isDockerHostSet() + assert.True(s.T(), result) + }) - result := IsDockerHostSet() + s.Run("DOCKER_HOST is set and returns true", func() { + s.Unsetenv("DOCKER_HOST") + result := isDockerHostSet() assert.False(s.T(), result) }) } -func (s *ContainerRuntimeSuite) TestIsDockerHostSetToAstroMachine() { +func (s *PodmanRuntimeSuite) TestIsDockerHostSetToAstroMachine() { s.Run("DOCKER_HOST is set to astro-machine and returns true", func() { - os.Setenv("DOCKER_HOST", "unix:///path/to/astro-machine.sock") - defer os.Unsetenv("DOCKER_HOST") - - result := IsDockerHostSetToAstroMachine() + s.Setenv("DOCKER_HOST", "unix:///path/to/astro-machine.sock") + result := isDockerHostSetToAstroMachine() assert.True(s.T(), result) }) s.Run("DOCKER_HOST is set to other-machine and returns false", func() { - os.Setenv("DOCKER_HOST", "unix:///path/to/other-machine.sock") - defer os.Unsetenv("DOCKER_HOST") - - result := IsDockerHostSetToAstroMachine() + s.Setenv("DOCKER_HOST", "unix:///path/to/other-machine.sock") + result := isDockerHostSetToAstroMachine() assert.False(s.T(), result) }) s.Run("DOCKER_HOST is not set and returns false", func() { - os.Unsetenv("DOCKER_HOST") - - result := IsDockerHostSetToAstroMachine() + s.Unsetenv("DOCKER_HOST") + result := isDockerHostSetToAstroMachine() assert.False(s.T(), result) }) } diff --git a/airflow/runtimes/types/podman.go b/airflow/runtimes/types/podman.go new file mode 100644 index 000000000..0863e4be6 --- /dev/null +++ b/airflow/runtimes/types/podman.go @@ -0,0 +1,35 @@ +package types + +// ListedMachine contains information about a Podman machine +// as it is provided from the `podman machine ls --format json` command. +type ListedMachine struct { + Name string + Running bool + Starting bool + LastUp string +} + +// PodmanSocket contains the path to the Podman socket. +type PodmanSocket struct { + Path string +} + +// ConnectionInfo contains information about the connection to a Podman machine. +type ConnectionInfo struct { + PodmanSocket PodmanSocket +} + +// InspectedMachine contains information about a Podman machine +// as it is provided from the `podman machine inspect` command. +type InspectedMachine struct { + Name string + ConnectionInfo ConnectionInfo + State string +} + +// ListedContainer contains information about a Podman container +// as it is provided from the `podman ps --format json` command. +type ListedContainer struct { + Name string + Labels map[string]string +} diff --git a/cmd/airflow_hooks.go b/cmd/airflow_hooks.go index 810bebb13..95ebcef16 100644 --- a/cmd/airflow_hooks.go +++ b/cmd/airflow_hooks.go @@ -36,7 +36,8 @@ func EnsureRuntime(cmd *cobra.Command, args []string) error { return err } - if runtimes.IsWindows() { + osChecker := new(runtimes.DefaultOSChecker) + if osChecker.IsWindows() { pluginsDir := filepath.Join(config.WorkingPath, "plugins") if _, err := os.Stat(pluginsDir); os.IsNotExist(err) { err := os.MkdirAll(pluginsDir, os.ModePerm) diff --git a/config/config.go b/config/config.go index 77c414481..2b2a9452f 100644 --- a/config/config.go +++ b/config/config.go @@ -87,7 +87,7 @@ var ( DisableEnvObjects: newCfg("disable_env_objects", "false"), AutoSelect: newCfg("auto_select", "false"), PodmanCPU: newCfg("podman.cpu", "2"), - PodmanMEM: newCfg("podman.mem", "2048"), + PodmanMemory: newCfg("podman.mem", "2048"), } // viperHome is the viper object in the users home directory diff --git a/config/types.go b/config/types.go index 111b9b522..fc79bd3cf 100644 --- a/config/types.go +++ b/config/types.go @@ -46,7 +46,7 @@ type cfgs struct { DisableEnvObjects cfg AutoSelect cfg PodmanCPU cfg - PodmanMEM cfg + PodmanMemory cfg } // Creates a new cfg struct