From 79a038bfb13a746cedeca8058a229e6973edb10b Mon Sep 17 00:00:00 2001 From: Brian Goff Date: Tue, 24 Feb 2026 15:18:19 -0800 Subject: [PATCH] rpm: generate subpackage sections in RPM spec template Add SubPackages() method and supporting types to produce %package, %description, %files, and scriptlet sections for each supplemental package. Refactor Install() to include subpackage artifacts in the shared %install section. Includes comprehensive template-level tests. Signed-off-by: Brian Goff --- packaging/linux/rpm/buildroot_test.go | 351 +++++++++ packaging/linux/rpm/template.go | 584 ++++++++------ packaging/linux/rpm/template_test.go | 840 ++++++++++++++++++++- targets/linux/rpm/distro/container.go | 8 +- targets/linux/rpm/distro/container_test.go | 91 +++ test/linux_target_test.go | 10 + test/subpackage_test.go | 326 ++++++++ test/target_almalinux_test.go | 3 + test/target_azlinux_test.go | 2 + test/target_rockylinux_test.go | 3 + 10 files changed, 1959 insertions(+), 259 deletions(-) create mode 100644 packaging/linux/rpm/buildroot_test.go create mode 100644 targets/linux/rpm/distro/container_test.go create mode 100644 test/subpackage_test.go diff --git a/packaging/linux/rpm/buildroot_test.go b/packaging/linux/rpm/buildroot_test.go new file mode 100644 index 000000000..ad6853108 --- /dev/null +++ b/packaging/linux/rpm/buildroot_test.go @@ -0,0 +1,351 @@ +package rpm + +import ( + "strings" + "testing" + + "github.com/moby/buildkit/client/llb" + "github.com/project-dalec/dalec" + "github.com/project-dalec/dalec/internal/test" + "gotest.tools/v3/assert" + "gotest.tools/v3/assert/cmp" +) + +func TestRPMSSpec_Buildroot_respects_directory_arg(t *testing.T) { + t.Parallel() + + t.Run("when the directory specified is left empty", func(t *testing.T) { + t.Parallel() + spec := baseSpec("myapp") + + state := RPMSpec(spec, llb.Scratch(), "", "") + + t.Run("the build root is put into /", func(t *testing.T) { + t.Parallel() + ops := test.LLBOpsFromState(t.Context(), t, state) + + dirPath := findMkdir(t, ops) + assert.Equal(t, dirPath, "/SPECS/myapp") + + filePath, _ := findSpecMkfile(t, ops) + assert.Equal(t, filePath, "/SPECS/myapp/myapp.spec") + }) + }) + + t.Run("when the directory specified is non-empty", func(t *testing.T) { + t.Parallel() + spec := baseSpec("myapp") + state := RPMSpec(spec, llb.Scratch(), "", "custom/dir") + + t.Run("the build root is put into that directory", func(t *testing.T) { + t.Parallel() + ops := test.LLBOpsFromState(t.Context(), t, state) + + dirPath := findMkdir(t, ops) + assert.Equal(t, dirPath, "/custom/dir") + + filePath, _ := findSpecMkfile(t, ops) + assert.Equal(t, filePath, "/custom/dir/myapp.spec") + }) + }) +} + +func TestRPMSpec_Subpackages(t *testing.T) { + t.Parallel() + + t.Run("when a spec has no subpackages", func(t *testing.T) { + t.Parallel() + spec := baseSpec("foo") + + state := RPMSpec(spec, llb.Scratch(), "azlinux3", "") + ops := test.LLBOpsFromState(t.Context(), t, state) + + t.Run("then the rpm spec does not have subpackage markers", func(t *testing.T) { + t.Parallel() + _, data := findSpecMkfile(t, ops) + assert.Assert(t, !strings.Contains(data, "%package -n")) + assert.Assert(t, !strings.Contains(data, "%description -n")) + assert.Assert(t, !strings.Contains(data, "%files -n")) + }) + }) + + t.Run("given a spec has subpackages", func(t *testing.T) { + t.Parallel() + + t.Run("when the subpackage uses the default naming", func(t *testing.T) { + t.Parallel() + + spec := baseSpec("foo") + spec.Targets = map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "debug": { + Description: "Debug symbols for foo", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo-debug": {}, + }, + }, + }, + }, + }, + } + + t.Run("the rpm spec has subpackage markers matching the default name", func(t *testing.T) { + t.Parallel() + state := RPMSpec(spec, llb.Scratch(), "azlinux3", "") + ops := test.LLBOpsFromState(t.Context(), t, state) + + _, data := findSpecMkfile(t, ops) + + assert.Assert(t, cmp.Contains(data, "%package -n foo-debug")) + assert.Assert(t, cmp.Contains(data, "%description -n foo-debug")) + assert.Assert(t, cmp.Contains(data, "Debug symbols for foo")) + assert.Assert(t, cmp.Contains(data, "%files -n foo-debug")) + assert.Assert(t, cmp.Contains(data, "%{_bindir}/foo-debug")) + }) + }) + + t.Run("when the subpackage has a custom name", func(t *testing.T) { + t.Parallel() + spec := baseSpec("foo") + spec.Targets = map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "compat": { + Name: "foo-compat-v2", + Description: "Backward compatibility shim", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo-v2": {}, + }, + }, + }, + }, + }, + } + + t.Run("the rpm spec has subpackage markers matching the custom name", func(t *testing.T) { + t.Parallel() + + state := RPMSpec(spec, llb.Scratch(), "azlinux3", "") + ops := test.LLBOpsFromState(t.Context(), t, state) + + _, data := findSpecMkfile(t, ops) + + assert.Assert(t, cmp.Contains(data, "%package -n foo-compat-v2")) + assert.Assert(t, cmp.Contains(data, "%description -n foo-compat-v2")) + assert.Assert(t, cmp.Contains(data, "%files -n foo-compat-v2")) + assert.Assert(t, cmp.Contains(data, "%{_bindir}/foo-v2")) + // Should NOT contain the default derived name + assert.Assert(t, !strings.Contains(data, "%package -n foo-compat\n"), "should use custom name, not default") + }) + }) + + t.Run("when a subpackage declares package relationships", func(t *testing.T) { + t.Parallel() + spec := baseSpec("foo") + spec.Targets = map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "devel": { + Description: "Development files for foo", + Dependencies: &dalec.SubPackageDependencies{ + Runtime: dalec.PackageDependencyList{ + "foo": dalec.PackageConstraints{ + Version: []string{"= %{version}-%{release}"}, + }, + "libfoo-headers": {}, + }, + Recommends: dalec.PackageDependencyList{ + "foo-docs": {}, + }, + }, + Provides: dalec.PackageDependencyList{ + "foo-dev": {}, + }, + Conflicts: dalec.PackageDependencyList{ + "foo-devel-old": { + Version: []string{"< 1.0"}, + }, + }, + Replaces: dalec.PackageDependencyList{ + "foo-devel-legacy": {}, + }, + }, + }, + }, + } + + t.Run("the rpm spec includes the declared package relationships", func(t *testing.T) { + t.Parallel() + state := RPMSpec(spec, llb.Scratch(), "azlinux3", "") + ops := test.LLBOpsFromState(t.Context(), t, state) + + _, data := findSpecMkfile(t, ops) + + assert.Assert(t, cmp.Contains(data, "%package -n foo-devel")) + assert.Assert(t, cmp.Contains(data, "Requires: foo == %{version}-%{release}")) + assert.Assert(t, cmp.Contains(data, "Requires: libfoo-headers")) + assert.Assert(t, cmp.Contains(data, "Recommends: foo-docs")) + assert.Assert(t, cmp.Contains(data, "Provides: foo-dev")) + assert.Assert(t, cmp.Contains(data, "Conflicts: foo-devel-old < 1.0")) + assert.Assert(t, cmp.Contains(data, "Obsoletes: foo-devel-legacy")) + }) + }) + + t.Run("when the spec has multiple subpackages", func(t *testing.T) { + t.Parallel() + spec := baseSpec("foo") + spec.Targets = map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "debug": { + Description: "Debug package", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo-debug": {}, + }, + }, + }, + "contrib": { + Description: "Contrib package", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo-contrib": {}, + }, + }, + }, + }, + }, + } + + t.Run("the rpm spec orders the subpackage sections by key", func(t *testing.T) { + t.Parallel() + state := RPMSpec(spec, llb.Scratch(), "azlinux3", "") + ops := test.LLBOpsFromState(t.Context(), t, state) + + _, data := findSpecMkfile(t, ops) + + assert.Assert(t, cmp.Contains(data, "%package -n foo-contrib")) + assert.Assert(t, cmp.Contains(data, "%package -n foo-debug")) + + contribIdx := strings.Index(data, "%package -n foo-contrib") + debugIdx := strings.Index(data, "%package -n foo-debug") + assert.Assert(t, contribIdx < debugIdx) + }) + }) + + t.Run("when the root package and a subpackage have artifacts", func(t *testing.T) { + t.Parallel() + spec := baseSpec("foo") + spec.Artifacts = dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo": {}, + }, + } + spec.Targets = map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "debug": { + Description: "Debug symbols", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo-debug": {}, + }, + }, + }, + }, + }, + } + + t.Run("the install section includes artifacts from both packages", func(t *testing.T) { + t.Parallel() + state := RPMSpec(spec, llb.Scratch(), "azlinux3", "") + ops := test.LLBOpsFromState(t.Context(), t, state) + + _, data := findSpecMkfile(t, ops) + + assert.Assert(t, cmp.Contains(data, "cp -r foo %{buildroot}/%{_bindir}/foo")) + assert.Assert(t, cmp.Contains(data, "cp -r foo-debug %{buildroot}/%{_bindir}/foo-debug")) + }) + }) + + t.Run("when a target without subpackages is selected", func(t *testing.T) { + t.Parallel() + spec := baseSpec("foo") + spec.Targets = map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "debug": { + Description: "Debug", + }, + }, + }, + } + + t.Run("the rpm spec does not have subpackage markers", func(t *testing.T) { + t.Parallel() + state := RPMSpec(spec, llb.Scratch(), "jammy", "") + ops := test.LLBOpsFromState(t.Context(), t, state) + + _, data := findSpecMkfile(t, ops) + assert.Assert(t, !strings.Contains(data, "%package -n")) + }) + }) + }) +} + +// baseSpec returns a minimal valid spec suitable for RPMSpec(). +func baseSpec(name string) *dalec.Spec { + return &dalec.Spec{ + Name: name, + Version: "1.0.0", + Revision: "1", + Description: "Test package", + License: "MIT", + } +} + +// findSpecMkfile iterates over all LLB ops and returns the path and data of the +// first Mkfile action whose path ends in ".spec". +func findSpecMkfile(t *testing.T, ops []test.LLBOp) (path string, data string) { + t.Helper() + for _, op := range ops { + f := op.Op.GetFile() + if f == nil { + continue + } + for _, a := range f.Actions { + mkfile := a.GetMkfile() + if mkfile == nil { + continue + } + if strings.HasSuffix(mkfile.Path, ".spec") { + return mkfile.Path, string(mkfile.Data) + } + } + } + t.Fatal("no Mkfile action with .spec path found in LLB ops") + return "", "" +} + +// findMkdir iterates over all LLB ops and returns the path of the first Mkdir action. +func findMkdir(t *testing.T, ops []test.LLBOp) string { + t.Helper() + for _, op := range ops { + f := op.Op.GetFile() + if f == nil { + continue + } + for _, a := range f.Actions { + mkdir := a.GetMkdir() + if mkdir == nil { + continue + } + return mkdir.Path + } + } + t.Fatal("no Mkdir action found in LLB ops") + return "" +} diff --git a/packaging/linux/rpm/template.go b/packaging/linux/rpm/template.go index be2a09586..84e3e57f0 100644 --- a/packaging/linux/rpm/template.go +++ b/packaging/linux/rpm/template.go @@ -51,6 +51,7 @@ BuildArch: noarch {{ .PreUn -}} {{ .PostUn -}} {{ .Files -}} +{{ .SubPackages -}} {{ .Changelog -}} `))) @@ -61,6 +62,15 @@ func optionalField(key, value string) string { return key + ": " + value + "\n" } +func writeUserGroupProvides(b *strings.Builder, artifacts dalec.Artifacts) { + for _, user := range artifacts.Users { + fmt.Fprintf(b, "Provides: user(%s)\n", user.Name) + } + for _, group := range artifacts.Groups { + fmt.Fprintf(b, "Provides: group(%s)\n", group.Name) + } +} + var tmplFuncs = map[string]any{ "optionalField": optionalField, } @@ -96,10 +106,8 @@ func (w *specWrapper) Provides() fmt.Stringer { provides := w.Spec.GetProvides(w.Target) - ls := dalec.SortMapKeys(provides) - - for _, name := range ls { - writeDep(b, "Provides", name, provides[name]) + for name, constraints := range dalec.SortedMapIter(provides) { + writeDep(b, "Provides", name, constraints) } // Self-Provide `user(X)` / `group(X)` for every user/group this @@ -136,13 +144,7 @@ func (w *specWrapper) Provides() fmt.Stringer { // - rpm-sysusers(7), "DEPENDENCIES" section, current doc covering // the %attr → user()/group() Requires generation: // https://rpm-software-management.github.io/rpm/man/rpm-sysusers.7 - artifacts := w.Spec.GetArtifacts(w.Target) - for _, user := range artifacts.Users { - fmt.Fprintf(b, "Provides: user(%s)\n", user.Name) - } - for _, group := range artifacts.Groups { - fmt.Fprintf(b, "Provides: group(%s)\n", group.Name) - } + writeUserGroupProvides(b, w.Spec.GetArtifacts(w.Target)) if b.Len() > 0 { b.WriteString("\n") @@ -228,24 +230,72 @@ func getUserPostRequires(users []dalec.AddUserConfig, groups []dalec.AddGroupCon // However, AzureLinux cannot resolve the /usr/bin/chown requirement. // Thus, we just require coreutils which provides chown/chgrp on all distros hopefully. func getOwnershipPostRequires(artifacts dalec.Artifacts) string { - out := "Requires(post): coreutils\n" - for _, cfg := range artifacts.Binaries { - if cfg.User != "" || cfg.Group != "" { - return out + if hasOwnershipCommands(artifacts) { + return "Requires(post): coreutils\n" + } + return "" +} + +func ownershipRequested(user, group string) bool { + return user != "" || group != "" +} + +func artifactMapHasOwnership(artifacts map[string]dalec.ArtifactConfig) bool { + for _, cfg := range artifacts { + if ownershipRequested(cfg.User, cfg.Group) { + return true } } - for _, cfg := range artifacts.Libs { - if cfg.User != "" || cfg.Group != "" { - return out + return false +} + +func directoryMapHasOwnership(dirs map[string]dalec.ArtifactDirConfig) bool { + for _, cfg := range dirs { + if ownershipRequested(cfg.User, cfg.Group) { + return true } } - for _, cfg := range artifacts.Libexec { - if cfg.User != "" || cfg.Group != "" { - return out + return false +} + +func symlinkOwnershipChanges(artifacts dalec.Artifacts, link dalec.ArtifactSymlinkConfig) (user, group bool) { + for _, candidate := range artifacts.Users { + if candidate.Name == link.User { + user = true + break + } + } + for _, candidate := range artifacts.Groups { + if candidate.Name == link.Group { + group = true + break } } + return user, group +} - return "" +func hasOwnershipCommands(artifacts dalec.Artifacts) bool { + if artifactMapHasOwnership(artifacts.ConfigFiles) || + artifactMapHasOwnership(artifacts.DataDirs) || + artifactMapHasOwnership(artifacts.Libs) || + artifactMapHasOwnership(artifacts.Binaries) || + artifactMapHasOwnership(artifacts.Libexec) { + return true + } + + if artifacts.Directories != nil && + (directoryMapHasOwnership(artifacts.Directories.Config) || + directoryMapHasOwnership(artifacts.Directories.State)) { + return true + } + + for _, link := range artifacts.Links { + user, group := symlinkOwnershipChanges(artifacts, link) + if user || group { + return true + } + } + return false } func (w *specWrapper) Requires() fmt.Stringer { @@ -553,23 +603,31 @@ func systemdPreUnScript(unitName string, cfg dalec.SystemdUnitConfig) string { return fmt.Sprintf("%%systemd_preun %s\n", unitName) } -func (w *specWrapper) PreUn() fmt.Stringer { - b := &strings.Builder{} - - artifacts := w.GetArtifacts(w.Target) +func preUnScriptBody(artifacts dalec.Artifacts) string { if artifacts.Systemd.IsEmpty() || (len(artifacts.Systemd.EnabledUnits()) == 0) { - return b + return "" } - b.WriteString("%preun\n") - keys := dalec.SortMapKeys(artifacts.Systemd.Units) - for _, servicePath := range keys { - serviceName := filepath.Base(servicePath) - unitConf := artifacts.Systemd.Units[servicePath] + b := &strings.Builder{} + for servicePath, unitConf := range dalec.SortedMapIter(artifacts.Systemd.Units) { + serviceName := unitConf.Artifact().ResolveName(servicePath) b.WriteString( systemdPreUnScript(serviceName, unitConf), ) } + return b.String() +} + +func (w *specWrapper) PreUn() fmt.Stringer { + b := &strings.Builder{} + + body := preUnScriptBody(w.GetArtifacts(w.Target)) + if body == "" { + return b + } + + b.WriteString("%preun\n") + b.WriteString(body) return b } @@ -607,50 +665,33 @@ fi return s } -func (w *specWrapper) Post() fmt.Stringer { +func postScriptBody(artifacts dalec.Artifacts) string { b := &strings.Builder{} + b.WriteString(systemdPostSection(artifacts)) + b.WriteString(postUsersScript(artifacts)) + b.WriteString(postGroupsScript(artifacts)) + b.WriteString(symlinkOwnershipScript(artifacts)) + b.WriteString(artifactOwnershipScript(artifacts)) + b.WriteString(directoryOwnershipScript(artifacts)) + b.WriteString(artifactCapabilitiesScript(artifacts)) + return b.String() +} - systemd := w.postSystemd() - users := w.postUsers() - groups := w.postGroups() - symlinkOwnership := w.getSymlinkOwnership() - artifactOwnership := w.getArtifactOwnership() - directoryOwnership := w.getDirectoryOwnership() - artifactCapabilities := w.getArtifactCapabilities() +func (w *specWrapper) Post() fmt.Stringer { + b := &strings.Builder{} - if systemd == "" && users == "" && groups == "" && symlinkOwnership == "" && artifactOwnership == "" && directoryOwnership == "" && artifactCapabilities == "" { + body := postScriptBody(w.Spec.GetArtifacts(w.Target)) + if body == "" { return b } b.WriteString("%post\n") - if systemd != "" { - b.WriteString(systemd) - } - if users != "" { - b.WriteString(users) - } - if groups != "" { - b.WriteString(groups) - } - if symlinkOwnership != "" { - b.WriteString(symlinkOwnership) - } - if artifactOwnership != "" { - b.WriteString(artifactOwnership) - } - if directoryOwnership != "" { - b.WriteString(directoryOwnership) - } - if artifactCapabilities != "" { - b.WriteString(artifactCapabilities) - } - + b.WriteString(body) b.WriteString("\n") return b } -func (w *specWrapper) postUsers() string { - artifacts := w.Spec.GetArtifacts(w.Target) +func postUsersScript(artifacts dalec.Artifacts) string { if len(artifacts.Users) == 0 { return "" } @@ -662,8 +703,7 @@ func (w *specWrapper) postUsers() string { return b.String() } -func (w *specWrapper) postGroups() string { - artifacts := w.Spec.GetArtifacts(w.Target) +func postGroupsScript(artifacts dalec.Artifacts) string { if len(artifacts.Groups) == 0 { return "" } @@ -675,14 +715,13 @@ func (w *specWrapper) postGroups() string { return b.String() } -func (w *specWrapper) getDirectoryOwnership() string { - artifacts := w.Spec.GetArtifacts(w.Target) +func directoryOwnershipScript(artifacts dalec.Artifacts) string { if artifacts.Directories == nil { return "" } b := &strings.Builder{} setDirOwnership := func(root, p string, cfg *dalec.ArtifactDirConfig) { - if cfg == nil { + if cfg == nil || !ownershipRequested(cfg.User, cfg.Group) { return } user := cfg.User @@ -695,25 +734,20 @@ func (w *specWrapper) getDirectoryOwnership() string { fmt.Fprintf(b, "chgrp -R %s %s\n", group, targetDir) } } - configKeys := dalec.SortMapKeys(artifacts.Directories.Config) - for _, p := range configKeys { - cfg := artifacts.Directories.Config[p] + for p, cfg := range dalec.SortedMapIter(artifacts.Directories.Config) { setDirOwnership(`/%{_sysconfdir}`, p, &cfg) } - stateKeys := dalec.SortMapKeys(artifacts.Directories.State) - for _, p := range stateKeys { - cfg := artifacts.Directories.State[p] + for p, cfg := range dalec.SortedMapIter(artifacts.Directories.State) { setDirOwnership(`/%{_sharedstatedir}`, p, &cfg) } return b.String() } -func (w *specWrapper) getArtifactOwnership() string { - artifacts := w.Spec.GetArtifacts(w.Target) +func artifactOwnershipScript(artifacts dalec.Artifacts) string { b := &strings.Builder{} setArtifactOwnership := func(root, p string, cfg *dalec.ArtifactConfig) { - if cfg == nil { + if cfg == nil || !ownershipRequested(cfg.User, cfg.Group) { return } user := cfg.User @@ -735,40 +769,36 @@ func (w *specWrapper) getArtifactOwnership() string { } if artifacts.ConfigFiles != nil { - configKeys := dalec.SortMapKeys(artifacts.ConfigFiles) - for _, c := range configKeys { - cfg := artifacts.ConfigFiles[c] + for c, cfg := range dalec.SortedMapIter(artifacts.ConfigFiles) { setArtifactOwnership(`/%{_sysconfdir}`, c, &cfg) } } if artifacts.DataDirs != nil { - dataFileKeys := dalec.SortMapKeys(artifacts.DataDirs) - for _, k := range dataFileKeys { - df := artifacts.DataDirs[k] + for k, df := range dalec.SortedMapIter(artifacts.DataDirs) { setArtifactOwnership(`/%{_datadir}`, k, &df) } } // Directory ownership is handled in getDirectoryOwnership; do not duplicate here. if artifacts.Libs != nil { - libs := dalec.SortMapKeys(artifacts.Libs) - for _, l := range libs { - cfg := artifacts.Libs[l] + for l, cfg := range dalec.SortedMapIter(artifacts.Libs) { setArtifactOwnership(`/%{_libdir}`, l, &cfg) } } if artifacts.Binaries != nil { - binKeys := dalec.SortMapKeys(artifacts.Binaries) - for _, p := range binKeys { - cfg := artifacts.Binaries[p] + for p, cfg := range dalec.SortedMapIter(artifacts.Binaries) { setArtifactOwnership(`/%{_bindir}`, p, &cfg) } } + if artifacts.Libexec != nil { + for p, cfg := range dalec.SortedMapIter(artifacts.Libexec) { + setArtifactOwnership(`/%{_libexecdir}`, p, &cfg) + } + } return b.String() } -func (w *specWrapper) getArtifactCapabilities() string { - artifacts := w.Spec.GetArtifacts(w.Target) +func artifactCapabilitiesScript(artifacts dalec.Artifacts) string { b := &strings.Builder{} // Only use setcap in postinstall if there's also a chown/chgrp @@ -792,23 +822,17 @@ func (w *specWrapper) getArtifactCapabilities() string { } if artifacts.Libs != nil { - libs := dalec.SortMapKeys(artifacts.Libs) - for _, l := range libs { - cfg := artifacts.Libs[l] + for l, cfg := range dalec.SortedMapIter(artifacts.Libs) { setArtifactCapabilities(`/%{_libdir}`, l, &cfg) } } if artifacts.Binaries != nil { - binKeys := dalec.SortMapKeys(artifacts.Binaries) - for _, p := range binKeys { - cfg := artifacts.Binaries[p] + for p, cfg := range dalec.SortedMapIter(artifacts.Binaries) { setArtifactCapabilities(`/%{_bindir}`, p, &cfg) } } if artifacts.Libexec != nil { - libexecKeys := dalec.SortMapKeys(artifacts.Libexec) - for _, k := range libexecKeys { - cfg := artifacts.Libexec[k] + for k, cfg := range dalec.SortedMapIter(artifacts.Libexec) { setArtifactCapabilities(`/%{_libexecdir}`, k, &cfg) } } @@ -816,35 +840,25 @@ func (w *specWrapper) getArtifactCapabilities() string { return b.String() } -func (w *specWrapper) getSymlinkOwnership() string { - artifacts := w.Spec.GetArtifacts(w.Target) +func symlinkOwnershipScript(artifacts dalec.Artifacts) string { if len(artifacts.Links) == 0 { return "" } b := &strings.Builder{} - users := make(map[string]struct{}, len(artifacts.Users)) - groups := make(map[string]struct{}, len(artifacts.Groups)) - for _, user := range artifacts.Users { - users[user.Name] = struct{}{} - } - for _, group := range artifacts.Groups { - groups[group.Name] = struct{}{} - } - for _, link := range artifacts.Links { - if _, ok := users[link.User]; ok { + user, group := symlinkOwnershipChanges(artifacts, link) + if user { fmt.Fprintf(b, "chown -h %s %s\n", link.User, link.Dest) } - if _, ok := groups[link.Group]; ok { + if group { fmt.Fprintf(b, "chgrp -h %s %s\n", link.Group, link.Dest) } } return b.String() } -func (w *specWrapper) postSystemd() string { - artifacts := w.Spec.GetArtifacts(w.Target) +func systemdPostSection(artifacts dalec.Artifacts) string { if artifacts.Systemd.IsEmpty() { return "" } @@ -855,10 +869,8 @@ func (w *specWrapper) postSystemd() string { // as this eliminates the need for extra dependencies in the target container return "" } - b := &strings.Builder{} - keys := dalec.SortMapKeys(enabledUnits) - for _, servicePath := range keys { + for servicePath := range dalec.SortedMapIter(enabledUnits) { unitConf := artifacts.Systemd.Units[servicePath] artifact := unitConf.Artifact() b.WriteString( @@ -869,23 +881,30 @@ func (w *specWrapper) postSystemd() string { return b.String() } -func (w *specWrapper) PostUn() fmt.Stringer { - b := &strings.Builder{} - - artifacts := w.GetArtifacts(w.Target) +func postUnScriptBody(artifacts dalec.Artifacts) string { if artifacts.Systemd.IsEmpty() { - return b + return "" } - b.WriteString("%postun\n") - keys := dalec.SortMapKeys(artifacts.Systemd.Units) - for _, servicePath := range keys { - cfg := artifacts.Systemd.Units[servicePath] + b := &strings.Builder{} + for servicePath, cfg := range dalec.SortedMapIter(artifacts.Systemd.Units) { a := cfg.Artifact() serviceName := a.ResolveName(servicePath) fmt.Fprintf(b, "%%systemd_postun %s\n", serviceName) } + return b.String() +} + +func (w *specWrapper) PostUn() fmt.Stringer { + b := &strings.Builder{} + + body := postUnScriptBody(w.GetArtifacts(w.Target)) + if body == "" { + return b + } + b.WriteString("%postun\n") + b.WriteString(body) return b } @@ -894,7 +913,20 @@ func (w *specWrapper) Install() fmt.Stringer { fmt.Fprintln(b, "%install") artifacts := w.Spec.GetArtifacts(w.Target) + installArtifacts(b, artifacts, w.Name) + for key, pkg := range dalec.GetSubPackagesForTarget(w.Spec, w.Target) { + if pkg.Artifacts != nil { + resolvedName := pkg.ResolvedName(w.Spec.Name, key) + installArtifacts(b, *pkg.Artifacts, resolvedName) + } + } + + b.WriteString("\n") + return b +} + +func installArtifacts(b *strings.Builder, artifacts dalec.Artifacts, pkgName string) { copyArtifact := func(root, p string, cfg *dalec.ArtifactConfig) { if cfg == nil { return @@ -915,20 +947,12 @@ func (w *specWrapper) Install() fmt.Stringer { } } - if len(artifacts.Binaries) > 0 { - binKeys := dalec.SortMapKeys(artifacts.Binaries) - for _, p := range binKeys { - cfg := artifacts.Binaries[p] - copyArtifact(`%{buildroot}/%{_bindir}`, p, &cfg) - } + for p, cfg := range dalec.SortedMapIter(artifacts.Binaries) { + copyArtifact(`%{buildroot}/%{_bindir}`, p, &cfg) } - if len(artifacts.Manpages) > 0 { - manKeys := dalec.SortMapKeys(artifacts.Manpages) - for _, p := range manKeys { - cfg := artifacts.Manpages[p] - copyArtifact(`%{buildroot}/%{_mandir}`, p, &cfg) - } + for p, cfg := range dalec.SortedMapIter(artifacts.Manpages) { + copyArtifact(`%{buildroot}/%{_mandir}`, p, &cfg) } createArtifactDir := func(root, p string, cfg dalec.ArtifactDirConfig) { @@ -942,73 +966,51 @@ func (w *specWrapper) Install() fmt.Stringer { } if artifacts.Directories != nil { - configKeys := dalec.SortMapKeys(artifacts.Directories.Config) - for _, p := range configKeys { - cfg := artifacts.Directories.Config[p] + for p, cfg := range dalec.SortedMapIter(artifacts.Directories.Config) { createArtifactDir(`%{buildroot}/%{_sysconfdir}`, p, cfg) } - stateKeys := dalec.SortMapKeys(artifacts.Directories.State) - for _, p := range stateKeys { - cfg := artifacts.Directories.State[p] + for p, cfg := range dalec.SortedMapIter(artifacts.Directories.State) { createArtifactDir(`%{buildroot}/%{_sharedstatedir}`, p, cfg) } } - if len(artifacts.DataDirs) > 0 { - dataFileKeys := dalec.SortMapKeys(artifacts.DataDirs) - for _, k := range dataFileKeys { - df := artifacts.DataDirs[k] - copyArtifact(`%{buildroot}/%{_datadir}`, k, &df) - } + for k, df := range dalec.SortedMapIter(artifacts.DataDirs) { + copyArtifact(`%{buildroot}/%{_datadir}`, k, &df) } if artifacts.Libexec != nil { - libexecFileKeys := dalec.SortMapKeys(artifacts.Libexec) - for _, k := range libexecFileKeys { - le := artifacts.Libexec[k] + for k, le := range dalec.SortedMapIter(artifacts.Libexec) { copyArtifact(`%{buildroot}/%{_libexecdir}`, k, &le) } } - configKeys := dalec.SortMapKeys(artifacts.ConfigFiles) - for _, c := range configKeys { - cfg := artifacts.ConfigFiles[c] + for c, cfg := range dalec.SortedMapIter(artifacts.ConfigFiles) { copyArtifact(`%{buildroot}/%{_sysconfdir}`, c, &cfg) } if artifacts.Systemd != nil { - serviceKeys := dalec.SortMapKeys(artifacts.Systemd.Units) - for _, p := range serviceKeys { - cfg := artifacts.Systemd.Units[p] + for p, cfg := range dalec.SortedMapIter(artifacts.Systemd.Units) { // must include systemd unit extension (.service, .socket, .timer, etc.) in name copyArtifact(`%{buildroot}/%{_unitdir}`, p, cfg.Artifact()) } - dropinKeys := dalec.SortMapKeys(artifacts.Systemd.Dropins) - for _, d := range dropinKeys { - cfg := artifacts.Systemd.Dropins[d] + for d, cfg := range dalec.SortedMapIter(artifacts.Systemd.Dropins) { copyArtifact(`%{buildroot}/%{_unitdir}`, d, cfg.Artifact()) } } - docKeys := dalec.SortMapKeys(artifacts.Docs) - for _, d := range docKeys { - cfg := artifacts.Docs[d] - root := filepath.Join(`%{buildroot}/%{_docdir}`, w.Name) + for d, cfg := range dalec.SortedMapIter(artifacts.Docs) { + root := filepath.Join(`%{buildroot}/%{_docdir}`, pkgName) copyArtifact(root, d, &cfg) } - licenseKeys := dalec.SortMapKeys(artifacts.Licenses) - for _, l := range licenseKeys { - cfg := artifacts.Licenses[l] - root := filepath.Join(`%{buildroot}/%{_licensedir}`, w.Name) + for l, cfg := range dalec.SortedMapIter(artifacts.Licenses) { + root := filepath.Join(`%{buildroot}/%{_licensedir}`, pkgName) copyArtifact(root, l, &cfg) } - libs := dalec.SortMapKeys(artifacts.Libs) - for _, l := range libs { - cfg := artifacts.Libs[l] + for l, cfg := range dalec.SortedMapIter(artifacts.Libs) { root := filepath.Join(`%{buildroot}/%{_libdir}`) copyArtifact(root, l, &cfg) } @@ -1018,67 +1020,55 @@ func (w *specWrapper) Install() fmt.Stringer { fmt.Fprintln(b, "ln -sf", l.Source, "%{buildroot}/"+l.Dest) } - headersKeys := dalec.SortMapKeys(artifacts.Headers) - for _, h := range headersKeys { - cfg := artifacts.Headers[h] + for h, cfg := range dalec.SortedMapIter(artifacts.Headers) { copyArtifact(`%{buildroot}/%{_includedir}`, h, &cfg) } - b.WriteString("\n") - return b } func (w *specWrapper) Files() fmt.Stringer { b := &strings.Builder{} fmt.Fprintf(b, "%%files\n") + writeFilesBody(b, w.GetArtifacts(w.Target), w.Name) + b.WriteString("\n") + return b +} - artifacts := w.GetArtifacts(w.Target) - - if len(artifacts.Binaries) > 0 { - binKeys := dalec.SortMapKeys(artifacts.Binaries) - for _, p := range binKeys { - cfg := artifacts.Binaries[p] - full := filepath.Join(`%{_bindir}/`, cfg.SubPath, cfg.ResolveName(p)) - // Use %caps macro if capabilities are set and there's no chown - capString := dalec.CapabilitiesString(cfg.LinuxCapabilities) - if capString != "" && cfg.User == "" && cfg.Group == "" { - fmt.Fprintf(b, "%%caps(%s) %s\n", capString, full) - } else { - fmt.Fprintln(b, full) - } +func writeFilesBody(b *strings.Builder, artifacts dalec.Artifacts, pkgName string) { + for p, cfg := range dalec.SortedMapIter(artifacts.Binaries) { + full := filepath.Join(`%{_bindir}/`, cfg.SubPath, cfg.ResolveName(p)) + // Use %caps macro if capabilities are set and there's no chown + capString := dalec.CapabilitiesString(cfg.LinuxCapabilities) + if capString != "" && cfg.User == "" && cfg.Group == "" { + fmt.Fprintf(b, "%%caps(%s) %s\n", capString, full) + } else { + fmt.Fprintln(b, full) } } - if len(artifacts.Manpages) > 0 { - fmt.Fprintln(b, `%{_mandir}/*/*`) + for p, cfg := range dalec.SortedMapIter(artifacts.Manpages) { + path := filepath.Join(`%{_mandir}`, cfg.SubPath, cfg.ResolveName(p)) + fmt.Fprintln(b, path) } if artifacts.Directories != nil { - configKeys := dalec.SortMapKeys(artifacts.Directories.Config) - for _, p := range configKeys { + for p := range dalec.SortedMapIter(artifacts.Directories.Config) { dir := strings.Join([]string{`%dir`, filepath.Join(`%{_sysconfdir}`, p)}, " ") fmt.Fprintln(b, dir) } - stateKeys := dalec.SortMapKeys(artifacts.Directories.State) - for _, p := range stateKeys { + for p := range dalec.SortedMapIter(artifacts.Directories.State) { dir := strings.Join([]string{`%dir`, filepath.Join(`%{_sharedstatedir}`, p)}, " ") fmt.Fprintln(b, dir) } } - if artifacts.DataDirs != nil { - dataKeys := dalec.SortMapKeys(artifacts.DataDirs) - for _, k := range dataKeys { - df := artifacts.DataDirs[k] + for k, df := range dalec.SortedMapIter(artifacts.DataDirs) { fullPath := filepath.Join(`%{_datadir}`, df.SubPath, df.ResolveName(k)) fmt.Fprintln(b, fullPath) } } - if artifacts.Libexec != nil { - dataKeys := dalec.SortMapKeys(artifacts.Libexec) - for _, k := range dataKeys { - le := artifacts.Libexec[k] + for k, le := range dalec.SortedMapIter(artifacts.Libexec) { targetDir := filepath.Join(`%{_libexecdir}`, le.SubPath) fullPath := filepath.Join(targetDir, le.ResolveName(k)) // Use %caps macro if capabilities are set and there's no chown @@ -1091,18 +1081,13 @@ func (w *specWrapper) Files() fmt.Stringer { } } - configKeys := dalec.SortMapKeys(artifacts.ConfigFiles) - for _, c := range configKeys { - cfg := artifacts.ConfigFiles[c] + for c, cfg := range dalec.SortedMapIter(artifacts.ConfigFiles) { fullPath := filepath.Join(`%{_sysconfdir}`, cfg.SubPath, cfg.ResolveName(c)) fullDirective := strings.Join([]string{`%config(noreplace)`, fullPath}, " ") fmt.Fprintln(b, fullDirective) } - if artifacts.Systemd != nil { - serviceKeys := dalec.SortMapKeys(artifacts.Systemd.Units) - for _, p := range serviceKeys { - cfg := artifacts.Systemd.Units[p] + for p, cfg := range dalec.SortedMapIter(artifacts.Systemd.Units) { a := cfg.Artifact() unitPath := filepath.Join(`%{_unitdir}/`, a.SubPath, a.ResolveName(p)) fmt.Fprintln(b, unitPath) @@ -1112,9 +1097,7 @@ func (w *specWrapper) Files() fmt.Stringer { // process these to get a unique list of files per unit name. // we need a single dir entry for the directory // need a file entry for each of files - dropinKeys := dalec.SortMapKeys(artifacts.Systemd.Dropins) - for _, d := range dropinKeys { - cfg := artifacts.Systemd.Dropins[d] + for d, cfg := range dalec.SortedMapIter(artifacts.Systemd.Dropins) { art := cfg.Artifact() files, ok := dropins[cfg.Unit] if !ok { @@ -1127,8 +1110,7 @@ func (w *specWrapper) Files() fmt.Stringer { ) dropins[cfg.Unit] = append(files, p) } - unitNames := dalec.SortMapKeys(dropins) - for _, u := range unitNames { + for u := range dalec.SortedMapIter(dropins) { dir := strings.Join([]string{ `%dir`, filepath.Join( @@ -1144,25 +1126,19 @@ func (w *specWrapper) Files() fmt.Stringer { } } - docKeys := dalec.SortMapKeys(artifacts.Docs) - for _, d := range docKeys { - cfg := artifacts.Docs[d] - path := filepath.Join(`%{_docdir}`, w.Name, cfg.SubPath, cfg.ResolveName(d)) + for d, cfg := range dalec.SortedMapIter(artifacts.Docs) { + path := filepath.Join(`%{_docdir}`, pkgName, cfg.SubPath, cfg.ResolveName(d)) fullDirective := strings.Join([]string{`%doc`, path}, " ") fmt.Fprintln(b, fullDirective) } - licenseKeys := dalec.SortMapKeys(artifacts.Licenses) - for _, l := range licenseKeys { - cfg := artifacts.Licenses[l] - path := filepath.Join(`%{_licensedir}`, w.Name, cfg.SubPath, cfg.ResolveName(l)) + for l, cfg := range dalec.SortedMapIter(artifacts.Licenses) { + path := filepath.Join(`%{_licensedir}`, pkgName, cfg.SubPath, cfg.ResolveName(l)) fullDirective := strings.Join([]string{`%license`, path}, " ") fmt.Fprintln(b, fullDirective) } - libKeys := dalec.SortMapKeys(artifacts.Libs) - for _, l := range libKeys { - cfg := artifacts.Libs[l] + for l, cfg := range dalec.SortedMapIter(artifacts.Libs) { path := filepath.Join(`%{_libdir}`, cfg.SubPath, cfg.ResolveName(l)) // Use %caps macro if capabilities are set and there's no chown capString := dalec.CapabilitiesString(cfg.LinuxCapabilities) @@ -1189,21 +1165,14 @@ func (w *specWrapper) Files() fmt.Stringer { } } - if len(artifacts.Headers) > 0 { - headersKeys := dalec.SortMapKeys(artifacts.Headers) - for _, h := range headersKeys { - hf := artifacts.Headers[h] - path := filepath.Join(`%{_includedir}`, hf.SubPath, hf.ResolveName(h)) - fmt.Fprintln(b, path) - } + for h, hf := range dalec.SortedMapIter(artifacts.Headers) { + path := filepath.Join(`%{_includedir}`, hf.SubPath, hf.ResolveName(h)) + fmt.Fprintln(b, path) } - b.WriteString("\n") - return b } func (w *specWrapper) DisableStrip() string { - artifacts := w.Spec.GetArtifacts(w.Target) - if artifacts.DisableStrip { + if w.Spec.GetArtifacts(w.Target).DisableStrip { return "%global __strip /bin/true" } return "" @@ -1217,6 +1186,135 @@ func (w *specWrapper) DisableAutoReq() string { return "" } +type subPkgWrapper struct { + ResolvedName string + Pkg dalec.SubPackage + ParentName string +} + +// RPM's -n form handles both derived and custom package names uniformly. +func (s *subPkgWrapper) directive() string { + return "-n " + s.ResolvedName +} + +func (w *specWrapper) SubPackages() (fmt.Stringer, error) { + b := &strings.Builder{} + + for key, pkg := range dalec.GetSubPackagesForTarget(w.Spec, w.Target) { + sw := &subPkgWrapper{ + ResolvedName: pkg.ResolvedName(w.Spec.Name, key), + Pkg: pkg, + ParentName: w.Spec.Name, + } + + sw.writePackageHeader(b) + sw.writeDescription(b) + sw.writePost(b) + sw.writePreUn(b) + sw.writePostUn(b) + sw.writeFiles(b) + } + + return b, nil +} + +func (s *subPkgWrapper) writePackageHeader(b *strings.Builder) { + fmt.Fprintf(b, "%%package %s\n", s.directive()) + fmt.Fprintf(b, "Summary: %s\n", s.Pkg.Description) + + // Install-time requirements for the subpackage's own scriptlets. These do + // not come from the spec's declared dependencies; they are derived from the + // artifacts the subpackage ships (systemd units, users/groups, ownership). + if s.Pkg.Artifacts != nil { + if s.Pkg.Artifacts.DisableAutoRequires { + b.WriteString("AutoReq: no\n") + } + b.WriteString(getSystemdRequires(s.Pkg.Artifacts.Systemd)) + b.WriteString(getUserPostRequires(s.Pkg.Artifacts.Users, s.Pkg.Artifacts.Groups)) + b.WriteString(getOwnershipPostRequires(*s.Pkg.Artifacts)) + } + + runtimeDeps := s.Pkg.Dependencies.GetRuntime() + for name, constraints := range dalec.SortedMapIter(runtimeDeps) { + writeDep(b, "Requires", name, constraints) + } + + recommends := s.Pkg.Dependencies.GetRecommends() + for name, constraints := range dalec.SortedMapIter(recommends) { + writeDep(b, "Recommends", name, constraints) + } + + for name, constraints := range dalec.SortedMapIter(s.Pkg.Provides) { + writeDep(b, "Provides", name, constraints) + } + if s.Pkg.Artifacts != nil { + writeUserGroupProvides(b, *s.Pkg.Artifacts) + } + + for name, constraints := range dalec.SortedMapIter(s.Pkg.Conflicts) { + writeDep(b, "Conflicts", name, constraints) + } + + for name, constraints := range dalec.SortedMapIter(s.Pkg.Replaces) { + writeDep(b, "Obsoletes", name, constraints) + } + + b.WriteString("\n") +} + +func (s *subPkgWrapper) writeDescription(b *strings.Builder) { + fmt.Fprintf(b, "%%description %s\n", s.directive()) + fmt.Fprintf(b, "%s\n\n", s.Pkg.Description) +} + +func (s *subPkgWrapper) writePost(b *strings.Builder) { + if s.Pkg.Artifacts == nil { + return + } + body := postScriptBody(*s.Pkg.Artifacts) + if body == "" { + return + } + fmt.Fprintf(b, "%%post %s\n", s.directive()) + b.WriteString(body) + b.WriteString("\n") +} + +func (s *subPkgWrapper) writePreUn(b *strings.Builder) { + if s.Pkg.Artifacts == nil { + return + } + body := preUnScriptBody(*s.Pkg.Artifacts) + if body == "" { + return + } + fmt.Fprintf(b, "%%preun %s\n", s.directive()) + b.WriteString(body) + b.WriteString("\n") +} + +func (s *subPkgWrapper) writePostUn(b *strings.Builder) { + if s.Pkg.Artifacts == nil { + return + } + body := postUnScriptBody(*s.Pkg.Artifacts) + if body == "" { + return + } + fmt.Fprintf(b, "%%postun %s\n", s.directive()) + b.WriteString(body) + b.WriteString("\n") +} + +func (s *subPkgWrapper) writeFiles(b *strings.Builder) { + fmt.Fprintf(b, "%%files %s\n", s.directive()) + + if s.Pkg.Artifacts != nil { + writeFilesBody(b, *s.Pkg.Artifacts, s.ResolvedName) + } + b.WriteString("\n") +} + // WriteSpec generates an rpm spec from the provided [dalec.Spec] and distro target and writes it to the passed in writer func WriteSpec(spec *dalec.Spec, target string, w io.Writer) error { return WriteSpecWithSourceFilter(spec, target, dalec.SourceFilterConfig{}, w) diff --git a/packaging/linux/rpm/template_test.go b/packaging/linux/rpm/template_test.go index cbe33e717..c08cf1ed7 100644 --- a/packaging/linux/rpm/template_test.go +++ b/packaging/linux/rpm/template_test.go @@ -393,6 +393,24 @@ fi `) }) + t.Run("renamed systemd units are disabled under their installed name", func(t *testing.T) { + w := &specWrapper{Spec: &dalec.Spec{ + Artifacts: dalec.Artifacts{ + Systemd: &dalec.SystemdConfiguration{ + Units: map[string]dalec.SystemdUnitConfig{ + "src/original.service": { + Name: "installed.service", + Enable: true, + }, + }, + }, + }, + }} + + got := w.PreUn().String() + assert.Equal(t, got, "%preun\n%systemd_preun installed.service\n") + }) + t.Run("test systemd preun, no enabled units", func(t *testing.T) { w := &specWrapper{Spec: &dalec.Spec{ Artifacts: dalec.Artifacts{ @@ -750,7 +768,7 @@ getent group testgroup >/dev/null || groupadd --system testgroup assert.Equal(t, want, got) }) - t.Run("disable auto requires", func(t *testing.T) { + t.Run("the root package controls automatic requirement generation", func(t *testing.T) { w := &specWrapper{Spec: &dalec.Spec{ Artifacts: dalec.Artifacts{ DisableAutoRequires: true, @@ -1011,24 +1029,153 @@ OrderWithRequires(postun): systemd want := "Requires(post): /usr/sbin/groupadd, /usr/bin/getent\n" assert.Equal(t, got, want) }) + + t.Run("coreutils is required exactly when ownership commands are generated", func(t *testing.T) { + cases := []struct { + name string + artifacts dalec.Artifacts + scripts []string + }{ + { + name: "binary user ownership emits chown", + artifacts: dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{"app": {User: "svc"}}, + }, + scripts: []string{"chown -R svc /%{_bindir}/app\n"}, + }, + { + name: "library group ownership emits chgrp", + artifacts: dalec.Artifacts{ + Libs: map[string]dalec.ArtifactConfig{"libapp.so": {Group: "svc"}}, + }, + scripts: []string{"chgrp -R svc /%{_libdir}/libapp.so\n"}, + }, + { + name: "libexec user ownership emits chown", + artifacts: dalec.Artifacts{ + Libexec: map[string]dalec.ArtifactConfig{"helper": {User: "svc"}}, + }, + scripts: []string{"chown -R svc /%{_libexecdir}/helper\n"}, + }, + { + name: "config file user ownership emits chown", + artifacts: dalec.Artifacts{ + ConfigFiles: map[string]dalec.ArtifactConfig{"app.conf": {User: "svc"}}, + }, + scripts: []string{"chown -R svc /%{_sysconfdir}/app.conf\n"}, + }, + { + name: "data file group ownership emits chgrp", + artifacts: dalec.Artifacts{ + DataDirs: map[string]dalec.ArtifactConfig{"data": {Group: "svc"}}, + }, + scripts: []string{"chgrp -R svc /%{_datadir}/data\n"}, + }, + { + name: "config directory user ownership emits chown", + artifacts: dalec.Artifacts{ + Directories: &dalec.CreateArtifactDirectories{ + Config: map[string]dalec.ArtifactDirConfig{"app": {User: "svc"}}, + }, + }, + scripts: []string{"chown -R svc /%{_sysconfdir}/app\n"}, + }, + { + name: "state directory group ownership emits chgrp", + artifacts: dalec.Artifacts{ + Directories: &dalec.CreateArtifactDirectories{ + State: map[string]dalec.ArtifactDirConfig{"app": {Group: "svc"}}, + }, + }, + scripts: []string{"chgrp -R svc /%{_sharedstatedir}/app\n"}, + }, + { + name: "declared symlink ownership emits chown and chgrp", + artifacts: dalec.Artifacts{ + Users: []dalec.AddUserConfig{{Name: "svc"}}, + Groups: []dalec.AddGroupConfig{{Name: "svc"}}, + Links: []dalec.ArtifactSymlinkConfig{{ + Source: "/usr/bin/app", + Dest: "/usr/bin/app-link", + User: "svc", + Group: "svc", + }}, + }, + scripts: []string{ + "chown -h svc /usr/bin/app-link\n", + "chgrp -h svc /usr/bin/app-link\n", + }, + }, + { + name: "empty file and directory ownership emits no command", + artifacts: dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{"app": {}}, + Libexec: map[string]dalec.ArtifactConfig{"helper": {}}, + Libs: map[string]dalec.ArtifactConfig{"libapp.so": {}}, + ConfigFiles: map[string]dalec.ArtifactConfig{"app.conf": {}}, + DataDirs: map[string]dalec.ArtifactConfig{"data": {}}, + Directories: &dalec.CreateArtifactDirectories{ + Config: map[string]dalec.ArtifactDirConfig{"config": {}}, + State: map[string]dalec.ArtifactDirConfig{"state": {}}, + }, + }, + }, + { + name: "undeclared symlink ownership emits no command", + artifacts: dalec.Artifacts{ + Links: []dalec.ArtifactSymlinkConfig{{ + Source: "/usr/bin/app", + Dest: "/usr/bin/app-link", + User: "svc", + Group: "svc", + }}, + }, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + w := specWrapper{Spec: &dalec.Spec{Artifacts: tc.artifacts}} + + ownershipScript := artifactOwnershipScript(tc.artifacts) + + directoryOwnershipScript(tc.artifacts) + + symlinkOwnershipScript(tc.artifacts) + wantRequires := "" + if ownershipScript != "" { + wantRequires = "Requires(post): coreutils\n" + } + assert.Equal(t, getOwnershipPostRequires(tc.artifacts), wantRequires) + assert.Equal(t, strings.Contains(w.Requires().String(), "Requires(post): coreutils\n"), wantRequires != "") + + gotPost := w.Post().String() + for _, script := range tc.scripts { + assert.Assert(t, cmp.Contains(ownershipScript, script)) + assert.Assert(t, cmp.Contains(gotPost, script)) + } + if len(tc.scripts) == 0 { + assert.Equal(t, ownershipScript, "") + } + }) + } + }) } func TestTemplate_DisableStrip(t *testing.T) { - spec := &dalec.Spec{ - Artifacts: dalec.Artifacts{ - DisableStrip: true, - }, - } + t.Run("the root package can disable stripping", func(t *testing.T) { + w := &specWrapper{Spec: &dalec.Spec{ + Artifacts: dalec.Artifacts{DisableStrip: true}, + }} - w := &specWrapper{Spec: spec} - want := `%global __strip /bin/true` - got := w.DisableStrip() - assert.Equal(t, got, want) + assert.Equal(t, w.DisableStrip(), `%global __strip /bin/true`) + }) - spec.Artifacts.DisableStrip = false - want = "" - got = w.DisableStrip() - assert.Equal(t, got, want) + t.Run("stripping remains enabled when the root package does not disable it", func(t *testing.T) { + w := &specWrapper{Spec: &dalec.Spec{}} + + assert.Equal(t, w.DisableStrip(), "") + }) } func TestTemplate_Provides(t *testing.T) { @@ -1228,3 +1375,668 @@ func TestTemplate_ProvidesUsersAndGroups(t *testing.T) { assert.Assert(t, cmp.Contains(got, "Provides: group(svc)\n")) }) } + +func TestTemplate_PackagesOwnOnlyDeclaredManpages(t *testing.T) { + t.Parallel() + + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Artifacts: dalec.Artifacts{ + Manpages: map[string]dalec.ArtifactConfig{ + "docs/foo.1": {SubPath: "man1", Name: "primary.1"}, + }, + }, + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "docs": { + Description: "Additional manual pages", + Artifacts: &dalec.Artifacts{ + Manpages: map[string]dalec.ArtifactConfig{ + "docs/foo-admin.8": {SubPath: "man8"}, + }, + }, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + primary := w.Files().String() + supplemental, err := w.SubPackages() + assert.NilError(t, err) + + assert.Assert(t, cmp.Contains(primary, "%{_mandir}/man1/primary.1\n")) + assert.Assert(t, !strings.Contains(primary, "foo-admin.8")) + assert.Assert(t, cmp.Contains(supplemental.String(), "%{_mandir}/man8/foo-admin.8\n")) + assert.Assert(t, !strings.Contains(supplemental.String(), "primary.1")) + assert.Assert(t, !strings.Contains(primary+supplemental.String(), "%{_mandir}/*/*")) +} + +func TestTemplate_SupplementalPackagePreamble(t *testing.T) { + t.Run("each supplemental package controls automatic requirement generation independently", func(t *testing.T) { + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "disabled": { + Name: "custom-disabled", + Description: "Automatic requirements disabled", + Artifacts: &dalec.Artifacts{ + DisableAutoRequires: true, + }, + }, + "enabled": { + Name: "custom-enabled", + Description: "Automatic requirements enabled", + Artifacts: &dalec.Artifacts{}, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + out := got.String() + + assert.Assert(t, cmp.Contains(out, "%package -n custom-disabled\nSummary: Automatic requirements disabled\nAutoReq: no\n")) + assert.Assert(t, cmp.Contains(out, "%package -n custom-enabled\nSummary: Automatic requirements enabled\n\n")) + assert.Equal(t, strings.Count(out, "AutoReq: no"), 1) + }) + + t.Run("the root automatic-requirements setting is not inherited by a supplemental package without artifacts", func(t *testing.T) { + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Artifacts: dalec.Artifacts{ + DisableAutoRequires: true, + }, + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "meta": { + Name: "custom-meta", + Description: "Metadata only", + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + + assert.Equal(t, w.DisableAutoReq(), "AutoReq: no") + assert.Assert(t, cmp.Contains(got.String(), "%package -n custom-meta\nSummary: Metadata only\n\n")) + assert.Assert(t, !strings.Contains(got.String(), "AutoReq: no")) + }) + + t.Run("declared users and groups provide their RPM capabilities", func(t *testing.T) { + got := renderSupplementalPackage(t, dalec.Artifacts{ + Users: []dalec.AddUserConfig{{Name: "svcuser"}}, + Groups: []dalec.AddGroupConfig{{Name: "svcgroup"}}, + Links: []dalec.ArtifactSymlinkConfig{{ + Source: "/usr/bin/foo", + Dest: "/usr/bin/foo-link", + User: "svcuser", + Group: "svcgroup", + }}, + }) + + assert.Assert(t, cmp.Contains(got, "Provides: user(svcuser)\n")) + assert.Assert(t, cmp.Contains(got, "Provides: group(svcgroup)\n")) + assert.Assert(t, cmp.Contains(got, "%attr(-, svcuser, svcgroup) /usr/bin/foo-link\n")) + }) + + t.Run("ownership changes require coreutils", func(t *testing.T) { + got := renderSupplementalPackage(t, dalec.Artifacts{ + ConfigFiles: map[string]dalec.ArtifactConfig{ + "foo.conf": {User: "svcuser"}, + }, + }) + + assert.Assert(t, cmp.Contains(got, "Requires(post): coreutils\n")) + assert.Assert(t, cmp.Contains(got, "chown -R svcuser /%{_sysconfdir}/foo.conf\n")) + }) + + t.Run("artifacts without ownership do not require coreutils", func(t *testing.T) { + got := renderSupplementalPackage(t, dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{"foo": {}}, + }) + + assert.Assert(t, !strings.Contains(got, "Requires(post): coreutils")) + }) + + t.Run("renamed systemd units use their installed name in every scriptlet", func(t *testing.T) { + got := renderSupplementalPackage(t, dalec.Artifacts{ + Systemd: &dalec.SystemdConfiguration{ + Units: map[string]dalec.SystemdUnitConfig{ + "src/original.service": { + Name: "installed.service", + Enable: true, + }, + }, + }, + }) + + assert.Assert(t, cmp.Contains(got, "systemctl enable installed.service || :")) + assert.Assert(t, cmp.Contains(got, "%systemd_preun installed.service\n")) + assert.Assert(t, cmp.Contains(got, "%systemd_postun installed.service\n")) + assert.Assert(t, !strings.Contains(got, "%systemd_preun original.service")) + }) +} + +func TestTemplate_SubPackages(t *testing.T) { + t.Run("no supplemental packages", func(t *testing.T) { + t.Parallel() + w := &specWrapper{Spec: &dalec.Spec{Name: "foo"}} + got, err := w.SubPackages() + assert.NilError(t, err) + assert.Equal(t, got.String(), "") + }) + + t.Run("single subpackage with default name", func(t *testing.T) { + t.Parallel() + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "debug": { + Description: "Debug symbols for foo", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo-debug": {}, + }, + }, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + s := got.String() + + // Should have %package with -n foo-debug (default naming: parent-key) + assert.Assert(t, cmp.Contains(s, "%package -n foo-debug")) + assert.Assert(t, cmp.Contains(s, "Summary: Debug symbols for foo")) + assert.Assert(t, cmp.Contains(s, "%description -n foo-debug")) + assert.Assert(t, cmp.Contains(s, "Debug symbols for foo")) + assert.Assert(t, cmp.Contains(s, "%files -n foo-debug")) + assert.Assert(t, cmp.Contains(s, "%{_bindir}/foo-debug")) + }) + + t.Run("subpackage with custom name", func(t *testing.T) { + t.Parallel() + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "compat": { + Name: "foo-compat-v2", + Description: "Backward compatibility shim", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo-v2": {}, + }, + }, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + s := got.String() + + // Should use custom name + assert.Assert(t, cmp.Contains(s, "%package -n foo-compat-v2")) + assert.Assert(t, cmp.Contains(s, "Summary: Backward compatibility shim")) + assert.Assert(t, cmp.Contains(s, "%description -n foo-compat-v2")) + assert.Assert(t, cmp.Contains(s, "%files -n foo-compat-v2")) + assert.Assert(t, cmp.Contains(s, "%{_bindir}/foo-v2")) + }) + + t.Run("subpackage with dependencies", func(t *testing.T) { + t.Parallel() + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "devel": { + Description: "Development files for foo", + Dependencies: &dalec.SubPackageDependencies{ + Runtime: dalec.PackageDependencyList{ + "foo": dalec.PackageConstraints{ + Version: []string{"= %{version}-%{release}"}, + }, + "libfoo-headers": {}, + }, + Recommends: dalec.PackageDependencyList{ + "foo-docs": {}, + }, + }, + Provides: dalec.PackageDependencyList{ + "foo-dev": {}, + }, + Conflicts: dalec.PackageDependencyList{ + "foo-devel-old": { + Version: []string{"< 1.0"}, + }, + }, + Replaces: dalec.PackageDependencyList{ + "foo-devel-legacy": {}, + }, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + s := got.String() + + // Dependencies + assert.Assert(t, cmp.Contains(s, "Requires: foo == %{version}-%{release}")) + assert.Assert(t, cmp.Contains(s, "Requires: libfoo-headers")) + assert.Assert(t, cmp.Contains(s, "Recommends: foo-docs")) + assert.Assert(t, cmp.Contains(s, "Provides: foo-dev")) + assert.Assert(t, cmp.Contains(s, "Conflicts: foo-devel-old < 1.0")) + assert.Assert(t, cmp.Contains(s, "Obsoletes: foo-devel-legacy")) + }) + + t.Run("multiple subpackages sorted", func(t *testing.T) { + t.Parallel() + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "debug": { + Description: "Debug package", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo-debug": {}, + }, + }, + }, + "contrib": { + Description: "Contrib package", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo-contrib": {}, + }, + }, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + s := got.String() + + // Both subpackages should be present + assert.Assert(t, cmp.Contains(s, "%package -n foo-contrib")) + assert.Assert(t, cmp.Contains(s, "%package -n foo-debug")) + + // contrib should come before debug (sorted by key) + contribIdx := strings.Index(s, "%package -n foo-contrib") + debugIdx := strings.Index(s, "%package -n foo-debug") + assert.Assert(t, contribIdx < debugIdx, "contrib should appear before debug (sorted)") + }) + + t.Run("subpackage with nil artifacts", func(t *testing.T) { + t.Parallel() + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "meta": { + Description: "Meta package with no artifacts", + Dependencies: &dalec.SubPackageDependencies{ + Runtime: dalec.PackageDependencyList{ + "foo": {}, + "foo-debug": {}, + }, + }, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + s := got.String() + + assert.Assert(t, cmp.Contains(s, "%package -n foo-meta")) + assert.Assert(t, cmp.Contains(s, "%files -n foo-meta")) + // Should have requires but empty files section + assert.Assert(t, cmp.Contains(s, "Requires: foo\n")) + assert.Assert(t, cmp.Contains(s, "Requires: foo-debug\n")) + }) + + t.Run("subpackage with systemd units", func(t *testing.T) { + t.Parallel() + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "service": { + Description: "Service package", + Artifacts: &dalec.Artifacts{ + Systemd: &dalec.SystemdConfiguration{ + Units: map[string]dalec.SystemdUnitConfig{ + "foo-svc.service": { + Enable: true, + Start: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + s := got.String() + + // Should have service in %files + assert.Assert(t, cmp.Contains(s, "%{_unitdir}/foo-svc.service")) + // Should have %post for enabling + assert.Assert(t, cmp.Contains(s, "%post -n foo-service")) + assert.Assert(t, cmp.Contains(s, "systemctl enable foo-svc.service || :")) + assert.Assert(t, cmp.Contains(s, "systemctl start foo-svc.service || :")) + // Should have %preun for disabling + assert.Assert(t, cmp.Contains(s, "%preun -n foo-service")) + assert.Assert(t, cmp.Contains(s, "%systemd_preun foo-svc.service")) + // Should have %postun + assert.Assert(t, cmp.Contains(s, "%postun -n foo-service")) + assert.Assert(t, cmp.Contains(s, "%systemd_postun foo-svc.service")) + // Should declare install-time scriptlet requirements so the subpackage + // pulls in systemd independently of the primary package. + assert.Assert(t, cmp.Contains(s, "Requires(post): systemd\n")) + assert.Assert(t, cmp.Contains(s, "Requires(preun): systemd\n")) + assert.Assert(t, cmp.Contains(s, "Requires(postun): systemd\n")) + }) + + t.Run("subpackage with users requires adduser at install time", func(t *testing.T) { + t.Parallel() + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "service": { + Description: "Service package", + Artifacts: &dalec.Artifacts{ + Users: []dalec.AddUserConfig{{Name: "foouser"}}, + Groups: []dalec.AddGroupConfig{{Name: "foogroup"}}, + }, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + s := got.String() + + assert.Assert(t, cmp.Contains(s, "Requires(post): /usr/sbin/adduser, /usr/bin/getent\n")) + assert.Assert(t, cmp.Contains(s, "Requires(post): /usr/sbin/groupadd, /usr/bin/getent\n")) + }) + + t.Run("subpackage docs and licenses use resolved name", func(t *testing.T) { + t.Parallel() + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "devel": { + Description: "Development files", + Artifacts: &dalec.Artifacts{ + Docs: map[string]dalec.ArtifactConfig{ + "API.md": {}, + }, + Licenses: map[string]dalec.ArtifactConfig{ + "LICENSE": {}, + }, + }, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + s := got.String() + + // Docs and licenses should use the subpackage's resolved name + assert.Assert(t, cmp.Contains(s, "%doc %{_docdir}/foo-devel/API.md")) + assert.Assert(t, cmp.Contains(s, "%license %{_licensedir}/foo-devel/LICENSE")) + }) + + t.Run("wrong target returns no subpackages", func(t *testing.T) { + t.Parallel() + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "debug": { + Description: "Debug", + }, + }, + }, + }, + }, + Target: "jammy", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + assert.Equal(t, got.String(), "") + }) +} + +func TestTemplate_SubPackageInstall(t *testing.T) { + t.Run("install includes subpackage artifacts", func(t *testing.T) { + t.Parallel() + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Artifacts: dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo": {}, + }, + }, + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "debug": { + Description: "Debug symbols", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "foo-debug": {}, + }, + }, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got := w.Install().String() + + // Primary package artifact + assert.Assert(t, cmp.Contains(got, "cp -r foo %{buildroot}/%{_bindir}/foo")) + // Subpackage artifact + assert.Assert(t, cmp.Contains(got, "cp -r foo-debug %{buildroot}/%{_bindir}/foo-debug")) + }) + + t.Run("install subpackage docs use resolved name", func(t *testing.T) { + t.Parallel() + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "devel": { + Description: "Dev files", + Artifacts: &dalec.Artifacts{ + Docs: map[string]dalec.ArtifactConfig{ + "API.md": {}, + }, + Licenses: map[string]dalec.ArtifactConfig{ + "LICENSE": {}, + }, + }, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got := w.Install().String() + + // Docs and licenses should be installed under the subpackage's resolved name + assert.Assert(t, cmp.Contains(got, "%{buildroot}/%{_docdir}/foo-devel")) + assert.Assert(t, cmp.Contains(got, "%{buildroot}/%{_licensedir}/foo-devel")) + }) +} + +func TestTemplate_SubPackageFullSpec(t *testing.T) { + t.Parallel() + + spec := &dalec.Spec{ + Name: "myapp", + Version: "1.0.0", + Revision: "1", + Description: "My application", + License: "MIT", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "devel": { + Description: "Development headers for myapp", + Artifacts: &dalec.Artifacts{ + Headers: map[string]dalec.ArtifactConfig{ + "myapp.h": {}, + }, + }, + Dependencies: &dalec.SubPackageDependencies{ + Runtime: dalec.PackageDependencyList{ + "myapp": { + Version: []string{"= %{version}-%{release}"}, + }, + }, + }, + }, + }, + }, + }, + } + + w := &strings.Builder{} + err := WriteSpec(spec, "azlinux3", w) + assert.NilError(t, err) + + s := w.String() + + // Primary package should be present + assert.Assert(t, cmp.Contains(s, "Name: myapp")) + assert.Assert(t, cmp.Contains(s, "Summary: My application")) + + // Subpackage should be present + assert.Assert(t, cmp.Contains(s, "%package -n myapp-devel")) + assert.Assert(t, cmp.Contains(s, "Summary: Development headers for myapp")) + assert.Assert(t, cmp.Contains(s, "Requires: myapp == %{version}-%{release}")) + assert.Assert(t, cmp.Contains(s, "%description -n myapp-devel")) + assert.Assert(t, cmp.Contains(s, "Development headers for myapp")) + assert.Assert(t, cmp.Contains(s, "%files -n myapp-devel")) + assert.Assert(t, cmp.Contains(s, "%{_includedir}/myapp.h")) + + // Install should include subpackage headers + assert.Assert(t, cmp.Contains(s, "cp -r myapp.h %{buildroot}/%{_includedir}/myapp.h")) +} + +func renderSupplementalPackage(t *testing.T, artifacts dalec.Artifacts) string { + t.Helper() + + w := &specWrapper{ + Spec: &dalec.Spec{ + Name: "foo", + Targets: map[string]dalec.Target{ + "azlinux3": { + Packages: map[string]dalec.SubPackage{ + "supplemental": { + Description: "Supplemental package", + Artifacts: &artifacts, + }, + }, + }, + }, + }, + Target: "azlinux3", + } + + got, err := w.SubPackages() + assert.NilError(t, err) + return got.String() +} diff --git a/targets/linux/rpm/distro/container.go b/targets/linux/rpm/distro/container.go index a6f70fa79..6bf3a2ec8 100644 --- a/targets/linux/rpm/distro/container.go +++ b/targets/linux/rpm/distro/container.go @@ -47,7 +47,7 @@ func (cfg *Config) BuildContainer(ctx context.Context, client gwclient.Client, s baseMountPath := rpmMountDir + "-base" basePkgs := llb.Scratch().File(llb.Mkdir("/RPMS", 0o755), opts...) pkgs := []string{ - filepath.Join(rpmMountDir, "**/*.rpm"), + filepath.Join(rpmMountDir, "*/*.rpm"), } if !skipBase && len(cfg.BasePackages) > 0 { @@ -124,7 +124,11 @@ func (cfg *Config) HandleDepsOnly(ctx context.Context, client gwclient.Client) ( } pkg := cfg.BuildPkg(ctx, client, sOpt, depsSpec, targetKey, pg) - ctr := cfg.BuildContainer(ctx, client, sOpt, spec, targetKey, pkg, pg) + containerSpec := *spec + containerSpec.Name = depsSpec.Name + containerSpec.Version = depsSpec.Version + containerSpec.Revision = depsSpec.Revision + ctr := cfg.BuildContainer(ctx, client, sOpt, &containerSpec, targetKey, pkg, pg) def, err := ctr.Marshal(ctx, pc) if err != nil { diff --git a/targets/linux/rpm/distro/container_test.go b/targets/linux/rpm/distro/container_test.go new file mode 100644 index 000000000..9657f3c89 --- /dev/null +++ b/targets/linux/rpm/distro/container_test.go @@ -0,0 +1,91 @@ +package distro + +import ( + "path/filepath" + "testing" + + "github.com/moby/buildkit/client/llb" + gwclient "github.com/moby/buildkit/frontend/gateway/client" + "github.com/project-dalec/dalec" + "gotest.tools/v3/assert" +) + +func TestBuildContainerInstallsAllGeneratedBinaryRPMs(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + archDir string + rpm string + }{ + { + name: "the primary package is selected", + archDir: "x86_64", + rpm: "myapp-1.0.0-1.azl3.x86_64.rpm", + }, + { + name: "a default supplemental package is selected", + archDir: "x86_64", + rpm: "myapp-contrib-1.0.0-1.azl3.x86_64.rpm", + }, + { + name: "a custom-named supplemental package is selected", + archDir: "x86_64", + rpm: "my-custom-pkg-1.0.0-1.azl3.x86_64.rpm", + }, + { + name: "a documentation package is selected", + archDir: "noarch", + rpm: "myapp-docs-1.0.0-1.azl3.noarch.rpm", + }, + { + name: "a debug package is selected", + archDir: "x86_64", + rpm: "myapp-debuginfo-1.0.0-1.azl3.x86_64.rpm", + }, + { + name: "a compatibility package is selected", + archDir: "aarch64", + rpm: "myapp-compat-1.0.0-1.azl3.aarch64.rpm", + }, + } + + var installed []string + cfg := &Config{ + ContextRef: "worker", + InstallFunc: func(_ *dnfInstallConfig, _ string, pkgs []string) llb.RunOption { + installed = append(installed, pkgs...) + return llb.Args([]string{"true"}) + }, + } + sOpt := dalec.SourceOpts{ + GetContext: func(string, ...llb.LocalOption) (*llb.State, error) { + st := llb.Scratch() + return &st, nil + }, + } + + cfg.BuildContainer(t.Context(), &containerTestClient{}, sOpt, &dalec.Spec{}, "target", llb.Scratch()) + + assert.Assert(t, len(installed) == 1) + assert.Equal(t, installed[0], filepath.Join("/tmp/rpms", "*/*.rpm")) + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + matches, err := filepath.Match(installed[0], filepath.Join("/tmp/rpms", tc.archDir, tc.rpm)) + assert.NilError(t, err) + assert.Assert(t, matches, "pattern %q should select RPM %q", installed[0], tc.rpm) + }) + } +} + +type containerTestClient struct { + gwclient.Client // Unexpected gateway calls fail through the nil embedded client. +} + +func (*containerTestClient) BuildOpts() gwclient.BuildOpts { + return gwclient.BuildOpts{} +} diff --git a/test/linux_target_test.go b/test/linux_target_test.go index f84615f24..7ee0b5cdb 100644 --- a/test/linux_target_test.go +++ b/test/linux_target_test.go @@ -77,6 +77,8 @@ type targetConfig struct { // Sysext is the target for creating a systemd system extension. Sysext string + Subpackages *subpackageTestConfig + // FormatDepEqual, when set, alters the provided dependency version to match // what is necessary for the target distro to set a dependency for an equals // operator. @@ -3864,6 +3866,14 @@ func Value() string { ctx := startTestSpan(baseCtx, t) testArtifactCapabilities(ctx, t, testConfig) }) + + if testConfig.Target.Subpackages != nil { + t.Run("subpackages", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(baseCtx, t) + testSubpackages(ctx, t, testConfig.Target, testConfig.Target.Subpackages) + }) + } } func testNodeNpmGenerator(ctx context.Context, t *testing.T, targetCfg targetConfig, opts ...srOpt) { diff --git a/test/subpackage_test.go b/test/subpackage_test.go new file mode 100644 index 000000000..940a40f83 --- /dev/null +++ b/test/subpackage_test.go @@ -0,0 +1,326 @@ +package test + +import ( + "context" + "io/fs" + "path" + "strings" + "testing" + + "github.com/cavaliergopher/rpm" + gwclient "github.com/moby/buildkit/frontend/gateway/client" + "github.com/project-dalec/dalec" + "github.com/project-dalec/dalec/frontend/pkg/bkfs" + "gotest.tools/v3/assert" +) + +type subpackageTestConfig struct { + ReadPackageMetadata func(*testing.T, fs.FS, string) (subpackagePackageMetadata, bool) + ArtifactNameMatches func(string, string) bool +} + +type subpackagePackageMetadata struct { + Name string + Path string + RuntimeDependencies map[string]struct{} +} + +func rpmSubpackageTests() *subpackageTestConfig { + return &subpackageTestConfig{ + ReadPackageMetadata: readRPMPackageMetadata, + ArtifactNameMatches: rpmArtifactNameMatches, + } +} + +func testSubpackages(ctx context.Context, t *testing.T, targetCfg targetConfig, cfg *subpackageTestConfig) { + t.Run("a default container installs primary and supplemental package binaries", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + + spec := subpackageSpec(targetCfg.Key) + spec.Tests = []*dalec.TestSpec{ + { + Name: "primary and supplemental binaries execute", + Files: map[string]dalec.FileCheckOutput{ + "/usr/bin/primary-bin": {}, + "/usr/bin/contrib-bin": {}, + }, + Steps: []dalec.TestStep{ + { + Command: "/usr/bin/primary-bin", + Stdout: dalec.CheckOutput{Equals: "primary\n"}, + Stderr: dalec.CheckOutput{Empty: true}, + }, + { + Command: "/usr/bin/contrib-bin", + Stdout: dalec.CheckOutput{Equals: "contrib\n"}, + Stderr: dalec.CheckOutput{Empty: true}, + }, + }, + }, + } + + testEnv.RunTest(ctx, t, func(ctx context.Context, client gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget(targetCfg.Container)) + solveT(ctx, t, client, sr) + }) + }) + + t.Run("a default container installs a custom-named supplemental package", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + + spec := subpackageSpec(targetCfg.Key) + setSubpackageName(spec, targetCfg.Key, "my-custom-pkg") + spec.Tests = []*dalec.TestSpec{ + { + Name: "custom-named supplemental binary executes", + Files: map[string]dalec.FileCheckOutput{ + "/usr/bin/primary-bin": {}, + "/usr/bin/contrib-bin": {}, + }, + Steps: []dalec.TestStep{ + { + Command: "/usr/bin/primary-bin", + Stdout: dalec.CheckOutput{Equals: "primary\n"}, + Stderr: dalec.CheckOutput{Empty: true}, + }, + { + Command: "/usr/bin/contrib-bin", + Stdout: dalec.CheckOutput{Equals: "contrib\n"}, + Stderr: dalec.CheckOutput{Empty: true}, + }, + }, + }, + } + + testEnv.RunTest(ctx, t, func(ctx context.Context, client gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget(targetCfg.Container)) + solveT(ctx, t, client, sr) + }) + }) + + t.Run("a supplemental runtime dependency is written to package metadata", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + + const dependency = "curl" + spec := subpackageSpec(targetCfg.Key) + pkg := spec.Targets[targetCfg.Key].Packages["contrib"] + pkg.Dependencies = &dalec.SubPackageDependencies{ + Runtime: map[string]dalec.PackageConstraints{ + dependency: {}, + }, + } + spec.Targets[targetCfg.Key].Packages["contrib"] = pkg + + testPackageOutput(ctx, t, targetCfg, spec, func(pkgFS fs.FS) { + packages := readSubpackagePackageMetadata(t, pkgFS, cfg) + + pkgName := spec.Name + "-contrib" + pkg, ok := packages[pkgName] + + assert.Assert(t, ok, "supplemental package %q was not found: %v", pkgName, packages) + _, ok = pkg.RuntimeDependencies[dependency] + assert.Assert(t, ok, "package %q does not require %q: %v", pkgName, dependency, pkg.RuntimeDependencies) + }) + }) + + t.Run("a package target emits primary and supplemental package artifacts", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + + spec := subpackageSpec(targetCfg.Key) + testPackageOutput(ctx, t, targetCfg, spec, func(pkgFS fs.FS) { + packages := readSubpackagePackageMetadata(t, pkgFS, cfg) + assertPackageArtifacts(t, packageArtifactAssertions{ + Config: cfg, + Packages: packages, + Expected: []string{spec.Name, spec.Name + "-contrib"}, + }) + }) + }) + + t.Run("a package target emits a custom supplemental name instead of the default name", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + + spec := subpackageSpec(targetCfg.Key) + setSubpackageName(spec, targetCfg.Key, "my-custom-pkg") + + testPackageOutput(ctx, t, targetCfg, spec, func(pkgFS fs.FS) { + packages := readSubpackagePackageMetadata(t, pkgFS, cfg) + assertPackageArtifacts(t, packageArtifactAssertions{ + Config: cfg, + Packages: packages, + Expected: []string{spec.Name, "my-custom-pkg"}, + Unexpected: []string{spec.Name + "-contrib"}, + }) + }) + }) +} + +func subpackageSpec(targetKey string) *dalec.Spec { + return &dalec.Spec{ + Name: "test-subpkgs", + Version: "0.0.1", + Revision: "1", + Description: "Test supplemental packages end-to-end", + License: "MIT", + Website: "https://github.com/project-dalec/dalec", + Packager: "test", + Sources: map[string]dalec.Source{ + "primary-bin": { + Inline: &dalec.SourceInline{ + File: &dalec.SourceInlineFile{ + Contents: "#!/usr/bin/env bash\necho primary", + Permissions: 0o755, + }, + }, + }, + "contrib-bin": { + Inline: &dalec.SourceInline{ + File: &dalec.SourceInlineFile{ + Contents: "#!/usr/bin/env bash\necho contrib", + Permissions: 0o755, + }, + }, + }, + }, + Build: dalec.ArtifactBuild{ + Steps: []dalec.BuildStep{ + {Command: "/bin/true"}, + }, + }, + Artifacts: dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "primary-bin": {}, + }, + }, + Targets: map[string]dalec.Target{ + targetKey: { + Packages: map[string]dalec.SubPackage{ + "contrib": { + Description: "Contributed extras for test-subpkgs", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "contrib-bin": {}, + }, + }, + }, + }, + }, + }, + } +} + +func setSubpackageName(spec *dalec.Spec, targetKey, name string) { + pkg := spec.Targets[targetKey].Packages["contrib"] + pkg.Name = name + spec.Targets[targetKey].Packages["contrib"] = pkg +} + +func testPackageOutput(ctx context.Context, t *testing.T, targetCfg targetConfig, spec *dalec.Spec, check func(fs.FS)) { + t.Helper() + + testEnv.RunTest(ctx, t, func(ctx context.Context, client gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget(targetCfg.Package)) + res := solveT(ctx, t, client, sr) + ref, err := res.SingleRef() + assert.NilError(t, err) + check(bkfs.FromRef(ctx, ref)) + }) +} + +func readSubpackagePackageMetadata(t *testing.T, pkgFS fs.FS, cfg *subpackageTestConfig) map[string]subpackagePackageMetadata { + t.Helper() + + packages := make(map[string]subpackagePackageMetadata) + err := fs.WalkDir(pkgFS, ".", func(packagePath string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + + metadata, ok := cfg.ReadPackageMetadata(t, pkgFS, packagePath) + if !ok { + return nil + } + metadata.Path = packagePath + if existing, exists := packages[metadata.Name]; exists { + t.Fatalf("package %q emitted more than once: %q and %q", metadata.Name, existing.Path, packagePath) + } + packages[metadata.Name] = metadata + return nil + }) + assert.NilError(t, err) + assert.Assert(t, len(packages) > 0, "package target emitted no recognized package artifacts") + return packages +} + +func readRPMPackageMetadata(t *testing.T, pkgFS fs.FS, packagePath string) (subpackagePackageMetadata, bool) { + t.Helper() + + if !strings.HasSuffix(packagePath, ".rpm") || strings.HasSuffix(packagePath, ".src.rpm") { + return subpackagePackageMetadata{}, false + } + + f, err := pkgFS.Open(packagePath) + assert.NilError(t, err) + defer f.Close() + + pkg, err := rpm.Read(f) + assert.NilError(t, err) + + dependencies := make(map[string]struct{}) + for _, requirement := range pkg.Requires() { + dependencies[requirement.Name()] = struct{}{} + } + + return subpackagePackageMetadata{ + Name: pkg.Name(), + RuntimeDependencies: dependencies, + }, true +} + +func rpmArtifactNameMatches(packagePath, packageName string) bool { + return strings.HasPrefix(path.Base(packagePath), packageName+"-") +} + +type packageArtifactAssertions struct { + Config *subpackageTestConfig + Packages map[string]subpackagePackageMetadata + Expected []string + Unexpected []string +} + +func assertPackageArtifacts(t *testing.T, assertions packageArtifactAssertions) { + t.Helper() + + for _, packageName := range assertions.Expected { + pkg, ok := assertions.Packages[packageName] + assert.Assert(t, ok, "package %q was not found: %v", packageName, assertions.Packages) + assert.Assert( + t, + assertions.Config.ArtifactNameMatches(pkg.Path, packageName), + "package %q has unexpected artifact name %q", + packageName, + pkg.Path, + ) + } + for _, packageName := range assertions.Unexpected { + pkg, ok := assertions.Packages[packageName] + assert.Assert(t, !ok, "package %q was unexpectedly emitted as %q", packageName, pkg.Path) + for _, emitted := range assertions.Packages { + assert.Assert( + t, + !assertions.Config.ArtifactNameMatches(emitted.Path, packageName), + "package artifact %q unexpectedly uses name %q", + emitted.Path, + packageName, + ) + } + } +} diff --git a/test/target_almalinux_test.go b/test/target_almalinux_test.go index ddedae76a..e39b36dc4 100644 --- a/test/target_almalinux_test.go +++ b/test/target_almalinux_test.go @@ -22,6 +22,7 @@ func TestAlmalinux9(t *testing.T) { return v }, ListExpectedSignFiles: azlinuxListSignFiles("el9"), + Subpackages: rpmSubpackageTests(), PackageOverrides: map[string]string{ "rust": "rust cargo", "bazel": noPackageAvailable, @@ -59,6 +60,7 @@ func TestAlmalinux8(t *testing.T) { ctx := startTestSpan(baseCtx, t) cfg := testLinuxConfig{ Target: targetConfig{ + Key: "almalinux8", Package: "almalinux8/rpm", Container: "almalinux8/container", DepsOnly: "almalinux8/container/depsonly", @@ -67,6 +69,7 @@ func TestAlmalinux8(t *testing.T) { return v }, ListExpectedSignFiles: azlinuxListSignFiles("el8"), + Subpackages: rpmSubpackageTests(), PackageOverrides: map[string]string{ "rust": "rust cargo", "bazel": noPackageAvailable, diff --git a/test/target_azlinux_test.go b/test/target_azlinux_test.go index 7e0d505df..0d521224a 100644 --- a/test/target_azlinux_test.go +++ b/test/target_azlinux_test.go @@ -50,6 +50,7 @@ func TestAzlinux3(t *testing.T) { Worker: "azlinux3/worker", Sysext: "azlinux3/testing/sysext", ListExpectedSignFiles: azlinuxListSignFiles("azl3"), + Subpackages: rpmSubpackageTests(), }, LicenseDir: "/usr/share/licenses", SystemdDir: struct { @@ -96,6 +97,7 @@ func TestAzlinux4(t *testing.T) { Worker: "azlinux4/worker", Sysext: "azlinux4/testing/sysext", ListExpectedSignFiles: azlinuxListSignFiles("azl4"), + Subpackages: rpmSubpackageTests(), PackageOverrides: map[string]string{ // NOTE: bazel is not presently available in azl4 base repos. "bazel": noPackageAvailable, diff --git a/test/target_rockylinux_test.go b/test/target_rockylinux_test.go index 1f3e9368b..662db0cfc 100644 --- a/test/target_rockylinux_test.go +++ b/test/target_rockylinux_test.go @@ -22,6 +22,7 @@ func TestRockylinux9(t *testing.T) { return v }, ListExpectedSignFiles: azlinuxListSignFiles("el9"), + Subpackages: rpmSubpackageTests(), PackageOverrides: map[string]string{ "rust": "rust cargo", "bazel": noPackageAvailable, @@ -59,6 +60,7 @@ func TestRockylinux8(t *testing.T) { ctx := startTestSpan(baseCtx, t) cfg := testLinuxConfig{ Target: targetConfig{ + Key: "rockylinux8", Package: "rockylinux8/rpm", Container: "rockylinux8/container", DepsOnly: "rockylinux8/container/depsonly", @@ -67,6 +69,7 @@ func TestRockylinux8(t *testing.T) { return v }, ListExpectedSignFiles: azlinuxListSignFiles("el8"), + Subpackages: rpmSubpackageTests(), PackageOverrides: map[string]string{ "rust": "rust cargo", "bazel": noPackageAvailable,