From d9ad5d4647faae50b3c009ee06c6484f3ab71480 Mon Sep 17 00:00:00 2001 From: Kartik Joshi Date: Mon, 29 Jun 2026 13:30:18 +0530 Subject: [PATCH 1/2] targets/linux/rpm: minimize assembled container rootfs Add a post-install minimization pass for RPM container images assembled by DALEC. Signed-off-by: Kartik Joshi --- targets/linux/rpm/distro/container.go | 8 + targets/linux/rpm/distro/minimize.go | 259 ++++++++++++++++++++++++++ test/linux_target_test.go | 69 +++++++ test/target_almalinux_test.go | 20 +- test/target_azlinux_test.go | 2 + test/target_rockylinux_test.go | 20 +- 6 files changed, 360 insertions(+), 18 deletions(-) create mode 100644 targets/linux/rpm/distro/minimize.go diff --git a/targets/linux/rpm/distro/container.go b/targets/linux/rpm/distro/container.go index 95d404dbd..3752b5f85 100644 --- a/targets/linux/rpm/distro/container.go +++ b/targets/linux/rpm/distro/container.go @@ -74,10 +74,18 @@ func (cfg *Config) BuildContainer(ctx context.Context, client gwclient.Client, s frontend.IgnoreCache(client, targets.IgnoreCacheKeyContainer), ).AddMount(workPath, rootfs) + if !skipBase { + rootfs = minimizeContainer(rootfs, worker, rpmDir, basePkgs, opts...) + } + if post := spec.GetImagePost(targetKey); post != nil && len(post.Symlinks) > 0 { rootfs = rootfs.With(dalec.InstallPostSymlinks(post, worker, frontend.NativeSymlinkSupport(client), opts...)) } + if !skipBase { + rootfs = squashContainer(rootfs, opts...) + } + return rootfs } diff --git a/targets/linux/rpm/distro/minimize.go b/targets/linux/rpm/distro/minimize.go new file mode 100644 index 000000000..8196c87c0 --- /dev/null +++ b/targets/linux/rpm/distro/minimize.go @@ -0,0 +1,259 @@ +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[@]}") + +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" +` + +func minimizeContainer(rootfs, worker, rpmDir, basePkgs llb.State, opts ...llb.ConstraintsOpt) llb.State { + opts = append(opts, dalec.ProgressGroup("Minimize RPM container")) + + const ( + workPath = "/tmp/rootfs" + scriptPath = "/tmp/dalec/internal/rpm/minimize.sh" + rpmMountDir = "/tmp/rpms" + baseMountPath = "/tmp/rpms-base" + ) + + script := llb.Scratch().File(llb.Mkfile("minimize.sh", 0o755, []byte(rpmMinimizeScript)), opts...) + + return worker.Run( + dalec.WithConstraints(opts...), + llb.AddMount(scriptPath, script, llb.SourcePath("minimize.sh"), llb.Readonly), + llb.AddMount(rpmMountDir, rpmDir, llb.SourcePath("/RPMS"), llb.Readonly), + llb.AddMount(baseMountPath, basePkgs, llb.SourcePath("/RPMS"), llb.Readonly), + llb.Args([]string{scriptPath}), + ).AddMount(workPath, rootfs) +} + +func squashContainer(rootfs llb.State, opts ...llb.ConstraintsOpt) llb.State { + opts = append(opts, dalec.ProgressGroup("Squash RPM container")) + + return llb.Scratch().File(llb.Copy(rootfs, "/", "/", &llb.CopyInfo{ + CopyDirContentsOnly: true, + CreateDestPath: true, + AllowWildcard: true, + }), opts...) +} diff --git a/test/linux_target_test.go b/test/linux_target_test.go index d2cf7f902..de565aaa8 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,56 @@ 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("rpm_database_remains_queryable", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + + spec := testLinuxSpec(t, dalec.Spec{}) + 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("squash_produces_single_layer", 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) + + dt, ok := res.Metadata[exptypes.ExporterImageConfigKey] + assert.Assert(t, ok, "missing image config in result metadata") + + var img dalec.DockerImageSpec + assert.NilError(t, json.Unmarshal(dt, &img)) + + assert.Check(t, len(img.RootFS.DiffIDs) <= 1, + "expected squashed image to have at most 1 layer, got %d", len(img.RootFS.DiffIDs)) + }) + }) +} + 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 }, From 58b8ec4aebe3b79043558266f8a966d80a1cfaf2 Mon Sep 17 00:00:00 2001 From: Kartik Joshi Date: Tue, 18 Aug 2026 13:23:24 +0530 Subject: [PATCH 2/2] targets/linux/rpm: address minimization review feedback Make RPM image minimization opt-in via image.minimization_profile, run cleanup in the same package install operation, remove the separate squash operation, and update the schema, documentation, and tests. Signed-off-by: Kartik Joshi --- docs/spec.schema.json | 7 ++++ helpers.go | 12 +++++++ image.go | 10 ++++++ imgconfig.go | 4 +++ imgconfig_test.go | 38 +++++++++++++------- load_test.go | 5 +++ targets/linux/rpm/distro/container.go | 13 +++---- targets/linux/rpm/distro/dnf_install.go | 27 ++++++++++++++ targets/linux/rpm/distro/minimize.go | 32 ++++------------- targets/linux/rpm/distro/minimize_test.go | 15 ++++++++ targets/linux/rpm/distro/zypper_install.go | 36 +++++++++++++------ test/linux_target_test.go | 41 +++++++++++++++++----- website/content/image.md | 16 +++++++++ 13 files changed, 192 insertions(+), 64 deletions(-) create mode 100644 targets/linux/rpm/distro/minimize_test.go 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 3752b5f85..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( @@ -74,18 +79,10 @@ func (cfg *Config) BuildContainer(ctx context.Context, client gwclient.Client, s frontend.IgnoreCache(client, targets.IgnoreCacheKeyContainer), ).AddMount(workPath, rootfs) - if !skipBase { - rootfs = minimizeContainer(rootfs, worker, rpmDir, basePkgs, opts...) - } - if post := spec.GetImagePost(targetKey); post != nil && len(post.Symlinks) > 0 { rootfs = rootfs.With(dalec.InstallPostSymlinks(post, worker, frontend.NativeSymlinkSupport(client), opts...)) } - if !skipBase { - rootfs = squashContainer(rootfs, opts...) - } - return rootfs } 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 index 8196c87c0..4c2d0aed4 100644 --- a/targets/linux/rpm/distro/minimize.go +++ b/targets/linux/rpm/distro/minimize.go @@ -213,6 +213,9 @@ while IFS= read -r pkg; do 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" \ @@ -224,36 +227,15 @@ rm -rf \ "${rootfs}/var/log/dnf.librepo.log" \ "${rootfs}/var/log/hawkey.log" \ "${rootfs}/var/log/tdnf.log" \ - "${rootfs}/var/log/yum.log" + "${rootfs}/var/log/yum.log" 2>/dev/null || true ` -func minimizeContainer(rootfs, worker, rpmDir, basePkgs llb.State, opts ...llb.ConstraintsOpt) llb.State { +func minimizeInstall(opts ...llb.ConstraintsOpt) DnfInstallOpt { opts = append(opts, dalec.ProgressGroup("Minimize RPM container")) - const ( - workPath = "/tmp/rootfs" - scriptPath = "/tmp/dalec/internal/rpm/minimize.sh" - rpmMountDir = "/tmp/rpms" - baseMountPath = "/tmp/rpms-base" - ) + const scriptPath = "/tmp/dalec/internal/rpm/minimize.sh" script := llb.Scratch().File(llb.Mkfile("minimize.sh", 0o755, []byte(rpmMinimizeScript)), opts...) - return worker.Run( - dalec.WithConstraints(opts...), - llb.AddMount(scriptPath, script, llb.SourcePath("minimize.sh"), llb.Readonly), - llb.AddMount(rpmMountDir, rpmDir, llb.SourcePath("/RPMS"), llb.Readonly), - llb.AddMount(baseMountPath, basePkgs, llb.SourcePath("/RPMS"), llb.Readonly), - llb.Args([]string{scriptPath}), - ).AddMount(workPath, rootfs) -} - -func squashContainer(rootfs llb.State, opts ...llb.ConstraintsOpt) llb.State { - opts = append(opts, dalec.ProgressGroup("Squash RPM container")) - - return llb.Scratch().File(llb.Copy(rootfs, "/", "/", &llb.CopyInfo{ - CopyDirContentsOnly: true, - CreateDestPath: true, - AllowWildcard: true, - }), 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 de565aaa8..87fa3098f 100644 --- a/test/linux_target_test.go +++ b/test/linux_target_test.go @@ -6141,11 +6141,29 @@ func testRPMMinimalContainer(ctx context.Context, t *testing.T, testConfig testL 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": {}, } @@ -6164,24 +6182,29 @@ func testRPMMinimalContainer(ctx context.Context, t *testing.T, testConfig testL }) }) - t.Run("squash_produces_single_layer", func(t *testing.T) { + 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) - dt, ok := res.Metadata[exptypes.ExporterImageConfigKey] - assert.Assert(t, ok, "missing image config in result metadata") - - var img dalec.DockerImageSpec - assert.NilError(t, json.Unmarshal(dt, &img)) - - assert.Check(t, len(img.RootFS.DiffIDs) <= 1, - "expected squashed image to have at most 1 layer, got %d", len(img.RootFS.DiffIDs)) + 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") }) }) } 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.