From 5c61c8fda8827385292ac54f7a4c6e1add576302 Mon Sep 17 00:00:00 2001 From: James Nesbitt Date: Sat, 8 Aug 2026 17:29:29 +0300 Subject: [PATCH] feat(mcr): add optional installRecommends, run package managers directly Alternative to the installCLI approach on PRODENG-3641-install-cli. This addresses the mechanism rather than one package. docker-ee declares docker-ee-cli and cri-dockerd-ee as recommended packages (rpm Recommends / deb Recommends), never as hard requirements. Verified across rhel 8/9, sles 15 and ubuntu jammy/noble on stable-25.0 and stable-29.2. Package managers install recommended packages by default, which is why the CLI normally appears without launchpad naming it. Hardening baselines commonly disable that, and the runtime then installs without its CLI, which is what PRODENG-3641 reports. Naming docker-ee-cli explicitly fixes only the package that was noticed. cri-dockerd-ee is recommended too and launchpad never installs it explicitly, so the same hosts are missing the Kubernetes CRI shim. Add spec.mcr.installRecommends, default false, which tells the package manager to honour recommended packages regardless of the host default: yum/dnf --setopt=install_weak_deps=True apt-get -o APT::Install-Recommends=true zypper --recommends Which packages that installs is decided by the repository metadata, not by launchpad, so the list cannot drift. Note it differs by platform: on rpm hosts it is the two packages above, while on deb hosts it also brings ca-certificates, docker-ee-rootless-extras, git, a kernel package, libltdl7, pigz, procps and xz-utils. That difference is documented on the config field and is the main trade-off against the installCLI approach. rig's InstallPackage hardcodes " install -y " and takes no further arguments, so the MCR install now issues its command directly on all three families. SLES already did this for --allow-vendor-change (PRODENG-3623); rather than adding two more ad-hoc bypasses, the commands for all three managers are built in one place, MCRInstallCommand, and covered by a single table test. Default behaviour is unchanged. The generated commands with the flag unset are byte-identical to what rig ran, including apt's "-y -q" and SLES's --allow-vendor-change; the tests asserting that are the reason -q is there. rig also ran "apt-get update" inside InstallPackage, which InstallMCR already does explicitly beforehand, so it is not repeated. Windows is unaffected and documented as ignoring the value: MCR installs via install.ps1 from a single archive that already contains the CLI, with no package manager and no equivalent concept. Refs PRODENG-3641 Written by AI: claude-sonnet-5 --- pkg/configurer/enterpriselinux/el.go | 8 +- pkg/configurer/linux.go | 73 ++++++++++++++++++ pkg/configurer/mcr_install_command_test.go | 79 ++++++++++++++++++++ pkg/configurer/sles/sles.go | 6 +- pkg/configurer/ubuntu/ubuntu.go | 8 +- pkg/product/common/config/mcr_config.go | 23 ++++++ pkg/product/common/config/mcr_config_test.go | 33 ++++++++ 7 files changed, 225 insertions(+), 5 deletions(-) create mode 100644 pkg/configurer/mcr_install_command_test.go 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) + }) + } +}