diff --git a/pkg/configurer/enterpriselinux/el.go b/pkg/configurer/enterpriselinux/el.go index d7f4dc03..a631d741 100644 --- a/pkg/configurer/enterpriselinux/el.go +++ b/pkg/configurer/enterpriselinux/el.go @@ -73,8 +73,12 @@ gpgkey=%s if err := c.InstallPackage(h, "containerd.io"); err != nil { return fmt.Errorf("package manager could not install containerd.io") } - if err := c.InstallPackage(h, "docker-ee"); err != nil { - return fmt.Errorf("package manager could not install docker-ee") + installCmd, cmdErr := configurer.MCRInstallCommand(configurer.Yum, engineConfig) + if cmdErr != nil { + return fmt.Errorf("could not build MCR install command: %w", cmdErr) + } + if err := h.Exec(installCmd, exec.Sudo(h)); err != nil { + return fmt.Errorf("package manager could not install docker-ee: %w", err) } if err := c.EnableMCR(h, engineConfig); err != nil { diff --git a/pkg/configurer/linux.go b/pkg/configurer/linux.go index 6178e26a..4b80bbaf 100644 --- a/pkg/configurer/linux.go +++ b/pkg/configurer/linux.go @@ -26,6 +26,79 @@ const ( SbinPath = `PATH=/usr/local/sbin:/usr/sbin:/sbin:$PATH` ) +// mcrPackage is the Mirantis Container Runtime package name. It is identical +// across the rpm and deb repositories on repos.mirantis.com. +const mcrPackage = "docker-ee" + +// PackageManager identifies the package manager a Linux family installs MCR with. +type PackageManager int + +const ( + // Yum is dnf/yum, on the enterprise linux family. + Yum PackageManager = iota + // AptGet is apt-get, on debian and ubuntu. + AptGet + // Zypper is zypper, on SLES. + Zypper +) + +// ErrUnknownPackageManager is returned for a PackageManager this package does +// not know how to build a command for. +var ErrUnknownPackageManager = errors.New("unknown package manager") + +// MCRInstallCommand returns the command that installs the MCR runtime package. +// +// These commands are issued directly rather than through rig's InstallPackage, +// which hardcodes ` install -y ` and offers no way to pass +// additional arguments. SLES already had to bypass it for --allow-vendor-change +// (PRODENG-3623); installRecommends needs the same freedom on every family, so +// all three are built here instead, in one place, rather than three. +// +// When engineConfig.InstallRecommends is set, the manager is instructed to +// install recommended packages regardless of the host's configured default: +// +// - yum/dnf: --setopt=install_weak_deps=True +// - apt-get: -o APT::Install-Recommends=true, the configuration item behind +// apt's --no-install-recommends flag +// - zypper: --recommends, "install also recommended packages in addition to +// the required ones" +// +// Which packages those are is decided by the repository metadata, not here. See +// PRODENG-3641. +func MCRInstallCommand(manager PackageManager, engineConfig commonconfig.MCRConfig) (string, error) { + switch manager { + case Yum: + args := []string{"yum", "install", "-y"} + if engineConfig.InstallRecommends { + args = append(args, "--setopt=install_weak_deps=True") + } + + return strings.Join(append(args, mcrPackage), " "), nil + case AptGet: + // -q matches what rig's InstallPackage ran, so the default command is + // unchanged. rig also ran `apt-get update` here; InstallMCR already does + // that explicitly above, so it is not repeated. + args := []string{"DEBIAN_FRONTEND=noninteractive", "apt-get", "install", "-y", "-q"} + if engineConfig.InstallRecommends { + args = append(args, "-o", "APT::Install-Recommends=true") + } + + return strings.Join(append(args, mcrPackage), " "), nil + case Zypper: + // --allow-vendor-change is unconditional on SLES: cloud images ship a + // SUSE-vendor containerd that the Mirantis packages must replace, and + // zypper otherwise cancels non-interactively. See PRODENG-3623. + args := []string{"zypper", "-n", "install", "-y", "--allow-vendor-change"} + if engineConfig.InstallRecommends { + args = append(args, "--recommends") + } + + return strings.Join(append(args, mcrPackage), " "), nil + default: + return "", fmt.Errorf("%w: %d", ErrUnknownPackageManager, manager) + } +} + var ErrLinuxMCRInstall = errors.New("failed to install MCR on linux") // LinuxConfigurer is a generic linux host configurer. diff --git a/pkg/configurer/mcr_install_command_test.go b/pkg/configurer/mcr_install_command_test.go new file mode 100644 index 00000000..7c68431d --- /dev/null +++ b/pkg/configurer/mcr_install_command_test.go @@ -0,0 +1,79 @@ +package configurer_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/Mirantis/launchpad/pkg/configurer" + commonconfig "github.com/Mirantis/launchpad/pkg/product/common/config" +) + +// TestMCRInstallCommand pins the install command for every Linux family, with +// and without installRecommends. +// +// docker-ee declares docker-ee-cli and cri-dockerd-ee as recommended packages +// rather than hard requirements. Package managers install recommended packages +// by default, so the unset case must stay exactly what launchpad ran before: +// turning recommends on for everyone would change what every existing cluster +// installs. The set case must pass each manager's own opt-in. See PRODENG-3641. +func TestMCRInstallCommand(t *testing.T) { + for _, tc := range []struct { + name string + manager configurer.PackageManager + recommen bool + expected string + }{ + { + name: "yum default is unchanged from the previous behaviour", + manager: configurer.Yum, + expected: "yum install -y docker-ee", + }, + { + name: "yum opts in with setopt", + manager: configurer.Yum, + recommen: true, + expected: "yum install -y --setopt=install_weak_deps=True docker-ee", + }, + { + name: "apt-get default is unchanged from the previous behaviour", + manager: configurer.AptGet, + expected: "DEBIAN_FRONTEND=noninteractive apt-get install -y -q docker-ee", + }, + { + name: "apt-get opts in with the Install-Recommends config item", + manager: configurer.AptGet, + recommen: true, + expected: "DEBIAN_FRONTEND=noninteractive apt-get install -y -q -o APT::Install-Recommends=true docker-ee", + }, + { + // --allow-vendor-change must survive in both cases: without it zypper + // cancels non-interactively on SLES cloud images. See PRODENG-3623. + name: "zypper keeps allow-vendor-change when recommends is unset", + manager: configurer.Zypper, + expected: "zypper -n install -y --allow-vendor-change docker-ee", + }, + { + name: "zypper opts in with --recommends alongside allow-vendor-change", + manager: configurer.Zypper, + recommen: true, + expected: "zypper -n install -y --allow-vendor-change --recommends docker-ee", + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := configurer.MCRInstallCommand(tc.manager, + commonconfig.MCRConfig{InstallRecommends: tc.recommen}) + require.NoError(t, err) + require.Equal(t, tc.expected, got) + }) + } +} + +// TestMCRInstallCommandUnknownManager covers the error path, so an unhandled +// family fails loudly rather than returning an empty command that would be +// executed as a no-op. +func TestMCRInstallCommandUnknownManager(t *testing.T) { + got, err := configurer.MCRInstallCommand(configurer.PackageManager(99), commonconfig.MCRConfig{}) + require.ErrorIs(t, err, configurer.ErrUnknownPackageManager) + require.Empty(t, got) +} diff --git a/pkg/configurer/sles/sles.go b/pkg/configurer/sles/sles.go index b1fa12ca..cd538352 100644 --- a/pkg/configurer/sles/sles.go +++ b/pkg/configurer/sles/sles.go @@ -107,7 +107,11 @@ func (c Configurer) InstallMCR(h os.Host, engineConfig commonconfig.MCRConfig) e if err := h.Exec("zypper -n install -y --allow-vendor-change containerd.io", exec.Sudo(h)); err != nil { return fmt.Errorf("package manager could not install containerd.io: %w", err) } - if err := h.Exec("zypper -n install -y --allow-vendor-change docker-ee", exec.Sudo(h)); err != nil { + installCmd, cmdErr := configurer.MCRInstallCommand(configurer.Zypper, engineConfig) + if cmdErr != nil { + return fmt.Errorf("could not build MCR install command: %w", cmdErr) + } + if err := h.Exec(installCmd, exec.Sudo(h)); err != nil { return fmt.Errorf("package manager could not install docker-ee: %w", err) } diff --git a/pkg/configurer/ubuntu/ubuntu.go b/pkg/configurer/ubuntu/ubuntu.go index 6cd212af..73dc7e83 100644 --- a/pkg/configurer/ubuntu/ubuntu.go +++ b/pkg/configurer/ubuntu/ubuntu.go @@ -80,8 +80,12 @@ Signed-by: /usr/share/keyrings/mirantis-archive-keyring.gpg if err := c.InstallPackage(h, "containerd.io"); err != nil { return fmt.Errorf("package manager could not install containerd.io") } - if err := c.InstallPackage(h, "docker-ee"); err != nil { - return fmt.Errorf("package manager could not install docker-ee") + installCmd, cmdErr := configurer.MCRInstallCommand(configurer.AptGet, engineConfig) + if cmdErr != nil { + return fmt.Errorf("could not build MCR install command: %w", cmdErr) + } + if err := h.Exec(installCmd, exec.Sudo(h)); err != nil { + return fmt.Errorf("package manager could not install docker-ee: %w", err) } if err := c.EnableMCR(h, engineConfig); err != nil { diff --git a/pkg/product/common/config/mcr_config.go b/pkg/product/common/config/mcr_config.go index f1bf7bff..1b555c27 100644 --- a/pkg/product/common/config/mcr_config.go +++ b/pkg/product/common/config/mcr_config.go @@ -31,6 +31,29 @@ type MCRConfig struct { SwarmInstallFlags Flags `yaml:"swarmInstallFlags,omitempty,flow"` SwarmUpdateCommands []string `yaml:"swarmUpdateCommands,omitempty,flow"` + // InstallRecommends tells the package manager to install the runtime's + // recommended packages even when the host is configured not to. + // + // docker-ee declares docker-ee-cli and cri-dockerd-ee as recommended + // packages (rpm Recommends / deb Recommends), not as hard requirements. + // Package managers install recommended packages by default, so this is + // normally unnecessary. Hardening baselines commonly turn that off -- + // install_weak_deps=false for dnf/yum, APT::Install-Recommends "false" for + // apt, solver.onlyRequires for zypper -- and the runtime then installs + // without its CLI, leaving later docker commands to fail on a host that + // otherwise looks correctly installed. See PRODENG-3641. + // + // Note the set of recommended packages is defined by the repository + // metadata, not by launchpad, and differs by platform. On rpm hosts it is + // docker-ee-cli and cri-dockerd-ee. On deb hosts it additionally includes + // ca-certificates, docker-ee-rootless-extras, git, a kernel package, + // libltdl7, pigz, procps and xz-utils. + // + // Linux only. Windows installs MCR through install.ps1 from a single archive + // that already contains the CLI; there is no package manager and no + // equivalent concept, so this value is ignored on Windows hosts. + InstallRecommends bool `yaml:"installRecommends,omitempty"` + Metadata *MCRMetadata `yaml:"-"` } diff --git a/pkg/product/common/config/mcr_config_test.go b/pkg/product/common/config/mcr_config_test.go index 977aa3b4..70ab030f 100644 --- a/pkg/product/common/config/mcr_config_test.go +++ b/pkg/product/common/config/mcr_config_test.go @@ -39,3 +39,36 @@ func TestSwarmUpdateCommands(t *testing.T) { require.Equal(t, 1, slices.Index(cfg.SwarmUpdateCommands, "command2")) require.Equal(t, 2, slices.Index(cfg.SwarmUpdateCommands, "command3")) } + +func TestMCRConfig_InstallRecommends(t *testing.T) { + // The yaml key is the user-facing contract: a mismatch here would silently + // ignore the setting and leave the runtime installed without its CLI on a + // host that disables weak dependencies. See PRODENG-3641. + for _, tc := range []struct { + name string + yaml string + expected bool + }{ + { + name: "absent defaults to false", + yaml: "channel: stable", + expected: false, + }, + { + name: "installRecommends true is honoured", + yaml: "channel: stable\ninstallRecommends: true", + expected: true, + }, + { + name: "installRecommends false is honoured", + yaml: "channel: stable\ninstallRecommends: false", + expected: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + cfg := commonconfig.MCRConfig{} + require.NoError(t, yaml.Unmarshal([]byte(tc.yaml), &cfg)) + require.Equal(t, tc.expected, cfg.InstallRecommends) + }) + } +}