diff --git a/docs/spec.schema.json b/docs/spec.schema.json index b9029be09..2ac7e06c9 100644 --- a/docs/spec.schema.json +++ b/docs/spec.schema.json @@ -1300,6 +1300,13 @@ ], "description": "Labels is the list of labels to set in the image metadata." }, + "minimization_profile": { + "type": [ + "string", + "null" + ], + "description": "MinimizationProfile selects an optional post-install image minimization\npolicy. An empty value leaves the image unchanged; \"default\" enables the\ntarget-specific default policy." + }, "post": { "$ref": "#/$defs/PostInstall", "description": "Post is the post install configuration for the image.\nThis allows making additional modifications to the container rootfs after the package(s) are installed.\n\nUse this to perform actions that would otherwise require additional tooling inside the container that is not relevant to\nthe resulting container and makes a post-install script as part of the package unnecessary." diff --git a/helpers.go b/helpers.go index 787a4b649..51d29bce3 100644 --- a/helpers.go +++ b/helpers.go @@ -284,6 +284,18 @@ func (s *Spec) GetImagePost(target string) *PostInstall { return nil } +func (s *Spec) GetImageMinimizationProfile(target string) string { + if img := s.Targets[target].Image; img != nil && img.MinimizationProfile != "" { + return img.MinimizationProfile + } + + if s.Image != nil { + return s.Image.MinimizationProfile + } + + return "" +} + func (s *Spec) GetArtifacts(targetKey string) Artifacts { if t, ok := s.Targets[targetKey]; ok { // If unset then we should use the global artifacts but if set or deliberately empty then we should use that. diff --git a/image.go b/image.go index b9a80e34f..fe4aee363 100644 --- a/image.go +++ b/image.go @@ -14,9 +14,15 @@ import ( type DockerImageSpec = dockerspec.DockerOCIImage type DockerImageConfig = dockerspec.DockerOCIImageConfig +const ImageMinimizationProfileDefault = "default" + // ImageConfig is the configuration for the output image. // When the target output is a container image, this is used to configure the image. type ImageConfig struct { + // MinimizationProfile selects an optional post-install image minimization + // policy. An empty value leaves the image unchanged; "default" enables the + // target-specific default policy. + MinimizationProfile string `yaml:"minimization_profile,omitempty" json:"minimization_profile,omitempty"` // Entrypoint sets the image's "entrypoint" field. // This is used to control the default command to run when the image is run. Entrypoint string `yaml:"entrypoint,omitempty" json:"entrypoint,omitempty"` @@ -156,6 +162,10 @@ func (i *ImageConfig) validate() error { errs = append(errs, errors.New("cannot specify both image.base and image.bases")) } + if i.MinimizationProfile != "" && i.MinimizationProfile != ImageMinimizationProfileDefault { + errs = append(errs, errors.Errorf("unsupported image minimization profile %q", i.MinimizationProfile)) + } + for i, base := range i.Bases { if err := base.validate(); err != nil && !errorIsOnly(err, errNoImageSourcePath) { errs = append(errs, errors.Wrapf(err, "bases[%d]", i)) diff --git a/imgconfig.go b/imgconfig.go index 00c84fbaf..ad2c3398c 100644 --- a/imgconfig.go +++ b/imgconfig.go @@ -18,6 +18,10 @@ func MergeSpecImage(spec *Spec, targetKey string) *ImageConfig { } if i := spec.Targets[targetKey].Image; i != nil { + if i.MinimizationProfile != "" { + cfg.MinimizationProfile = i.MinimizationProfile + } + if i.Entrypoint != "" { cfg.Entrypoint = i.Entrypoint } diff --git a/imgconfig_test.go b/imgconfig_test.go index 081c540c4..acde5e429 100644 --- a/imgconfig_test.go +++ b/imgconfig_test.go @@ -83,22 +83,24 @@ func TestMergeSpecImage(t *testing.T) { t.Run("target overrides all string fields", func(t *testing.T) { spec := &Spec{ Image: &ImageConfig{ - Entrypoint: "/bin/old", - Cmd: "old", - WorkingDir: "/old", - StopSignal: "SIGINT", - Base: "old:latest", - User: "root", + Entrypoint: "/bin/old", + Cmd: "old", + WorkingDir: "/old", + StopSignal: "SIGINT", + Base: "old:latest", + User: "root", + MinimizationProfile: "default", }, Targets: map[string]Target{ "t1": { Image: &ImageConfig{ - Entrypoint: "/bin/new", - Cmd: "new", - WorkingDir: "/new", - StopSignal: "SIGTERM", - Base: "new:latest", - User: "nobody", + Entrypoint: "/bin/new", + Cmd: "new", + WorkingDir: "/new", + StopSignal: "SIGTERM", + Base: "new:latest", + User: "nobody", + MinimizationProfile: "default", }, }, }, @@ -110,6 +112,18 @@ func TestMergeSpecImage(t *testing.T) { assert.Check(t, cmp.Equal(cfg.StopSignal, "SIGTERM")) assert.Check(t, cmp.Equal(cfg.Base, "new:latest")) assert.Check(t, cmp.Equal(cfg.User, "nobody")) + assert.Check(t, cmp.Equal(cfg.MinimizationProfile, "default")) + }) + + t.Run("target minimization profile overrides spec profile", func(t *testing.T) { + spec := &Spec{ + Targets: map[string]Target{ + "t1": {Image: &ImageConfig{MinimizationProfile: "default"}}, + }, + } + + assert.Check(t, cmp.Equal(spec.GetImageMinimizationProfile("t1"), "default")) + assert.Check(t, cmp.Equal(spec.GetImageMinimizationProfile("other"), "")) }) t.Run("target env appends to spec env", func(t *testing.T) { diff --git a/load_test.go b/load_test.go index 585a8783c..d682fddc6 100644 --- a/load_test.go +++ b/load_test.go @@ -1555,6 +1555,11 @@ func TestImage_validate(t *testing.T) { Name: "No base image", Image: ImageConfig{}, }, + { + Name: "unsupported image minimization profile", + Image: ImageConfig{MinimizationProfile: "v1"}, + expectErr: "unsupported image minimization profile", + }, { Name: "image.base set", Image: ImageConfig{ diff --git a/targets/linux/rpm/distro/container.go b/targets/linux/rpm/distro/container.go index 95d404dbd..14f5deb66 100644 --- a/targets/linux/rpm/distro/container.go +++ b/targets/linux/rpm/distro/container.go @@ -64,6 +64,11 @@ func (cfg *Config) BuildContainer(ctx context.Context, client gwclient.Client, s pkgs = append(pkgs, filepath.Join(baseMountPath, "**/*.rpm")) } + minimize := !skipBase && spec.GetImageMinimizationProfile(targetKey) == dalec.ImageMinimizationProfileDefault + if minimize { + installOpts = append(installOpts, minimizeInstall(opts...)) + } + worker := cfg.Worker(sOpt, dalec.Platform(sOpt.TargetPlatform), dalec.WithConstraints(opts...)) rootfs = worker.Run( diff --git a/targets/linux/rpm/distro/dnf_install.go b/targets/linux/rpm/distro/dnf_install.go index 08771e137..948e19d95 100644 --- a/targets/linux/rpm/distro/dnf_install.go +++ b/targets/linux/rpm/distro/dnf_install.go @@ -40,6 +40,9 @@ type dnfInstallConfig struct { forceArch string disableProxyConfig bool + + postInstallPath string + postInstallScript llb.State } type DnfInstallOpt func(*dnfInstallConfig) @@ -96,6 +99,16 @@ func DnfInstallWithConstraints(opts []llb.ConstraintsOpt) DnfInstallOpt { } } +// DnfWithPostInstallScript runs the mounted script after package installation +// in the same worker operation. The script can modify the install root before +// the operation's filesystem layer is committed. +func DnfWithPostInstallScript(path string, script llb.State) DnfInstallOpt { + return func(cfg *dnfInstallConfig) { + cfg.postInstallPath = path + cfg.postInstallScript = script + } +} + func dnfInstallFlags(cfg *dnfInstallConfig) string { var cmdOpts string @@ -244,6 +257,11 @@ func dnfCommand(cfg *dnfInstallConfig, releaseVer string, exe string, dnfSubCmd installFlags := dnfInstallFlags(cfg) installFlags += " -y --setopt varsdir=/etc/dnf/vars --releasever=" + releaseVer + " " forceArch := cfg.forceArch + postInstallCommand := "" + if cfg.postInstallPath != "" { + postInstallCommand = fmt.Sprintf("\n%s", cfg.postInstallPath) + } + installScriptDt := `#!/usr/bin/env bash set -eux -o pipefail @@ -278,6 +296,7 @@ configure_dnf_proxy trap cleanup_dnf_proxy EXIT $cmd $dnf_sub_cmd $install_flags "${@}" +` + postInstallCommand + ` ` var runOpts []llb.RunOption @@ -285,6 +304,14 @@ $cmd $dnf_sub_cmd $install_flags "${@}" const installScriptPath = "/tmp/dalec/internal/dnf/install.sh" runOpts = append(runOpts, llb.AddMount(installScriptPath, installScript, llb.SourcePath("install.sh"), llb.Readonly)) + if cfg.postInstallPath != "" { + runOpts = append(runOpts, llb.AddMount( + cfg.postInstallPath, + cfg.postInstallScript, + llb.SourcePath(filepath.Base(cfg.postInstallPath)), + llb.Readonly, + )) + } // TODO(adamperlin): see if this can be removed for dnf // If we have keys to import in order to access a repo, we need to create a script to use `gpg` to import them diff --git a/targets/linux/rpm/distro/minimize.go b/targets/linux/rpm/distro/minimize.go new file mode 100644 index 000000000..4c2d0aed4 --- /dev/null +++ b/targets/linux/rpm/distro/minimize.go @@ -0,0 +1,241 @@ +package distro + +import ( + "github.com/moby/buildkit/client/llb" + "github.com/project-dalec/dalec" +) + +const rpmMinimizeScript = `#!/usr/bin/env bash +set -euo pipefail + +rootfs=/tmp/rootfs + +rpm_root() { + rpm --root "${rootfs}" "$@" +} + +seed_packages() { + local dir rpm_file path + + for dir in /tmp/rpms /tmp/rpms-base; do + [ -d "${dir}" ] || continue + + # Package output is always grouped by architecture, matching the + # /RPMS//*.rpm layout consumed by the installer. + for rpm_file in "${dir}"/*/*.rpm; do + [ -f "${rpm_file}" ] || continue + rpm -qp --qf '%{NAME}\n' "${rpm_file}" + done + done + + for path in \ + /etc/passwd \ + /etc/group \ + /etc/shadow \ + /etc/gshadow \ + /etc/subuid \ + /etc/subgid \ + /etc/nsswitch.conf; do + [ -e "${rootfs}${path}" ] || continue + rpm_root -qf --qf '%{NAME}\n' "${path}" 2>/dev/null || true + done +} + +is_scriptlet_requirement() { + local flags="$1" + + case "${flags}" in + *interp*|*pre*|*post*|*preun*|*postun*|*pretrans*|*posttrans*|*trigger*|*verify*) + return 0 + ;; + esac + + return 1 +} + +requirement_providers() { + local req="$1" + local providers + + [ -n "${req}" ] || return 0 + + case "${req}" in + rpmlib\(*|\(none\)) + return 0 + ;; + esac + + if ! providers="$(rpm_root -q --whatprovides --qf '%{NAME}\n' "${req}" 2>/dev/null | sed '/^$/d')"; then + echo "required RPM dependency ${req} has no installed provider" >&2 + return 1 + fi + + if [ -z "${providers}" ]; then + echo "required RPM dependency ${req} has no installed provider" >&2 + return 1 + fi + + printf '%s\n' "${providers}" +} + +rich_requirement_providers() { + local pkg="$1" + local providers + + # rpm --whatprovides cannot evaluate boolean requirements. Ask the + # installed-package solver for the package's direct requirement providers. + if providers="$(dnf -q --installroot "${rootfs}" repoquery --installed \ + --providers-of=requires --qf '%{name}\n' "${pkg}" 2>/dev/null)"; then + printf '%s\n' "${providers}" + elif providers="$(dnf -q --installroot "${rootfs}" repoquery --installed \ + --requires --resolve --qf '%{name}\n' "${pkg}" 2>/dev/null)"; then + printf '%s\n' "${providers}" + else + echo "failed to resolve rich RPM requirements for ${pkg}" >&2 + return 1 + fi +} + +declare -a queue=() +declare -A keep=() + +mapfile -t seeds < <(seed_packages | sort -u) +if [ "${#seeds[@]}" -eq 0 ]; then + echo "no RPM seed packages found for minimization" >&2 + exit 1 +fi + +for pkg in "${seeds[@]}"; do + [ -n "${pkg}" ] || continue + if rpm_root -q "${pkg}" >/dev/null 2>&1; then + queue+=("${pkg}") + fi +done + +if [ "${#queue[@]}" -eq 0 ]; then + echo "no installed RPM seed packages found for minimization" >&2 + exit 1 +fi + +queue_index=0 +while [ "${queue_index}" -lt "${#queue[@]}" ]; do + pkg="${queue[queue_index]}" + queue_index=$((queue_index + 1)) + + [ -n "${pkg}" ] || continue + + if [ -n "${keep[${pkg}]+x}" ]; then + continue + fi + + if ! rpm_root -q "${pkg}" >/dev/null 2>&1; then + continue + fi + + keep["${pkg}"]=1 + has_rich_requirements=false + + if ! requirements="$(rpm_root -q --qf '[%{REQUIRENAME}\t%{REQUIREFLAGS:deptype}\n]' "${pkg}")"; then + echo "failed to read RPM requirements for ${pkg}" >&2 + exit 1 + fi + + while IFS=$'\t' read -r req flags; do + [ -n "${req}" ] || continue + if is_scriptlet_requirement "${flags:-}"; then + continue + fi + if [[ "${req}" == \(* ]]; then + has_rich_requirements=true + continue + fi + + providers="$(requirement_providers "${req}")" || exit 1 + while IFS= read -r provider; do + [ -n "${provider}" ] && queue+=("${provider}") + done <<< "${providers}" + done <<< "${requirements}" + + if "${has_rich_requirements}"; then + providers="$(rich_requirement_providers "${pkg}")" || exit 1 + while IFS= read -r provider; do + [ -n "${provider}" ] && queue+=("${provider}") + done <<< "${providers}" + fi +done + +mapfile -t installed < <(rpm_root -qa --qf '%{NAME}\n' | sort -u) +declare -a remove=() +for pkg in "${installed[@]}"; do + [ -n "${pkg}" ] || continue + if [ -z "${keep[${pkg}]+x}" ]; then + remove+=("${pkg}") + fi +done + +echo "DALEC RPM keep set:" >&2 +printf '%s\n' "${!keep[@]}" | sort | sed 's/^/ /' >&2 + +if [ "${#remove[@]}" -gt 0 ]; then + echo "DALEC RPM packages removed during minimization:" + printf '%s\n' "${remove[@]}" | sed 's/^/ /' >&2 + + remove_specs="$( + for pkg in "${remove[@]}"; do + rpm_root -q --qf '%{NAME}\t%{VERSION}\t%{RELEASE}\t%{ARCH}\n' "${pkg}" 2>/dev/null \ + | while IFS=$'\t' read -r name version release arch; do + [ -n "${name}" ] || continue + + if [ "${arch}" = "(none)" ]; then + printf '%s-%s-%s\n' "${name}" "${version}" "${release}" + continue + fi + + printf '%s-%s-%s.%s\n' "${name}" "${version}" "${release}" "${arch}" + done + done | sort -u + )" + + printf '%s\n' "${remove_specs}" | xargs -r rpm --root "${rootfs}" -e --noscripts --notriggers --nodeps +fi + +if [ -d "${rootfs}/usr/lib/sysimage/rpm" ] && [ ! -e "${rootfs}/var/lib/rpm" ]; then + mkdir -p "${rootfs}/var/lib" + ln -s ../../usr/lib/sysimage/rpm "${rootfs}/var/lib/rpm" +fi + +rpm_root -qa >/dev/null + +while IFS= read -r pkg; do + if ! rpm_root -q "${pkg}" >/dev/null 2>&1; then + echo "required package ${pkg} is missing after RPM minimization" >&2 + exit 1 + fi +done < <(printf '%s\n' "${!keep[@]}") + +# Package-manager cache paths may be BuildKit cache mounts while minimization +# runs in the install operation. Those mounts are not committed to the image, +# and their mountpoints cannot be removed until the operation exits. +rm -rf \ + "${rootfs}/var/cache/dnf" \ + "${rootfs}/var/cache/libdnf5" \ + "${rootfs}/var/cache/tdnf" \ + "${rootfs}/var/cache/yum" \ + "${rootfs}/var/lib/dnf" \ + "${rootfs}/var/lib/yum" \ + "${rootfs}/var/log/dnf.log" \ + "${rootfs}/var/log/dnf.librepo.log" \ + "${rootfs}/var/log/hawkey.log" \ + "${rootfs}/var/log/tdnf.log" \ + "${rootfs}/var/log/yum.log" 2>/dev/null || true +` + +func minimizeInstall(opts ...llb.ConstraintsOpt) DnfInstallOpt { + opts = append(opts, dalec.ProgressGroup("Minimize RPM container")) + + const scriptPath = "/tmp/dalec/internal/rpm/minimize.sh" + + script := llb.Scratch().File(llb.Mkfile("minimize.sh", 0o755, []byte(rpmMinimizeScript)), opts...) + + return DnfWithPostInstallScript(scriptPath, script) +} diff --git a/targets/linux/rpm/distro/minimize_test.go b/targets/linux/rpm/distro/minimize_test.go new file mode 100644 index 000000000..375aa38b7 --- /dev/null +++ b/targets/linux/rpm/distro/minimize_test.go @@ -0,0 +1,15 @@ +package distro + +import ( + "strings" + "testing" +) + +func TestRPMMinimizeScriptUsesDependencyTypeFormatter(t *testing.T) { + if !strings.Contains(rpmMinimizeScript, "%{REQUIREFLAGS:deptype}") { + t.Fatal("RPM minimization must use the deptype formatter for scriptlet requirements") + } + if strings.Contains(rpmMinimizeScript, "%{REQUIREFLAGS:depflags}") { + t.Fatal("RPM minimization must not use the depflags formatter for scriptlet requirements") + } +} diff --git a/targets/linux/rpm/distro/zypper_install.go b/targets/linux/rpm/distro/zypper_install.go index 204efc73f..9b1945fb0 100644 --- a/targets/linux/rpm/distro/zypper_install.go +++ b/targets/linux/rpm/distro/zypper_install.go @@ -3,6 +3,7 @@ package distro import ( "bytes" "fmt" + "path/filepath" "strings" "text/template" @@ -151,6 +152,7 @@ import_keys_path={{ shellQuote .ImportKeysPath }} global_flags={{ shellQuote .GlobalFlags }} zypper_sub_cmd={{ shellQuote .ZypperSubCmd }} install_flags={{ shellQuote .InstallFlags }} +post_install_path={{ shellQuote .PostInstallPath }} if [ -x "$import_keys_path" ]; then "$import_keys_path" @@ -245,21 +247,27 @@ EOF fi zypper $global_flags $zypper_sub_cmd $install_flags "${install_args[@]}" + +if [ -n "$post_install_path" ]; then + "$post_install_path" +fi `)) var installScriptBuf bytes.Buffer err := zypperInstallScriptTmpl.Execute(&installScriptBuf, struct { - ImportKeysPath string - GlobalFlags string - ZypperSubCmd string - InstallFlags string - IncludeDocs bool + ImportKeysPath string + GlobalFlags string + ZypperSubCmd string + InstallFlags string + PostInstallPath string + IncludeDocs bool }{ - ImportKeysPath: importKeysPath, - GlobalFlags: globalFlagsStr, - ZypperSubCmd: zypperSubCmdStr, - InstallFlags: installFlagsStr, - IncludeDocs: cfg.includeDocs, + ImportKeysPath: importKeysPath, + GlobalFlags: globalFlagsStr, + ZypperSubCmd: zypperSubCmdStr, + InstallFlags: installFlagsStr, + PostInstallPath: cfg.postInstallPath, + IncludeDocs: cfg.includeDocs, }) if err != nil { // The template is a compile-time constant, so Execute realistically only @@ -277,6 +285,14 @@ zypper $global_flags $zypper_sub_cmd $install_flags "${install_args[@]}" runOpts := []llb.RunOption{ llb.AddMount(installScriptPath, installScript, llb.SourcePath("install.sh"), llb.Readonly), } + if cfg.postInstallPath != "" { + runOpts = append(runOpts, llb.AddMount( + cfg.postInstallPath, + cfg.postInstallScript, + llb.SourcePath(filepath.Base(cfg.postInstallPath)), + llb.Readonly, + )) + } // If we have keys to import in order to access a repo, mount a script that // imports them into the rpm keyring (zypper uses the same rpm keyring). diff --git a/test/linux_target_test.go b/test/linux_target_test.go index d2cf7f902..87fa3098f 100644 --- a/test/linux_target_test.go +++ b/test/linux_target_test.go @@ -803,6 +803,12 @@ EOF t.Run("minimal_container", func(t *testing.T) { skip.If(t, testConfig.Target.MinimalContainer == "", "skipping test as it is not supported for this config") t.Parallel() + + if strings.HasSuffix(testConfig.Target.Package, "/rpm") { + testRPMMinimalContainer(ctx, t, testConfig) + return + } + testContainerTarget(ctx, t, testConfig, testConfig.Target.MinimalContainer) t.Run("cleanup", func(t *testing.T) { @@ -1529,6 +1535,10 @@ index 0000000..5260cb1 "zsh": {Version: []string{">= 3", "< 99"}}, "zstd": {Version: []string{">= 1.5.0"}}, }, + Test: map[string]dalec.PackageConstraints{ + "bash": {}, + "grep": {}, + }, }, Build: dalec.ArtifactBuild{ @@ -3416,6 +3426,10 @@ func Value() string { Runtime: map[string]dalec.PackageConstraints{ "coreutils": {}, }, + Test: map[string]dalec.PackageConstraints{ + "bash": {}, + "grep": {}, + }, }, Tests: []*dalec.TestSpec{ { @@ -5740,6 +5754,11 @@ func testPrebuiltPackages(ctx context.Context, t *testing.T, testConfig testLinu Vendor: "Dalec", Packager: "Dalec", Description: "Test using pre-built packages", + Dependencies: &dalec.PackageDependencies{ + Test: dalec.PackageDependencyList{ + "bash": {}, + }, + }, Sources: map[string]dalec.Source{ "hello": { Inline: &dalec.SourceInline{ @@ -6117,6 +6136,79 @@ func testDepsOnly(ctx context.Context, t *testing.T, testConfig testLinuxConfig) }) } +func testRPMMinimalContainer(ctx context.Context, t *testing.T, testConfig testLinuxConfig) { + t.Helper() + + target := testConfig.Target.MinimalContainer + + t.Run("minimization_is_opt_in", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + + spec := testLinuxSpec(t, dalec.Spec{}) + + testEnv.RunTest(ctx, t, func(ctx context.Context, gwc gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, &spec), withBuildTarget(target)) + res := solveT(ctx, t, gwc, sr) + + for _, op := range test.LLBOpsFromState(ctx, t, resultToState(t, res)) { + assert.Assert(t, op.OpMetadata.ProgressGroup.Name != "Minimize RPM container", + "RPM minimization must be disabled unless explicitly selected") + } + }) + }) + + t.Run("rpm_database_remains_queryable", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + + spec := testLinuxSpec(t, dalec.Spec{}) + spec.Image = &dalec.ImageConfig{MinimizationProfile: dalec.ImageMinimizationProfileDefault} + spec.Dependencies.Test = map[string]dalec.PackageConstraints{ + "rpm": {}, + } + spec.Tests = []*dalec.TestSpec{ + { + Name: "RPM database remains queryable after minimization", + Steps: []dalec.TestStep{ + {Command: "rpm -qa >/dev/null"}, + }, + }, + } + + testEnv.RunTest(ctx, t, func(ctx context.Context, gwc gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, &spec), withBuildTarget(target)) + solveT(ctx, t, gwc, sr) + }) + }) + + t.Run("minimization_runs_when_selected", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + + spec := testLinuxSpec(t, dalec.Spec{}) + spec.Image = &dalec.ImageConfig{MinimizationProfile: dalec.ImageMinimizationProfileDefault} + + testEnv.RunTest(ctx, t, func(ctx context.Context, gwc gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, &spec), withBuildTarget(target)) + res := solveT(ctx, t, gwc, sr) + + found := false + squashed := false + for _, op := range test.LLBOpsFromState(ctx, t, resultToState(t, res)) { + if op.OpMetadata.ProgressGroup.Name == "Squash RPM container" { + squashed = true + } + if op.OpMetadata.ProgressGroup.Name == "Minimize RPM container" { + found = true + } + } + assert.Assert(t, found, "expected selected RPM minimization profile to run") + assert.Assert(t, !squashed, "RPM minimization must not require a separate squash operation") + }) + }) +} + func testContainerTarget(ctx context.Context, t *testing.T, testConfig testLinuxConfig, target string) { t.Helper() diff --git a/test/target_almalinux_test.go b/test/target_almalinux_test.go index 3c9b774a1..b2e4c605c 100644 --- a/test/target_almalinux_test.go +++ b/test/target_almalinux_test.go @@ -13,11 +13,12 @@ func TestAlmalinux9(t *testing.T) { ctx := startTestSpan(baseCtx, t) cfg := testLinuxConfig{ Target: targetConfig{ - Key: "almalinux9", - Package: "almalinux9/rpm", - Container: "almalinux9/container", - DepsOnly: "almalinux9/container/depsonly", - Worker: "almalinux9/worker", + Key: "almalinux9", + Package: "almalinux9/rpm", + Container: "almalinux9/container", + DepsOnly: "almalinux9/container/depsonly", + MinimalContainer: "almalinux9/container", + Worker: "almalinux9/worker", FormatDepEqual: func(v, _ string) string { return v }, @@ -59,10 +60,11 @@ func TestAlmalinux8(t *testing.T) { ctx := startTestSpan(baseCtx, t) cfg := testLinuxConfig{ Target: targetConfig{ - Package: "almalinux8/rpm", - Container: "almalinux8/container", - DepsOnly: "almalinux8/container/depsonly", - Worker: "almalinux8/worker", + Package: "almalinux8/rpm", + Container: "almalinux8/container", + DepsOnly: "almalinux8/container/depsonly", + MinimalContainer: "almalinux8/container", + Worker: "almalinux8/worker", FormatDepEqual: func(v, _ string) string { return v }, diff --git a/test/target_azlinux_test.go b/test/target_azlinux_test.go index 8bc683d8e..94c11942d 100644 --- a/test/target_azlinux_test.go +++ b/test/target_azlinux_test.go @@ -47,6 +47,7 @@ func TestAzlinux3(t *testing.T) { Package: "azlinux3/rpm", Container: "azlinux3/container", DepsOnly: "azlinux3/container/depsonly", + MinimalContainer: "azlinux3/container", Worker: "azlinux3/worker", Sysext: "azlinux3/testing/sysext", ListExpectedSignFiles: azlinuxListSignFiles("azl3"), @@ -93,6 +94,7 @@ func TestAzlinux4(t *testing.T) { Package: "azlinux4/rpm", Container: "azlinux4/container", DepsOnly: "azlinux4/container/depsonly", + MinimalContainer: "azlinux4/container", Worker: "azlinux4/worker", Sysext: "azlinux4/testing/sysext", ListExpectedSignFiles: azlinuxListSignFiles("azl4"), diff --git a/test/target_rockylinux_test.go b/test/target_rockylinux_test.go index 558f574ae..3147acd3b 100644 --- a/test/target_rockylinux_test.go +++ b/test/target_rockylinux_test.go @@ -13,11 +13,12 @@ func TestRockylinux9(t *testing.T) { ctx := startTestSpan(baseCtx, t) cfg := testLinuxConfig{ Target: targetConfig{ - Key: "rockylinux9", - Package: "rockylinux9/rpm", - Container: "rockylinux9/container", - DepsOnly: "rockylinux9/container/depsonly", - Worker: "rockylinux9/worker", + Key: "rockylinux9", + Package: "rockylinux9/rpm", + Container: "rockylinux9/container", + DepsOnly: "rockylinux9/container/depsonly", + MinimalContainer: "rockylinux9/container", + Worker: "rockylinux9/worker", FormatDepEqual: func(v, _ string) string { return v }, @@ -59,10 +60,11 @@ func TestRockylinux8(t *testing.T) { ctx := startTestSpan(baseCtx, t) cfg := testLinuxConfig{ Target: targetConfig{ - Package: "rockylinux8/rpm", - Container: "rockylinux8/container", - DepsOnly: "rockylinux8/container/depsonly", - Worker: "rockylinux8/worker", + Package: "rockylinux8/rpm", + Container: "rockylinux8/container", + DepsOnly: "rockylinux8/container/depsonly", + MinimalContainer: "rockylinux8/container", + Worker: "rockylinux8/worker", FormatDepEqual: func(v, _ string) string { return v }, diff --git a/website/content/image.md b/website/content/image.md index de6e4265d..75e1fa02c 100644 --- a/website/content/image.md +++ b/website/content/image.md @@ -10,6 +10,7 @@ of the produced image. The image field is an object with the following propertie - `base`: The image ref to use as the base for the output container. [Deprecated: use `bases` instead] [base section](#base) - `bases`: The list of base images to use as the base for the output container(s). [bases section](#bases) - `post`: The post processing for the image, such as symlinks. [post section](#post) +- `minimization_profile`: An optional post-install minimization policy. [minimization profile section](#minimization-profile) - `labels`: The labels for the image. This is an optional field. [labels section](#labels) - `env`: The environment variables for the image. This is an optional field. [env section](#env) - `entrypoint`: The entrypoint for the image. This is an optional field. [entrypoint section](#entrypoint) @@ -36,6 +37,21 @@ are keyed on the image platform metadata. With the exception of `base`, `bases`, and `post`, these fields are all used to merge with the configured (or default) base image(s). +### Minimization profile + +Image minimization is opt-in. Set `minimization_profile` to `default` to enable +the target's default package minimization policy. An empty value leaves the +container unchanged. The RPM container policy preserves the RPM database while +removing packages outside the runtime dependency closure during the container +setup operation. Custom base images are not minimized. + +Example: + +```yaml +image: + minimization_profile: default +``` + ### Base The `base` field is used to specify the base image for the output container.