From eaeb84f18f8038a212d56e2e60dbfdbde4b0f35c Mon Sep 17 00:00:00 2001 From: James Nesbitt Date: Sat, 8 Aug 2026 12:50:23 +0300 Subject: [PATCH] feat(mcr): add optional installCLI to install docker-ee-cli Launchpad installs the runtime with a bare `yum install -y docker-ee` (equivalently apt-get/zypper) and relies on package manager dependency resolution to bring in docker-ee-cli. That normally works, and the CLI is present without launchpad naming it. It does not always work. A customer on CIS-hardened RHEL 8.10 ended up with the runtime installed and no CLI, so every later docker command failed on a host that otherwise looked correctly installed. Installing docker-ee-cli by hand and re-running launchpad completed the cluster. Checked every docker-ee package on repos.mirantis.com across rhel 8/9, sles 15 and ubuntu jammy/noble, on both an older and the current channel: docker-ee lists docker-ee-cli only as a Recommends (rpm weak dependency / deb Recommends field), never as a Requires or Depends. Package managers install Recommends by default, which is why this normally works unattended -- but common hardening baselines disable exactly that: dnf/yum's install_weak_deps=false, apt's `APT::Install-Recommends "false"`. That is consistent with, though not yet confirmed against, the reporting customer's CIS-hardened host; their launchpad config and docker-ee.repo are still pending. Add spec.mcr.installCLI, default false, which names the CLI package explicitly, bypassing weak-dependency resolution entirely. It is added to the same package manager invocation as the runtime rather than installed afterwards, so the two cannot resolve to mismatched versions. The default stays false deliberately: Recommends are installed by default on an unhardened host, so installing the CLI unconditionally would change what every existing cluster installs in order to work around an environment-specific setting. The package list lives in one helper, configurer.MCRPackages, rather than being repeated across the three Linux configurers, so the package names cannot drift between them. Windows is unaffected and the value is documented as ignored there: MCR is installed via install.ps1, which ships the CLI in the same archive with no separate package. Refs PRODENG-3641 Written by AI: claude-sonnet-5 --- pkg/configurer/enterpriselinux/el.go | 5 +-- pkg/configurer/linux.go | 23 ++++++++++++ pkg/configurer/mcr_packages_test.go | 37 ++++++++++++++++++++ pkg/configurer/sles/sles.go | 5 +-- pkg/configurer/ubuntu/ubuntu.go | 6 ++-- pkg/product/common/config/mcr_config.go | 19 ++++++++++ pkg/product/common/config/mcr_config_test.go | 32 +++++++++++++++++ 7 files changed, 121 insertions(+), 6 deletions(-) create mode 100644 pkg/configurer/mcr_packages_test.go diff --git a/pkg/configurer/enterpriselinux/el.go b/pkg/configurer/enterpriselinux/el.go index d7f4dc03..b3c7f432 100644 --- a/pkg/configurer/enterpriselinux/el.go +++ b/pkg/configurer/enterpriselinux/el.go @@ -73,8 +73,9 @@ 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") + mcrPackages := configurer.MCRPackages(engineConfig) + if err := c.InstallPackage(h, mcrPackages...); err != nil { + return fmt.Errorf("package manager could not install %s", strings.Join(mcrPackages, ", ")) } if err := c.EnableMCR(h, engineConfig); err != nil { diff --git a/pkg/configurer/linux.go b/pkg/configurer/linux.go index 6178e26a..33673ea6 100644 --- a/pkg/configurer/linux.go +++ b/pkg/configurer/linux.go @@ -26,6 +26,29 @@ const ( SbinPath = `PATH=/usr/local/sbin:/usr/sbin:/sbin:$PATH` ) +const ( + // mcrPackage is the Mirantis Container Runtime package name. It is identical + // across the rpm and deb repositories on repos.mirantis.com. + mcrPackage = "docker-ee" + // mcrCLIPackage is the docker CLI package name, likewise identical across + // rpm and deb. + mcrCLIPackage = "docker-ee-cli" +) + +// MCRPackages returns the packages to install for MCR, in the order they should +// be passed to the package manager. +// +// The CLI is only listed when spec.mcr.installCLI is set. It is returned in the +// same slice rather than installed separately so that both land in a single +// package manager transaction and cannot be resolved to mismatched versions. +func MCRPackages(engineConfig commonconfig.MCRConfig) []string { + if engineConfig.InstallCLI { + return []string{mcrPackage, mcrCLIPackage} + } + + return []string{mcrPackage} +} + var ErrLinuxMCRInstall = errors.New("failed to install MCR on linux") // LinuxConfigurer is a generic linux host configurer. diff --git a/pkg/configurer/mcr_packages_test.go b/pkg/configurer/mcr_packages_test.go new file mode 100644 index 00000000..06688e97 --- /dev/null +++ b/pkg/configurer/mcr_packages_test.go @@ -0,0 +1,37 @@ +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" +) + +// TestMCRPackages covers the package set every Linux configurer installs. +// +// The runtime package lists the CLI only as a Recommends (a weak dependency), +// not a Requires/Depends, so the default must stay runtime-only: adding the CLI +// unconditionally would change what every existing cluster installs. +// spec.mcr.installCLI opts in for hosts where weak-dependency resolution is +// disabled or otherwise cannot be relied on. See PRODENG-3641. +func TestMCRPackages(t *testing.T) { + t.Run("default installs the runtime only", func(t *testing.T) { + require.Equal(t, []string{"docker-ee"}, configurer.MCRPackages(commonconfig.MCRConfig{})) + }) + + t.Run("installCLI adds the cli package", func(t *testing.T) { + require.Equal(t, []string{"docker-ee", "docker-ee-cli"}, + configurer.MCRPackages(commonconfig.MCRConfig{InstallCLI: true})) + }) + + t.Run("both packages are returned together for a single transaction", func(t *testing.T) { + // Returned as one slice, and the runtime stays first, so callers hand both + // to the package manager in one invocation. Installing them separately + // would allow the runtime and CLI to resolve to mismatched versions. + pkgs := configurer.MCRPackages(commonconfig.MCRConfig{InstallCLI: true}) + require.Len(t, pkgs, 2) + require.Equal(t, "docker-ee", pkgs[0]) + }) +} diff --git a/pkg/configurer/sles/sles.go b/pkg/configurer/sles/sles.go index b1fa12ca..f1035416 100644 --- a/pkg/configurer/sles/sles.go +++ b/pkg/configurer/sles/sles.go @@ -107,8 +107,9 @@ 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 { - return fmt.Errorf("package manager could not install docker-ee: %w", err) + mcrPackages := configurer.MCRPackages(engineConfig) + if err := h.Exec("zypper -n install -y --allow-vendor-change "+strings.Join(mcrPackages, " "), exec.Sudo(h)); err != nil { + return fmt.Errorf("package manager could not install %s: %w", strings.Join(mcrPackages, ", "), err) } if err := c.EnableMCR(h, engineConfig); err != nil { diff --git a/pkg/configurer/ubuntu/ubuntu.go b/pkg/configurer/ubuntu/ubuntu.go index 6cd212af..29b5bb1f 100644 --- a/pkg/configurer/ubuntu/ubuntu.go +++ b/pkg/configurer/ubuntu/ubuntu.go @@ -2,6 +2,7 @@ package ubuntu import ( "fmt" + "strings" "github.com/Mirantis/launchpad/pkg/configurer" commonconfig "github.com/Mirantis/launchpad/pkg/product/common/config" @@ -80,8 +81,9 @@ 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") + mcrPackages := configurer.MCRPackages(engineConfig) + if err := c.InstallPackage(h, mcrPackages...); err != nil { + return fmt.Errorf("package manager could not install %s", strings.Join(mcrPackages, ", ")) } 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..6547e8a5 100644 --- a/pkg/product/common/config/mcr_config.go +++ b/pkg/product/common/config/mcr_config.go @@ -31,6 +31,25 @@ type MCRConfig struct { SwarmInstallFlags Flags `yaml:"swarmInstallFlags,omitempty,flow"` SwarmUpdateCommands []string `yaml:"swarmUpdateCommands,omitempty,flow"` + // InstallCLI additionally installs the docker CLI package (docker-ee-cli) + // alongside the runtime, in the same package manager transaction so the two + // cannot end up at mismatched versions. + // + // The runtime package lists the CLI only as a Recommends (a weak + // dependency), not a Requires/Depends, on every repos.mirantis.com package + // checked -- rpm (dnf/yum) and deb (apt) alike. Package managers install + // Recommends by default, so this is normally not needed. It is needed when + // weak-dependency installation is disabled, which common hardening + // baselines do explicitly (dnf/yum: install_weak_deps=false; apt: + // APT::Install-Recommends "false"). Set it on hosts where that applies, or + // where the runtime has otherwise installed without the CLI. See + // PRODENG-3641. + // + // Linux only. Windows installs MCR through install.ps1, which ships the CLI + // as part of the same archive and has no separate package, so this value is + // ignored on Windows hosts. + InstallCLI bool `yaml:"installCLI,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..c9e20818 100644 --- a/pkg/product/common/config/mcr_config_test.go +++ b/pkg/product/common/config/mcr_config_test.go @@ -39,3 +39,35 @@ 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_InstallCLI(t *testing.T) { + // The yaml key is the user-facing contract: a mismatch here would silently + // ignore the setting and reintroduce PRODENG-3641 for anyone opting in. + for _, tc := range []struct { + name string + yaml string + expected bool + }{ + { + name: "absent defaults to false", + yaml: "channel: stable", + expected: false, + }, + { + name: "installCLI true is honoured", + yaml: "channel: stable\ninstallCLI: true", + expected: true, + }, + { + name: "installCLI false is honoured", + yaml: "channel: stable\ninstallCLI: 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.InstallCLI) + }) + } +}