diff --git a/cli/cmd/install_docker.go b/cli/cmd/install_docker.go new file mode 100644 index 00000000..e93daec0 --- /dev/null +++ b/cli/cmd/install_docker.go @@ -0,0 +1,84 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cmd + +import ( + "fmt" + + packageio "github.com/codesphere-cloud/cs-go/pkg/io" + "github.com/codesphere-cloud/oms/internal/env" + "github.com/codesphere-cloud/oms/internal/installer/docker" + "github.com/codesphere-cloud/oms/internal/installer/node" + "github.com/codesphere-cloud/oms/internal/util" + "github.com/spf13/cobra" +) + +// InstallDockerCmd represents the docker install command. +type InstallDockerCmd struct { + cmd *cobra.Command + Opts InstallDockerOpts + Env env.Env + FileWriter util.FileIO +} + +// InstallDockerOpts holds all CLI flags for the docker sub-command. +type InstallDockerOpts struct { + *GlobalOptions + + // SSH / remote host + // SSHKeyPath string + SSHUser string + SSHHost string + SSHPort int +} + +func (c *InstallDockerCmd) RunE(_ *cobra.Command, args []string) error { + return c.InstallDocker() +} + +func AddInstallDockerCmd(install *cobra.Command, opts *GlobalOptions) { + pg := InstallDockerCmd{ + cmd: &cobra.Command{ + Use: "docker", + Short: "Install Docker on a remote host", + Long: packageio.Long(`Install Docker (if not already present) on a remote host accessed via SSH.`), + Example: formatExamples("install docker", []packageio.Example{}), + }, + Opts: InstallDockerOpts{GlobalOptions: opts}, + Env: env.NewEnv(), + FileWriter: util.NewFilesystemWriter(), + } + + f := pg.cmd.Flags() + + // SSH flags + f.StringVar(&pg.Opts.SSHHost, "ssh-host", "", "Remote host IP or hostname (required)") + f.IntVar(&pg.Opts.SSHPort, "ssh-port", 22, "SSH port on the remote host") + f.StringVar(&pg.Opts.SSHUser, "ssh-user", "root", "SSH username") + // f.StringVar(&pg.Opts.SSHKeyPath, "ssh-key-path", "", "Path to SSH private key") + + _ = pg.cmd.MarkFlagRequired("ssh-host") + + AddCmd(install, pg.cmd) + pg.cmd.RunE = pg.RunE +} + +func (c *InstallDockerCmd) InstallDocker() error { + node := &node.Node{ + Name: "node", + ExternalIP: c.Opts.SSHHost, + + NodeClient: node.NewSSHNodeClient(true), + } + + dockerClient := docker.New("root", node) + if !dockerClient.IsInstalled() { + err := dockerClient.InstallWithApt() + if err != nil { + return fmt.Errorf("failed to install Docker: %w", err) + } + } + + return nil +} diff --git a/internal/installer/docker/docker.go b/internal/installer/docker/docker.go new file mode 100644 index 00000000..c066ccde --- /dev/null +++ b/internal/installer/docker/docker.go @@ -0,0 +1,165 @@ +// Copyright (c) Codesphere Inc. +// SPDX-License-Identifier: Apache-2.0 + +// package docker installs docker on a remote host +package docker + +import ( + "fmt" + "log" + + "github.com/codesphere-cloud/oms/internal/installer/node" +) + +// DockerManager abstracts Docker operations on a remote host. +// The interface makes the command easy to unit-test with mocks. +// +//mockery:generate: true +type DockerManager interface { + // IsInstalled checks whether the docker binary is available on the remote host. + IsInstalled() bool + + // Install installs Docker Engine on the remote host using Docker's official apt repository. + InstallWithApt() error +} + +type dockerManager struct { + remoteUser string + remoteNode *node.Node +} + +func New(user string, node *node.Node) DockerManager { + return &dockerManager{ + remoteUser: user, + remoteNode: node, + } +} + +// IsInstalled checks whether the docker binary is available on the remote host. +func (d *dockerManager) IsInstalled() bool { + err := d.remoteNode.RunSSHCommand(d.remoteUser, "command -v docker") + + return err == nil +} + +// InstallWithApt installs Docker Engine on the remote host using Docker's official apt repository +// see https://docs.docker.com/engine/install/ubuntu/#install-using-the-repository +func (d *dockerManager) InstallWithApt() error { + log.Println("Installing Docker on remote host via apt...") + + if err := d.removeConflictingPackages(); err != nil { + return fmt.Errorf("failed to remove conflicting docker packages") + } + + if err := d.installAptPrerequisites(); err != nil { + return fmt.Errorf("failed to install docker apt prequisites") + } + + if err := d.addDockerRepository(); err != nil { + return fmt.Errorf("failed to add docker apt repository") + } + + if err := d.installDockerPackages(); err != nil { + return fmt.Errorf("failed to install docker packages") + } + + if err := d.startDaemon(); err != nil { + return fmt.Errorf("failed to start docker daemon") + } + + return nil +} + +// removeConflictingPackages removes any unofficial Docker packages that may +// conflict with the official Docker Engine packages. The list matches what the +// official docs specify. +func (d *dockerManager) removeConflictingPackages() error { + cmd := "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true" + if err := d.remoteNode.RunSSHCommand(d.remoteUser, cmd); err != nil { + return fmt.Errorf("failed to remove conflicting packages: %w", err) + } + + return nil +} + +// installAptPrerequisites ensures ca-certificates and curl are present; +// these are required before the Docker GPG key and repo can be added. +func (d *dockerManager) installAptPrerequisites() error { + log.Println("Installing Docker apt prerequisites...") + for _, cmd := range []string{ + "apt-get update -qq", + "apt-get install -y -qq ca-certificates curl", + } { + if err := d.remoteNode.RunSSHCommand(d.remoteUser, cmd); err != nil { + return fmt.Errorf("failed to install apt prerequisites (%q): %w", cmd, err) + } + } + + return nil +} + +// addDockerRepository adds Docker's official GPG key and apt repository, +// exactly as described in the official Ubuntu install docs. +func (d *dockerManager) addDockerRepository() error { + log.Println("Adding Docker apt repository...") + dockerAddRepoCmd := fmt.Sprintf( + "sudo install -m 0755 -d /etc/apt/keyrings && " + + "sudo curl -fsSL https://download.docker.com/linux/ubuntu/gpg -o /etc/apt/keyrings/docker.asc && " + + "sudo chmod a+r /etc/apt/keyrings/docker.asc && " + + "SUITE=$(. /etc/os-release && echo \"${UBUNTU_CODENAME:-$VERSION_CODENAME}\") && " + + "ARCH=$(dpkg --print-architecture) && " + + "sudo tee /etc/apt/sources.list.d/docker.sources > /dev/null </dev/null || true"). + Return(errors.New("ssh error")) + + err := manager.InstallWithApt() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to remove conflicting docker packages")) + }) + + It("fails when installing apt prerequisites fails", func() { + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get update -qq"). + Return(errors.New("apt error")) + + err := manager.InstallWithApt() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install docker apt prequisites")) + }) + + It("fails when adding the docker repository fails", func() { + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get update -qq"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq ca-certificates curl"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", mock.MatchedBy(isAddRepoCommand)). + Return(errors.New("repo error")). + Once() + + err := manager.InstallWithApt() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to add docker apt repository")) + }) + + It("fails when installing docker packages fails", func() { + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get update -qq"). + Return(nil). + Twice() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq ca-certificates curl"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", mock.MatchedBy(isAddRepoCommand)). + Return(nil). + Once() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin"). + Return(errors.New("install error")) + + err := manager.InstallWithApt() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to install docker packages")) + }) + + It("fails when starting the docker daemon fails", func() { + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get update -qq"). + Return(nil). + Twice() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq ca-certificates curl"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", mock.MatchedBy(isAddRepoCommand)). + Return(nil). + Once() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "systemctl start docker"). + Return(errors.New("daemon error")) + + err := manager.InstallWithApt() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to start docker daemon")) + }) + + It("succeeds when all steps succeed", func() { + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get remove -y docker.io docker-compose docker-compose-v2 docker-doc podman-docker containerd runc 2>/dev/null || true"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get update -qq"). + Return(nil). + Twice() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq ca-certificates curl"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", mock.MatchedBy(isAddRepoCommand)). + Return(nil). + Once() + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "apt-get install -y -qq docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "systemctl start docker"). + Return(nil) + nodeClient.EXPECT(). + RunCommand(remoteNode, "ubuntu", "systemctl enable docker"). + Return(nil) + + err := manager.InstallWithApt() + Expect(err).ToNot(HaveOccurred()) + }) + }) +}) diff --git a/internal/installer/docker/mocks.go b/internal/installer/docker/mocks.go new file mode 100644 index 00000000..5a4ec8f6 --- /dev/null +++ b/internal/installer/docker/mocks.go @@ -0,0 +1,124 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package docker + +import ( + mock "github.com/stretchr/testify/mock" +) + +// NewMockDockerManager creates a new instance of MockDockerManager. 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 NewMockDockerManager(t interface { + mock.TestingT + Cleanup(func()) +}) *MockDockerManager { + mock := &MockDockerManager{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MockDockerManager is an autogenerated mock type for the DockerManager type +type MockDockerManager struct { + mock.Mock +} + +type MockDockerManager_Expecter struct { + mock *mock.Mock +} + +func (_m *MockDockerManager) EXPECT() *MockDockerManager_Expecter { + return &MockDockerManager_Expecter{mock: &_m.Mock} +} + +// InstallWithApt provides a mock function for the type MockDockerManager +func (_mock *MockDockerManager) InstallWithApt() error { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for InstallWithApt") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func() error); ok { + r0 = returnFunc() + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MockDockerManager_InstallWithApt_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'InstallWithApt' +type MockDockerManager_InstallWithApt_Call struct { + *mock.Call +} + +// InstallWithApt is a helper method to define mock.On call +func (_e *MockDockerManager_Expecter) InstallWithApt() *MockDockerManager_InstallWithApt_Call { + return &MockDockerManager_InstallWithApt_Call{Call: _e.mock.On("InstallWithApt")} +} + +func (_c *MockDockerManager_InstallWithApt_Call) Run(run func()) *MockDockerManager_InstallWithApt_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDockerManager_InstallWithApt_Call) Return(err error) *MockDockerManager_InstallWithApt_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MockDockerManager_InstallWithApt_Call) RunAndReturn(run func() error) *MockDockerManager_InstallWithApt_Call { + _c.Call.Return(run) + return _c +} + +// IsInstalled provides a mock function for the type MockDockerManager +func (_mock *MockDockerManager) IsInstalled() bool { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for IsInstalled") + } + + var r0 bool + if returnFunc, ok := ret.Get(0).(func() bool); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(bool) + } + return r0 +} + +// MockDockerManager_IsInstalled_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'IsInstalled' +type MockDockerManager_IsInstalled_Call struct { + *mock.Call +} + +// IsInstalled is a helper method to define mock.On call +func (_e *MockDockerManager_Expecter) IsInstalled() *MockDockerManager_IsInstalled_Call { + return &MockDockerManager_IsInstalled_Call{Call: _e.mock.On("IsInstalled")} +} + +func (_c *MockDockerManager_IsInstalled_Call) Run(run func()) *MockDockerManager_IsInstalled_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockDockerManager_IsInstalled_Call) Return(b bool) *MockDockerManager_IsInstalled_Call { + _c.Call.Return(b) + return _c +} + +func (_c *MockDockerManager_IsInstalled_Call) RunAndReturn(run func() bool) *MockDockerManager_IsInstalled_Call { + _c.Call.Return(run) + return _c +}