From f45eacd1b40eaa8cc6c0882d74704541a0a2deca Mon Sep 17 00:00:00 2001 From: Brian Goff Date: Tue, 24 Feb 2026 15:18:13 -0800 Subject: [PATCH 1/5] docs: document subpackages in targets guide Signed-off-by: Brian Goff --- website/content/targets.md | 66 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/website/content/targets.md b/website/content/targets.md index 19eb85769..fe55a0210 100644 --- a/website/content/targets.md +++ b/website/content/targets.md @@ -188,6 +188,72 @@ targets: qux: ``` +### Subpackages + +Subpackages (also called supplemental packages) let a single build produce more +than one installable package. This is useful when you want to split build +outputs into separate packages — for example a main runtime package plus a +`-devel` package for headers, or a separate package for systemd units — without +running the build more than once. + +Subpackages are defined per target under the `packages` field. Each entry is +keyed by a short name; by default the produced package is named +`-`. All subpackages share the primary package's build +steps, sources, version, revision, license, vendor, and website — these cannot +be overridden. Each subpackage selects its own [artifacts](artifacts.md) and may +declare its own metadata. + +```yaml +name: foo + +targets: + azlinux3: + artifacts: + binaries: + bin/foo: + packages: + # Produces a package named "foo-devel". + devel: + description: Development files for foo + artifacts: + headers: + include/foo.h: + dependencies: + runtime: + foo: + # "name" overrides the default "foo-" naming. + service: + name: foo-systemd + description: systemd units for foo + artifacts: + systemd: + units: + foo.service: + enable: true +``` + +Each subpackage supports the following fields: + +- `name`: Override the default `-` package name. +- `description` (required): Package description/summary. Both RPM and Debian + require this for every subpackage. +- `artifacts`: The build outputs that go into this package. Subpackage artifacts + are self-contained — nothing is inherited from the primary package, so an + artifact placed in a subpackage is not also shipped by the primary package. +- `dependencies`: Only `runtime` and `recommends` are allowed. Build + dependencies are shared with the primary package and cannot be set here. +- `conflicts`, `provides`, `replaces`: Per-package metadata, equivalent to the + [primary package fields](#target-defined-package-metadata). + +Install-time scriptlet requirements are derived automatically from the +subpackage's own artifacts. For example, a subpackage that ships enabled systemd +units gets the appropriate `systemd` install-time requirements, and one that +defines users or groups pulls in the tools needed to create them — independently +of the primary package. + +Subpackage names must be unique within a target and must not collide with the +primary package name. + ## Special considerations ### Windows From d61fb674fa773c73952bb2151642dbdc798a0325 Mon Sep 17 00:00:00 2001 From: Brian Goff Date: Thu, 11 Jun 2026 17:33:30 -0700 Subject: [PATCH 2/5] windows: add subpackage support for windowscross/zip The windowscross/zip target previously emitted only the primary package and silently dropped any supplemental packages defined under a target's `packages` block. Now a single build produces one zip per package (primary + each supplemental), named `_-_.zip`. Build artifacts are staged into per-package directories so the zip and container targets can address each package individually. Artifact `name:` overrides are now honored for windows binaries, and chmod targets the staged file rather than the source path (fixing a latent bug for keys containing a slash). Because a zip with no contents is meaningless, the zip target now errors if any package (primary or supplemental) resolves to no artifacts. This mirrors the "empty artifacts on Windows" requirement and differs from rpm and deb, where an artifact-less metapackage is valid. The windowscross container target installs the binaries from every package, matching the linux container target which installs all packages produced for the target. Signed-off-by: Brian Goff --- targets/windows/handle_container.go | 35 +++++++- targets/windows/handle_zip.go | 117 +++++++++++++++++++++---- targets/windows/handle_zip_test.go | 127 ++++++++++++++++++++++++++++ targets/windows/validation.go | 14 +++ test/windows_test.go | 80 ++++++++++++++++++ website/content/targets.md | 11 +++ 6 files changed, 366 insertions(+), 18 deletions(-) create mode 100644 targets/windows/handle_zip_test.go diff --git a/targets/windows/handle_container.go b/targets/windows/handle_container.go index e0db28bd5..b69262678 100644 --- a/targets/windows/handle_container.go +++ b/targets/windows/handle_container.go @@ -144,9 +144,38 @@ func handleContainer(ctx context.Context, client gwclient.Client) (*gwclient.Res platform = &defaultPlatform } baseImage := bi.ToState(sOpt, pg, llb.Platform(*platform)) - out := baseImage. - File(llb.Copy(bin, "/", windowsSystemDir), pg). - With(copySymlinks(spec.GetImagePost(targetKey), pg)) + + // Install every package's binaries (primary + supplemental) into the + // image, matching the linux container target which installs all packages + // produced for the target. + // + // The primary package's binaries are at the root of bin, alongside the + // supplemental package subdirs. Always copy the root contents (excluding + // those subdirs) so the build step is realized in the graph even when the + // primary package has no binaries. Otherwise buildkit would prune the + // build, and specs that rely on a failing build step would wrongly succeed. + pkgs := windowsPackages(spec, targetKey) + + var subPackageDirs []string + for _, pkg := range pkgs { + if !pkg.Primary { + subPackageDirs = append(subPackageDirs, pkg.Name) + } + } + + out := baseImage.File( + llb.Copy(bin, "/", windowsSystemDir, dalec.WithDirContentsOnly(), llb.WithExcludePatterns(subPackageDirs)), + pg, + ) + + // Flatten each supplemental package's binaries into the system directory. + for _, pkg := range pkgs { + if pkg.Primary || len(pkg.Binaries) == 0 { + continue + } + out = out.File(llb.Copy(bin, "/"+pkg.Name+"/", windowsSystemDir, dalec.WithDirContentsOnly()), pg) + } + out = out.With(copySymlinks(spec.GetImagePost(targetKey), pg)) def, err := out.Marshal(ctx) if err != nil { diff --git a/targets/windows/handle_zip.go b/targets/windows/handle_zip.go index a5a591029..d393bb04f 100644 --- a/targets/windows/handle_zip.go +++ b/targets/windows/handle_zip.go @@ -31,12 +31,16 @@ func handleZip(ctx context.Context, client gwclient.Client) (*gwclient.Result, e return nil, nil, err } + if err := validateZipArtifacts(spec, targetKey); err != nil { + return nil, nil, err + } + pg := dalec.ProgressGroup("Build windows container: " + spec.Name) worker := distroConfig.Worker(sOpt, pg) bin := buildBinaries(ctx, spec, worker, client, sOpt, targetKey, pg) - st := getZipLLB(worker, platform, spec, bin, pg) + st := getZipLLB(worker, platform, spec, targetKey, bin, pg) def, err := st.Marshal(ctx) if err != nil { @@ -177,8 +181,7 @@ func buildBinaries(ctx context.Context, spec *dalec.Spec, worker llb.State, clie patched := dalec.PatchSources(worker, spec, sources, opts...) buildScript := createBuildScript(spec, opts...) - artifacts := spec.GetArtifacts(targetKey) - script := generateInvocationScript(artifacts.Binaries) + script := generateInvocationScript(spec, targetKey) builder := worker.With(dalec.SetBuildNetworkMode(spec)) st := builder.Run( @@ -208,32 +211,116 @@ func buildBinaries(ctx context.Context, spec *dalec.Spec, worker llb.State, clie return frontend.MaybeSign(ctx, client, st, spec, targetKey, sOpt) } -func getZipLLB(worker llb.State, platform *ocispecs.Platform, spec *dalec.Spec, artifacts llb.State, opts ...llb.ConstraintsOpt) llb.State { - fileName := fmt.Sprintf("%s_%s-%s_%s.zip", spec.Name, spec.Version, spec.Revision, platform.Architecture) - outName := filepath.Join(outputDir, fileName) +func getZipLLB(worker llb.State, platform *ocispecs.Platform, spec *dalec.Spec, targetKey string, artifacts llb.State, opts ...llb.ConstraintsOpt) llb.State { + const artifactsDir = "/tmp/artifacts" + + // Each package is zipped into a separate + // "_-_.zip" file. The primary package's + // artifacts live at the root of the artifacts dir; supplemental packages + // live under their own subdirectory. + script := &strings.Builder{} + fmt.Fprintln(script, "set -ex") + for _, pkg := range windowsPackages(spec, targetKey) { + fileName := fmt.Sprintf("%s_%s-%s_%s.zip", pkg.Name, spec.Version, spec.Revision, platform.Architecture) + outName := filepath.Join(outputDir, fileName) + if pkg.Primary { + // Zip only the top-level files so the supplemental package subdirs + // are not pulled into the primary archive. This also handles the + // package signer, which replaces the artifacts with a flat set of + // files at the root. + fmt.Fprintf(script, "(cd %q && find . -maxdepth 1 -type f -exec zip %q {} +)\n", artifactsDir, outName) + continue + } + srcDir := path.Join(artifactsDir, pkg.Name) + fmt.Fprintf(script, "(cd %q && zip %q *)\n", srcDir, outName) + } + zipped := worker.Run( - dalec.ShArgs("zip "+outName+" *"), - llb.Dir("/tmp/artifacts"), - llb.AddMount("/tmp/artifacts", artifacts), + dalec.ShArgs(script.String()), + llb.AddMount(artifactsDir, artifacts), dalec.WithConstraints(opts...), ).AddMount(outputDir, llb.Scratch()) return zipped } -func generateInvocationScript(binaries map[string]dalec.ArtifactConfig) *strings.Builder { +// windowsPackage pairs a resolved package name with the binary artifacts that +// go into it for a windows target. +type windowsPackage struct { + // Name is the resolved package name (primary package name or the + // supplemental package's resolved name). + Name string + // Binaries are the binary artifacts that belong to this package. + Binaries map[string]dalec.ArtifactConfig + // Primary is true for the spec's primary package. The primary package's + // artifacts are staged at the root of the staging dir (matching the + // original single-package layout); supplemental packages are staged under + // their own subdirectory. + Primary bool +} + +// windowsPackages returns the ordered set of packages produced for a windows +// target: the primary package first, followed by supplemental packages sorted +// by their map key. Windows packages only ship binaries. +func windowsPackages(spec *dalec.Spec, targetKey string) []windowsPackage { + pkgs := []windowsPackage{{ + Name: spec.Name, + Binaries: spec.GetArtifacts(targetKey).Binaries, + Primary: true, + }} + + sub := spec.GetSubPackages(targetKey) + for _, key := range dalec.SortMapKeys(sub) { + p := sub[key] + var binaries map[string]dalec.ArtifactConfig + if p.Artifacts != nil { + binaries = p.Artifacts.Binaries + } + pkgs = append(pkgs, windowsPackage{ + Name: p.ResolvedName(spec.Name, key), + Binaries: binaries, + }) + } + + return pkgs +} + +func generateInvocationScript(spec *dalec.Spec, targetKey string) *strings.Builder { script := &strings.Builder{} fmt.Fprintln(script, "#!/usr/bin/env sh") fmt.Fprintln(script, "set -ex") fmt.Fprintf(script, "/tmp/scripts/%s\n", buildScriptName) - sorted := dalec.SortMapKeys(binaries) + + // Stage each package's binaries so the zip and container targets can address + // them. The primary package is staged at the root of outputDir (the original + // single-package layout, which the package signer also flattens to); each + // supplemental package is staged under its own "outputDir/" subdir. + // Files are copied (not moved) so a build output shared by multiple packages + // stays available. + for _, pkg := range windowsPackages(spec, targetKey) { + destDir := outputDir + if !pkg.Primary { + destDir = path.Join(outputDir, pkg.Name) + } + writePackageArtifacts(script, destDir, pkg) + } + + return script +} + +// writePackageArtifacts writes the commands that stage a single package's +// binaries under destDir. +func writePackageArtifacts(script *strings.Builder, destDir string, pkg windowsPackage) { + fmt.Fprintf(script, "mkdir -p '%s'\n", destDir) + + sorted := dalec.SortMapKeys(pkg.Binaries) for _, bin := range sorted { - config := binaries[bin] - fmt.Fprintf(script, "mv '%s' '%s'\n", bin, outputDir) + config := pkg.Binaries[bin] + dest := path.Join(destDir, config.ResolveName(bin)) + fmt.Fprintf(script, "cp -r '%s' '%s'\n", bin, dest) if config.Permissions.Perm() != 0 { - fmt.Fprintf(script, "chmod %o '%s/%s'\n", config.Permissions.Perm(), outputDir, bin) + fmt.Fprintf(script, "chmod %o '%s'\n", config.Permissions.Perm(), dest) } } - return script } func createBuildScript(spec *dalec.Spec, opts ...llb.ConstraintsOpt) llb.State { diff --git a/targets/windows/handle_zip_test.go b/targets/windows/handle_zip_test.go new file mode 100644 index 000000000..f04625c20 --- /dev/null +++ b/targets/windows/handle_zip_test.go @@ -0,0 +1,127 @@ +package windows + +import ( + "strings" + "testing" + + "github.com/project-dalec/dalec" + "gotest.tools/v3/assert" +) + +const testTargetKey = "windowscross" + +func subpackageSpec() *dalec.Spec { + return &dalec.Spec{ + Name: "foo", + Version: "1.0.0", + Artifacts: dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "bin/foo.exe": {}, + }, + }, + Targets: map[string]dalec.Target{ + testTargetKey: { + Packages: map[string]dalec.SubPackage{ + "contrib": { + Description: "Contrib extras for foo", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "bin/foo-contrib.exe": {}, + }, + }, + }, + "tools": { + Name: "foo-utilities", + Description: "Foo utilities", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "bin/util.exe": {Name: "foo-util.exe"}, + }, + }, + }, + }, + }, + }, + } +} + +func TestWindowsPackages(t *testing.T) { + spec := subpackageSpec() + pkgs := windowsPackages(spec, testTargetKey) + + // Primary is always first; supplemental packages follow sorted by map key + // ("contrib" before "tools"). + assert.Equal(t, len(pkgs), 3) + assert.Equal(t, pkgs[0].Name, "foo") + assert.Equal(t, pkgs[1].Name, "foo-contrib") + // "tools" has a name override. + assert.Equal(t, pkgs[2].Name, "foo-utilities") + + assert.Equal(t, len(pkgs[0].Binaries), 1) + assert.Equal(t, len(pkgs[2].Binaries), 1) +} + +func TestGenerateInvocationScript(t *testing.T) { + spec := subpackageSpec() + script := generateInvocationScript(spec, testTargetKey).String() + + // The primary package stages at the root of the output dir; supplemental + // packages each get their own subdirectory. + assert.Assert(t, strings.Contains(script, "mkdir -p '"+outputDir+"'")) + assert.Assert(t, strings.Contains(script, "mkdir -p '"+outputDir+"/foo-contrib'")) + assert.Assert(t, strings.Contains(script, "mkdir -p '"+outputDir+"/foo-utilities'")) + + // Files are copied (not moved) into the package's staging directory by their + // resolved name. The primary package's files land at the output dir root. + assert.Assert(t, strings.Contains(script, "cp -r 'bin/foo.exe' '"+outputDir+"/foo.exe'")) + assert.Assert(t, strings.Contains(script, "cp -r 'bin/foo-contrib.exe' '"+outputDir+"/foo-contrib/foo-contrib.exe'")) + // Name override on the artifact is honored. + assert.Assert(t, strings.Contains(script, "cp -r 'bin/util.exe' '"+outputDir+"/foo-utilities/foo-util.exe'")) + + // The build script is still invoked. + assert.Assert(t, strings.Contains(script, buildScriptName)) +} + +func TestGenerateInvocationScriptPermissions(t *testing.T) { + spec := &dalec.Spec{ + Name: "foo", + Artifacts: dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "sub/dir/foo.exe": {Permissions: 0o755}, + }, + }, + } + + script := generateInvocationScript(spec, testTargetKey).String() + + // chmod must target the staged file (by resolved name), not the original + // build path. This guards against the previous bug where chmod referenced + // the source path under the output dir. The primary package stages at the + // output dir root. + assert.Assert(t, strings.Contains(script, "cp -r 'sub/dir/foo.exe' '"+outputDir+"/foo.exe'")) + assert.Assert(t, strings.Contains(script, "chmod 755 '"+outputDir+"/foo.exe'")) +} + +func TestValidateZipArtifacts(t *testing.T) { + t.Run("all packages have artifacts", func(t *testing.T) { + spec := subpackageSpec() + assert.NilError(t, validateZipArtifacts(spec, testTargetKey)) + }) + + t.Run("empty subpackage", func(t *testing.T) { + spec := subpackageSpec() + tgt := spec.Targets[testTargetKey] + tgt.Packages["empty"] = dalec.SubPackage{Description: "no artifacts"} + spec.Targets[testTargetKey] = tgt + + err := validateZipArtifacts(spec, testTargetKey) + assert.ErrorContains(t, err, "foo-empty") + assert.ErrorContains(t, err, "no artifacts") + }) + + t.Run("empty primary", func(t *testing.T) { + spec := &dalec.Spec{Name: "foo"} + err := validateZipArtifacts(spec, testTargetKey) + assert.ErrorContains(t, err, "\"foo\"") + }) +} diff --git a/targets/windows/validation.go b/targets/windows/validation.go index 009bafd75..e0d6d8109 100644 --- a/targets/windows/validation.go +++ b/targets/windows/validation.go @@ -1,6 +1,7 @@ package windows import ( + goerrors "errors" "fmt" "github.com/project-dalec/dalec" @@ -14,3 +15,16 @@ func validateRuntimeDeps(s *dalec.Spec, targetKey string) error { return nil } + +// validateZipArtifacts ensures every package produced for a windowscross/zip +// target ships at least one artifact. A zip file must contain something, so an +// empty primary or supplemental package is rejected. +func validateZipArtifacts(spec *dalec.Spec, targetKey string) error { + var errs []error + for _, pkg := range windowsPackages(spec, targetKey) { + if len(pkg.Binaries) == 0 { + errs = append(errs, fmt.Errorf("package %q produces no artifacts; windowscross/zip requires at least one binary per package", pkg.Name)) + } + } + return goerrors.Join(errs...) +} diff --git a/test/windows_test.go b/test/windows_test.go index c392f182c..98c6fa599 100644 --- a/test/windows_test.go +++ b/test/windows_test.go @@ -131,6 +131,12 @@ deb [trusted=yes] copy:` + repoPath + `/ / ctx := startTestSpan(baseCtx, t) testWindowsZipFilename(ctx, t) }) + + t.Run("subpackages", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(baseCtx, t) + testWindowsSubpackages(ctx, t) + }) } // Windows is only supported on amd64 (ie there is no arm64 windows image currently) @@ -831,6 +837,80 @@ func testWindowsDefaultPlatform(ctx context.Context, t *testing.T) { }) } +// testWindowsSubpackages exercises the windowscross/zip subpackage behavior: +// a single build produces one zip per package (primary + supplemental), and a +// package that resolves to no artifacts is rejected because an empty zip is +// meaningless. +func testWindowsSubpackages(ctx context.Context, t *testing.T) { + const targetKey = "windowscross" + + newSpec := func() *dalec.Spec { + return fillMetadata("test-win-subpkgs", &dalec.Spec{ + Build: dalec.ArtifactBuild{ + Steps: []dalec.BuildStep{ + {Command: "echo primary > primary.exe; echo contrib > contrib.exe"}, + }, + }, + Artifacts: dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "primary.exe": {}, + }, + }, + Targets: map[string]dalec.Target{ + targetKey: { + Packages: map[string]dalec.SubPackage{ + "contrib": { + Description: "Contributed extras", + Artifacts: &dalec.Artifacts{ + Binaries: map[string]dalec.ArtifactConfig{ + "contrib.exe": {}, + }, + }, + }, + }, + }, + }, + }) + } + + t.Run("one zip per package", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + spec := newSpec() + + testEnv.RunTest(ctx, t, func(ctx context.Context, gwc gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget("windowscross/zip")) + res := solveT(ctx, t, gwc, sr) + ref, err := res.SingleRef() + assert.NilError(t, err) + + for _, name := range []string{spec.Name, spec.Name + "-contrib"} { + filename := fmt.Sprintf("/%s_%s-%s_%s.zip", name, spec.Version, spec.Revision, runtime.GOARCH) + stat, err := ref.StatFile(ctx, gwclient.StatRequest{Path: filename}) + assert.NilError(t, err, "expected zip %s", filename) + assert.Equal(t, stat.Path, filepath.Base(filename)) + } + }) + }) + + t.Run("empty package is rejected", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + spec := newSpec() + // Add a subpackage with no artifacts; this should fail the build since + // it would produce an empty zip. + tgt := spec.Targets[targetKey] + tgt.Packages["empty"] = dalec.SubPackage{Description: "no artifacts here"} + spec.Targets[targetKey] = tgt + + testEnv.RunTest(ctx, t, func(ctx context.Context, gwc gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget("windowscross/zip")) + _, err := gwc.Solve(ctx, sr) + assert.ErrorContains(t, err, "no artifacts") + }) + }) +} + func testWindowsZipFilename(ctx context.Context, t *testing.T) { spec := newSimpleSpec() spec.Build.Steps = []dalec.BuildStep{ diff --git a/website/content/targets.md b/website/content/targets.md index fe55a0210..f04996647 100644 --- a/website/content/targets.md +++ b/website/content/targets.md @@ -254,6 +254,17 @@ of the primary package. Subpackage names must be unique within a target and must not collide with the primary package name. +On RPM and deb targets, a single package build (`/rpm`, `/deb`) +produces all packages — the primary package plus every subpackage defined for +the target — just like `rpmbuild` and `dpkg-buildpackage` emit all subpackages +from one invocation. On the `windowscross/zip` target each package is emitted as +its own `_-_.zip`. Because a zip must contain +something, the `windowscross/zip` target errors if any package (primary or +subpackage) resolves to no artifacts; on RPM and deb an artifact-less package is +allowed (for example a metapackage that only declares dependencies). The +`windowscross` container target installs the binaries from every package into +the image. + ## Special considerations ### Windows From 4540f002342231fc136f14a81b0d875a18ecead9 Mon Sep 17 00:00:00 2001 From: Brian Goff Date: Thu, 16 Jul 2026 17:36:33 -0700 Subject: [PATCH 3/5] targets: preserve supplemental package outputs Sign each Windows package independently before aggregating outputs so zip and container targets retain correctly attributed artifacts, dotfiles, and collision isolation. Reject unsupported supplemental Windows runtime dependencies and verify Linux package dependency metadata directly. This adds one signer solve per produced Windows package while keeping the shared binary build execution. Signed-off-by: Brian Goff --- targets/windows/handle_container.go | 28 +---- targets/windows/handle_zip.go | 76 +++++++------ targets/windows/handle_zip_test.go | 91 +++++++++------ targets/windows/validation.go | 14 ++- test/fixtures/signer/main.go | 20 ++++ test/subpackage_test.go | 69 ++++++++++++ test/target_ubuntu_test.go | 1 + test/windows_test.go | 167 ++++++++++++++++++++++++++++ 8 files changed, 377 insertions(+), 89 deletions(-) diff --git a/targets/windows/handle_container.go b/targets/windows/handle_container.go index b69262678..02e788115 100644 --- a/targets/windows/handle_container.go +++ b/targets/windows/handle_container.go @@ -147,33 +147,15 @@ func handleContainer(ctx context.Context, client gwclient.Client) (*gwclient.Res // Install every package's binaries (primary + supplemental) into the // image, matching the linux container target which installs all packages - // produced for the target. - // - // The primary package's binaries are at the root of bin, alongside the - // supplemental package subdirs. Always copy the root contents (excluding - // those subdirs) so the build step is realized in the graph even when the - // primary package has no binaries. Otherwise buildkit would prune the - // build, and specs that rely on a failing build step would wrongly succeed. + // produced for the target. Always copy the primary package so the build + // step is realized even when it has no binaries. pkgs := windowsPackages(spec, targetKey) - - var subPackageDirs []string - for _, pkg := range pkgs { - if !pkg.Primary { - subPackageDirs = append(subPackageDirs, pkg.Name) - } - } - - out := baseImage.File( - llb.Copy(bin, "/", windowsSystemDir, dalec.WithDirContentsOnly(), llb.WithExcludePatterns(subPackageDirs)), - pg, - ) - - // Flatten each supplemental package's binaries into the system directory. + out := baseImage for _, pkg := range pkgs { - if pkg.Primary || len(pkg.Binaries) == 0 { + if !pkg.Primary && len(pkg.Binaries) == 0 { continue } - out = out.File(llb.Copy(bin, "/"+pkg.Name+"/", windowsSystemDir, dalec.WithDirContentsOnly()), pg) + out = out.File(llb.Copy(bin, pkg.internalDir(), windowsSystemDir, dalec.WithDirContentsOnly()), pg) } out = out.With(copySymlinks(spec.GetImagePost(targetKey), pg)) diff --git a/targets/windows/handle_zip.go b/targets/windows/handle_zip.go index d393bb04f..82e24fd31 100644 --- a/targets/windows/handle_zip.go +++ b/targets/windows/handle_zip.go @@ -171,6 +171,8 @@ func buildBinaries(ctx context.Context, spec *dalec.Spec, worker llb.State, clie return dalec.ErrorState(worker, err) } + packageOpts := append([]llb.ConstraintsOpt(nil), opts...) + // Apply source map constraints for build steps opts = append(opts, spec.Build.Steps.GetSourceLocation(worker)) @@ -182,9 +184,10 @@ func buildBinaries(ctx context.Context, spec *dalec.Spec, worker llb.State, clie patched := dalec.PatchSources(worker, spec, sources, opts...) buildScript := createBuildScript(spec, opts...) script := generateInvocationScript(spec, targetKey) + pkgs := windowsPackages(spec, targetKey) builder := worker.With(dalec.SetBuildNetworkMode(spec)) - st := builder.Run( + runOpts := []llb.RunOption{ dalec.ShArgs(script.String()), llb.Dir("/build"), withSourcesMounted("/build", patched, spec.Sources, opts...), @@ -206,33 +209,41 @@ func buildBinaries(ctx context.Context, spec *dalec.Spec, worker llb.State, clie ei.State = ei.State.With(llb.AddEnv(k, v)) } }), - ).AddMount(outputDir, llb.Scratch()) + } + for _, pkg := range pkgs { + runOpts = append(runOpts, llb.AddMount(pkg.buildOutputDir(), llb.Scratch())) + } - return frontend.MaybeSign(ctx, client, st, spec, targetKey, sOpt) + built := builder.Run(runOpts...) + packageStates := make([]llb.State, 0, len(pkgs)) + for _, pkg := range pkgs { + original := built.GetMount(pkg.buildOutputDir()) + signed := frontend.MaybeSign(ctx, client, original, spec, targetKey, sOpt, packageOpts...) + + // Signers may return only changed files. Preserve the original package + // and overlay the signer output so replacements win. + packageState := original.File(llb.Copy(signed, "/", "/"), packageOpts...) + packageStates = append(packageStates, llb.Scratch().File( + llb.Copy(packageState, "/", pkg.internalDir(), dalec.WithCreateDestPath()), + packageOpts..., + )) + } + + return dalec.MergeAtPath(llb.Scratch(), packageStates, "/", packageOpts...) } func getZipLLB(worker llb.State, platform *ocispecs.Platform, spec *dalec.Spec, targetKey string, artifacts llb.State, opts ...llb.ConstraintsOpt) llb.State { const artifactsDir = "/tmp/artifacts" // Each package is zipped into a separate - // "_-_.zip" file. The primary package's - // artifacts live at the root of the artifacts dir; supplemental packages - // live under their own subdirectory. + // "_-_.zip" file. script := &strings.Builder{} fmt.Fprintln(script, "set -ex") for _, pkg := range windowsPackages(spec, targetKey) { fileName := fmt.Sprintf("%s_%s-%s_%s.zip", pkg.Name, spec.Version, spec.Revision, platform.Architecture) outName := filepath.Join(outputDir, fileName) - if pkg.Primary { - // Zip only the top-level files so the supplemental package subdirs - // are not pulled into the primary archive. This also handles the - // package signer, which replaces the artifacts with a flat set of - // files at the root. - fmt.Fprintf(script, "(cd %q && find . -maxdepth 1 -type f -exec zip %q {} +)\n", artifactsDir, outName) - continue - } - srcDir := path.Join(artifactsDir, pkg.Name) - fmt.Fprintf(script, "(cd %q && zip %q *)\n", srcDir, outName) + srcDir := path.Join(artifactsDir, pkg.internalDir()) + fmt.Fprintf(script, "(cd %q && find . -maxdepth 1 -type f -exec zip %q {} +)\n", srcDir, outName) } zipped := worker.Run( @@ -246,23 +257,32 @@ func getZipLLB(worker llb.State, platform *ocispecs.Platform, spec *dalec.Spec, // windowsPackage pairs a resolved package name with the binary artifacts that // go into it for a windows target. type windowsPackage struct { + // Index is the package's deterministic position: primary first, then + // supplemental packages sorted by map key. + Index int // Name is the resolved package name (primary package name or the // supplemental package's resolved name). Name string // Binaries are the binary artifacts that belong to this package. Binaries map[string]dalec.ArtifactConfig - // Primary is true for the spec's primary package. The primary package's - // artifacts are staged at the root of the staging dir (matching the - // original single-package layout); supplemental packages are staged under - // their own subdirectory. + // Primary is true for the spec's primary package. Primary bool } +func (p windowsPackage) buildOutputDir() string { + return path.Join(outputDir, fmt.Sprintf("%d", p.Index)) +} + +func (p windowsPackage) internalDir() string { + return path.Join("/", fmt.Sprintf("%d", p.Index)) +} + // windowsPackages returns the ordered set of packages produced for a windows // target: the primary package first, followed by supplemental packages sorted // by their map key. Windows packages only ship binaries. func windowsPackages(spec *dalec.Spec, targetKey string) []windowsPackage { pkgs := []windowsPackage{{ + Index: 0, Name: spec.Name, Binaries: spec.GetArtifacts(targetKey).Binaries, Primary: true, @@ -276,6 +296,7 @@ func windowsPackages(spec *dalec.Spec, targetKey string) []windowsPackage { binaries = p.Artifacts.Binaries } pkgs = append(pkgs, windowsPackage{ + Index: len(pkgs), Name: p.ResolvedName(spec.Name, key), Binaries: binaries, }) @@ -290,18 +311,11 @@ func generateInvocationScript(spec *dalec.Spec, targetKey string) *strings.Build fmt.Fprintln(script, "set -ex") fmt.Fprintf(script, "/tmp/scripts/%s\n", buildScriptName) - // Stage each package's binaries so the zip and container targets can address - // them. The primary package is staged at the root of outputDir (the original - // single-package layout, which the package signer also flattens to); each - // supplemental package is staged under its own "outputDir/" subdir. - // Files are copied (not moved) so a build output shared by multiple packages - // stays available. + // Each output path is a separate scratch mount, so every package state has + // the flat root expected by signers. Files are copied (not moved) so a build + // output shared by multiple packages stays available. for _, pkg := range windowsPackages(spec, targetKey) { - destDir := outputDir - if !pkg.Primary { - destDir = path.Join(outputDir, pkg.Name) - } - writePackageArtifacts(script, destDir, pkg) + writePackageArtifacts(script, pkg.buildOutputDir(), pkg) } return script diff --git a/targets/windows/handle_zip_test.go b/targets/windows/handle_zip_test.go index f04625c20..04690bcf0 100644 --- a/targets/windows/handle_zip_test.go +++ b/targets/windows/handle_zip_test.go @@ -46,40 +46,35 @@ func subpackageSpec() *dalec.Spec { } func TestWindowsPackages(t *testing.T) { - spec := subpackageSpec() - pkgs := windowsPackages(spec, testTargetKey) - - // Primary is always first; supplemental packages follow sorted by map key - // ("contrib" before "tools"). - assert.Equal(t, len(pkgs), 3) - assert.Equal(t, pkgs[0].Name, "foo") - assert.Equal(t, pkgs[1].Name, "foo-contrib") - // "tools" has a name override. - assert.Equal(t, pkgs[2].Name, "foo-utilities") - - assert.Equal(t, len(pkgs[0].Binaries), 1) - assert.Equal(t, len(pkgs[2].Binaries), 1) + t.Run("packages use deterministic indices independent of their names", func(t *testing.T) { + spec := subpackageSpec() + pkgs := windowsPackages(spec, testTargetKey) + + assert.Equal(t, len(pkgs), 3) + assert.Equal(t, pkgs[0].Name, "foo") + assert.Equal(t, pkgs[1].Name, "foo-contrib") + assert.Equal(t, pkgs[2].Name, "foo-utilities") + assert.Equal(t, pkgs[0].internalDir(), "/0") + assert.Equal(t, pkgs[1].internalDir(), "/1") + assert.Equal(t, pkgs[2].internalDir(), "/2") + assert.Equal(t, len(pkgs[0].Binaries), 1) + assert.Equal(t, len(pkgs[2].Binaries), 1) + }) } func TestGenerateInvocationScript(t *testing.T) { - spec := subpackageSpec() - script := generateInvocationScript(spec, testTargetKey).String() - - // The primary package stages at the root of the output dir; supplemental - // packages each get their own subdirectory. - assert.Assert(t, strings.Contains(script, "mkdir -p '"+outputDir+"'")) - assert.Assert(t, strings.Contains(script, "mkdir -p '"+outputDir+"/foo-contrib'")) - assert.Assert(t, strings.Contains(script, "mkdir -p '"+outputDir+"/foo-utilities'")) - - // Files are copied (not moved) into the package's staging directory by their - // resolved name. The primary package's files land at the output dir root. - assert.Assert(t, strings.Contains(script, "cp -r 'bin/foo.exe' '"+outputDir+"/foo.exe'")) - assert.Assert(t, strings.Contains(script, "cp -r 'bin/foo-contrib.exe' '"+outputDir+"/foo-contrib/foo-contrib.exe'")) - // Name override on the artifact is honored. - assert.Assert(t, strings.Contains(script, "cp -r 'bin/util.exe' '"+outputDir+"/foo-utilities/foo-util.exe'")) - - // The build script is still invoked. - assert.Assert(t, strings.Contains(script, buildScriptName)) + t.Run("each package is staged into an independent flat output", func(t *testing.T) { + spec := subpackageSpec() + script := generateInvocationScript(spec, testTargetKey).String() + + assert.Assert(t, strings.Contains(script, "mkdir -p '"+outputDir+"/0'")) + assert.Assert(t, strings.Contains(script, "mkdir -p '"+outputDir+"/1'")) + assert.Assert(t, strings.Contains(script, "mkdir -p '"+outputDir+"/2'")) + assert.Assert(t, strings.Contains(script, "cp -r 'bin/foo.exe' '"+outputDir+"/0/foo.exe'")) + assert.Assert(t, strings.Contains(script, "cp -r 'bin/foo-contrib.exe' '"+outputDir+"/1/foo-contrib.exe'")) + assert.Assert(t, strings.Contains(script, "cp -r 'bin/util.exe' '"+outputDir+"/2/foo-util.exe'")) + assert.Assert(t, strings.Contains(script, buildScriptName)) + }) } func TestGenerateInvocationScriptPermissions(t *testing.T) { @@ -98,8 +93,38 @@ func TestGenerateInvocationScriptPermissions(t *testing.T) { // build path. This guards against the previous bug where chmod referenced // the source path under the output dir. The primary package stages at the // output dir root. - assert.Assert(t, strings.Contains(script, "cp -r 'sub/dir/foo.exe' '"+outputDir+"/foo.exe'")) - assert.Assert(t, strings.Contains(script, "chmod 755 '"+outputDir+"/foo.exe'")) + assert.Assert(t, strings.Contains(script, "cp -r 'sub/dir/foo.exe' '"+outputDir+"/0/foo.exe'")) + assert.Assert(t, strings.Contains(script, "chmod 755 '"+outputDir+"/0/foo.exe'")) +} + +func TestValidateRuntimeDeps(t *testing.T) { + t.Run("a supplemental package runtime dependency identifies the package", func(t *testing.T) { + spec := subpackageSpec() + target := spec.Targets[testTargetKey] + pkg := target.Packages["contrib"] + pkg.Dependencies = &dalec.SubPackageDependencies{ + Runtime: dalec.PackageDependencyList{"distinctive-runtime-dependency": {}}, + } + target.Packages["contrib"] = pkg + spec.Targets[testTargetKey] = target + + err := validateRuntimeDeps(spec, testTargetKey) + assert.ErrorContains(t, err, `package "foo-contrib"`) + assert.ErrorContains(t, err, "cannot have runtime dependencies") + }) + + t.Run("supplemental runtime dependencies do not affect zip validation", func(t *testing.T) { + spec := subpackageSpec() + target := spec.Targets[testTargetKey] + pkg := target.Packages["contrib"] + pkg.Dependencies = &dalec.SubPackageDependencies{ + Runtime: dalec.PackageDependencyList{"distinctive-runtime-dependency": {}}, + } + target.Packages["contrib"] = pkg + spec.Targets[testTargetKey] = target + + assert.NilError(t, validateZipArtifacts(spec, testTargetKey)) + }) } func TestValidateZipArtifacts(t *testing.T) { diff --git a/targets/windows/validation.go b/targets/windows/validation.go index e0d6d8109..8d094b9c8 100644 --- a/targets/windows/validation.go +++ b/targets/windows/validation.go @@ -8,12 +8,22 @@ import ( ) func validateRuntimeDeps(s *dalec.Spec, targetKey string) error { + var errs []error rd := s.GetPackageDeps(targetKey).GetRuntime() if len(rd) != 0 { - return fmt.Errorf("targets with windows output images cannot have runtime dependencies") + errs = append(errs, fmt.Errorf("package %q: targets with windows output images cannot have runtime dependencies", s.Name)) } - return nil + subpackages := s.GetSubPackages(targetKey) + for _, key := range dalec.SortMapKeys(subpackages) { + pkg := subpackages[key] + if len(pkg.Dependencies.GetRuntime()) == 0 { + continue + } + errs = append(errs, fmt.Errorf("package %q: targets with windows output images cannot have runtime dependencies", pkg.ResolvedName(s.Name, key))) + } + + return goerrors.Join(errs...) } // validateZipArtifacts ensures every package produced for a windowscross/zip diff --git a/test/fixtures/signer/main.go b/test/fixtures/signer/main.go index 2ac57227c..2e2f6d107 100644 --- a/test/fixtures/signer/main.go +++ b/test/fixtures/signer/main.go @@ -7,6 +7,7 @@ import ( "fmt" "io/fs" "os" + "path" "strings" "github.com/moby/buildkit/client/llb" @@ -70,6 +71,9 @@ func main() { } var files []string + replaceFile := bopts["build-arg:DALEC_TEST_SIGNER_REPLACE_FILE"] + failFile := bopts["build-arg:DALEC_TEST_SIGNER_FAIL_FILE"] + replacements := make(map[string][]byte) err = fs.WalkDir(artifactsFS, ".", func(p string, info fs.DirEntry, err error) error { if err != nil { return err @@ -80,6 +84,16 @@ func main() { } files = append(files, p) + if p == failFile { + return fmt.Errorf("test signer failed for %q", p) + } + if p == replaceFile || replaceFile == "*" { + dt, err := fs.ReadFile(artifactsFS, p) + if err != nil { + return err + } + replacements[p] = append([]byte("signed:"), dt...) + } return nil }) if err != nil { @@ -96,6 +110,12 @@ func main() { File(llb.Mkfile("/target", 0o600, []byte(target)), pg). File(llb.Mkfile("/config.json", 0o600, configBytes), pg). File(llb.Mkfile("/manifest.json", 0o600, mfst), pg) + for p, dt := range dalec.SortedMapIter(replacements) { + if dir := path.Dir(p); dir != "." { + output = output.File(llb.Mkdir("/"+dir, 0o755, llb.WithParents(true)), pg) + } + output = output.File(llb.Mkfile("/"+p, 0o600, dt), pg) + } // For any build-arg seen, write a file to /env/ with the contents // being the value of the arg. diff --git a/test/subpackage_test.go b/test/subpackage_test.go index 940a40f83..954eb447c 100644 --- a/test/subpackage_test.go +++ b/test/subpackage_test.go @@ -1,7 +1,10 @@ package test import ( + "archive/tar" + "bufio" "context" + "io" "io/fs" "path" "strings" @@ -32,6 +35,13 @@ func rpmSubpackageTests() *subpackageTestConfig { } } +func debSubpackageTests() *subpackageTestConfig { + return &subpackageTestConfig{ + ReadPackageMetadata: readDEBPackageMetadata, + ArtifactNameMatches: debArtifactNameMatches, + } +} + 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() @@ -285,10 +295,69 @@ func readRPMPackageMetadata(t *testing.T, pkgFS fs.FS, packagePath string) (subp }, true } +func readDEBPackageMetadata(t *testing.T, pkgFS fs.FS, packagePath string) (subpackagePackageMetadata, bool) { + t.Helper() + + if !strings.HasSuffix(packagePath, ".deb") { + return subpackagePackageMetadata{}, false + } + + f, err := pkgFS.Open(packagePath) + assert.NilError(t, err) + defer f.Close() + + controlArchive := extractDebControlFile(t, f) + assert.Assert(t, controlArchive != nil, "control archive not found in DEB %q", packagePath) + defer controlArchive.Close() + + metadata := subpackagePackageMetadata{ + RuntimeDependencies: make(map[string]struct{}), + } + tarReader := tar.NewReader(controlArchive) + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + assert.NilError(t, err) + if path.Base(header.Name) != "control" { + continue + } + + scanner := bufio.NewScanner(tarReader) + for scanner.Scan() { + field, value, ok := strings.Cut(scanner.Text(), ": ") + if !ok { + continue + } + + switch field { + case "Package": + metadata.Name = value + case "Depends": + for dependency := range strings.SplitSeq(value, ",") { + name := strings.Fields(strings.TrimSpace(dependency)) + if len(name) > 0 { + metadata.RuntimeDependencies[name[0]] = struct{}{} + } + } + } + } + assert.NilError(t, scanner.Err()) + break + } + assert.Assert(t, metadata.Name != "", "DEB %q has no package name", packagePath) + return metadata, true +} + func rpmArtifactNameMatches(packagePath, packageName string) bool { return strings.HasPrefix(path.Base(packagePath), packageName+"-") } +func debArtifactNameMatches(packagePath, packageName string) bool { + return strings.HasPrefix(path.Base(packagePath), packageName+"_") +} + type packageArtifactAssertions struct { Config *subpackageTestConfig Packages map[string]subpackagePackageMetadata diff --git a/test/target_ubuntu_test.go b/test/target_ubuntu_test.go index ab229151a..48d8d8415 100644 --- a/test/target_ubuntu_test.go +++ b/test/target_ubuntu_test.go @@ -47,6 +47,7 @@ func debLinuxTestConfigFor(targetKey string, cfg *distro.Config, opts ...func(*t return ver + "-" + cfg.VersionID + "u" + rev }, ListExpectedSignFiles: debExpectedFiles(cfg.VersionID), + Subpackages: debSubpackageTests(), }, LicenseDir: "/usr/share/doc", SystemdDir: struct { diff --git a/test/windows_test.go b/test/windows_test.go index 98c6fa599..252a34742 100644 --- a/test/windows_test.go +++ b/test/windows_test.go @@ -1,10 +1,13 @@ package test import ( + "archive/zip" + "bytes" "context" "encoding/json" "errors" "fmt" + "io" "path/filepath" "runtime" "testing" @@ -893,6 +896,129 @@ func testWindowsSubpackages(ctx context.Context, t *testing.T) { }) }) + t.Run("signed package archives keep package-scoped replacements and original files", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + spec := newSpec() + spec.Build.Steps = []dalec.BuildStep{{ + Command: "printf primary-replacement > primary-replacement; printf primary-original > primary-original; printf contrib-replacement > contrib-replacement; printf contrib-original > contrib-original", + }} + spec.Artifacts.Binaries = map[string]dalec.ArtifactConfig{ + "primary-replacement": {Name: "replace.exe"}, + "primary-original": {Name: "primary-original.exe"}, + } + target := spec.Targets[targetKey] + pkg := target.Packages["contrib"] + pkg.Artifacts.Binaries = map[string]dalec.ArtifactConfig{ + "contrib-replacement": {Name: "replace.exe"}, + "contrib-original": {Name: "contrib-original.exe"}, + } + target.Packages["contrib"] = pkg + spec.Targets[targetKey] = target + spec.PackageConfig = testWindowsSignerConfig(map[string]string{ + "DALEC_TEST_SIGNER_REPLACE_FILE": "replace.exe", + }) + + testEnv.RunTest(ctx, t, func(ctx context.Context, gwc gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget("windowscross/zip")) + res := solveT(ctx, t, gwc, sr) + ref, err := res.SingleRef() + assert.NilError(t, err) + + primary := readWindowsZip(ctx, t, ref, windowsZipFilename(spec, spec.Name)) + assert.Equal(t, string(primary["replace.exe"]), "signed:primary-replacement") + assert.Equal(t, string(primary["primary-original.exe"]), "primary-original") + assert.Assert(t, primary["contrib-original.exe"] == nil) + assertWindowsSignerManifest(t, primary["manifest.json"], "primary-original.exe", "replace.exe") + + supplemental := readWindowsZip(ctx, t, ref, windowsZipFilename(spec, spec.Name+"-contrib")) + assert.Equal(t, string(supplemental["replace.exe"]), "signed:contrib-replacement") + assert.Equal(t, string(supplemental["contrib-original.exe"]), "contrib-original") + assert.Assert(t, supplemental["primary-original.exe"] == nil) + assertWindowsSignerManifest(t, supplemental["manifest.json"], "contrib-original.exe", "replace.exe") + }) + }) + + t.Run("signed primary and supplemental binaries are installed in the container", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + spec := newSpec() + spec.PackageConfig = testWindowsSignerConfig(map[string]string{ + "DALEC_TEST_SIGNER_REPLACE_FILE": "*", + }) + + testEnv.RunTest(ctx, t, func(ctx context.Context, gwc gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget("windowscross/container"), withWindowsAmd64) + res := solveT(ctx, t, gwc, sr) + ref, err := res.SingleRef() + assert.NilError(t, err) + + primary, err := ref.ReadFile(ctx, gwclient.ReadRequest{Filename: "/Windows/System32/primary.exe"}) + assert.NilError(t, err) + assert.Equal(t, string(primary), "signed:primary\n") + + supplemental, err := ref.ReadFile(ctx, gwclient.ReadRequest{Filename: "/Windows/System32/contrib.exe"}) + assert.NilError(t, err) + assert.Equal(t, string(supplemental), "signed:contrib\n") + }) + + t.Run("an all-dotfile supplemental package is archived", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + spec := newSpec() + target := spec.Targets[targetKey] + pkg := target.Packages["contrib"] + pkg.Artifacts.Binaries["contrib.exe"] = dalec.ArtifactConfig{Name: ".contrib.exe"} + target.Packages["contrib"] = pkg + spec.Targets[targetKey] = target + + testEnv.RunTest(ctx, t, func(ctx context.Context, gwc gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget("windowscross/zip")) + res := solveT(ctx, t, gwc, sr) + ref, err := res.SingleRef() + assert.NilError(t, err) + + supplemental := readWindowsZip(ctx, t, ref, windowsZipFilename(spec, spec.Name+"-contrib")) + assert.Equal(t, string(supplemental[".contrib.exe"]), "contrib\n") + }) + }) + + t.Run("a primary artifact named like a supplemental package remains isolated", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + spec := newSpec() + spec.Artifacts.Binaries["primary.exe"] = dalec.ArtifactConfig{Name: spec.Name + "-contrib"} + + testEnv.RunTest(ctx, t, func(ctx context.Context, gwc gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget("windowscross/zip")) + res := solveT(ctx, t, gwc, sr) + ref, err := res.SingleRef() + assert.NilError(t, err) + + primary := readWindowsZip(ctx, t, ref, windowsZipFilename(spec, spec.Name)) + assert.Equal(t, string(primary[spec.Name+"-contrib"]), "primary\n") + + supplemental := readWindowsZip(ctx, t, ref, windowsZipFilename(spec, spec.Name+"-contrib")) + assert.Equal(t, string(supplemental["contrib.exe"]), "contrib\n") + }) + }) + + t.Run("a supplemental package signer failure fails the build", func(t *testing.T) { + t.Parallel() + ctx := startTestSpan(ctx, t) + spec := newSpec() + spec.PackageConfig = testWindowsSignerConfig(map[string]string{ + "DALEC_TEST_SIGNER_FAIL_FILE": "contrib.exe", + }) + + testEnv.RunTest(ctx, t, func(ctx context.Context, gwc gwclient.Client) { + sr := newSolveRequest(withSpec(ctx, t, spec), withBuildTarget("windowscross/zip")) + _, err := gwc.Solve(ctx, sr) + assert.ErrorContains(t, err, `test signer failed for "contrib.exe"`) + }) + }) + }) + t.Run("empty package is rejected", func(t *testing.T) { t.Parallel() ctx := startTestSpan(ctx, t) @@ -911,6 +1037,47 @@ func testWindowsSubpackages(ctx context.Context, t *testing.T) { }) } +func testWindowsSignerConfig(args map[string]string) *dalec.PackageConfig { + return &dalec.PackageConfig{ + Signer: &dalec.PackageSigner{ + Frontend: &dalec.Frontend{Image: phonySignerRef}, + Args: args, + }, + } +} + +func windowsZipFilename(spec *dalec.Spec, packageName string) string { + return fmt.Sprintf("/%s_%s-%s_%s.zip", packageName, spec.Version, spec.Revision, runtime.GOARCH) +} + +func readWindowsZip(ctx context.Context, t *testing.T, ref gwclient.Reference, filename string) map[string][]byte { + t.Helper() + + dt, err := ref.ReadFile(ctx, gwclient.ReadRequest{Filename: filename}) + assert.NilError(t, err) + archive, err := zip.NewReader(bytes.NewReader(dt), int64(len(dt))) + assert.NilError(t, err) + + files := make(map[string][]byte, len(archive.File)) + for _, f := range archive.File { + r, err := f.Open() + assert.NilError(t, err) + content, err := io.ReadAll(r) + r.Close() + assert.NilError(t, err) + files[f.Name] = content + } + return files +} + +func assertWindowsSignerManifest(t *testing.T, dt []byte, expected ...string) { + t.Helper() + + var actual []string + assert.NilError(t, json.Unmarshal(dt, &actual)) + assert.DeepEqual(t, actual, expected) +} + func testWindowsZipFilename(ctx context.Context, t *testing.T) { spec := newSimpleSpec() spec.Build.Steps = []dalec.BuildStep{ From e4d0c271271a83a6f01b1ec92a93b87a6d7acf48 Mon Sep 17 00:00:00 2001 From: Brian Goff Date: Fri, 17 Jul 2026 09:11:07 -0700 Subject: [PATCH 4/5] targets: simplify supplemental package traversal Use deterministic map iteration in Windows package assembly and validation without materializing sorted key slices. Signed-off-by: Brian Goff --- targets/windows/handle_zip.go | 7 ++----- targets/windows/validation.go | 3 +-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/targets/windows/handle_zip.go b/targets/windows/handle_zip.go index 82e24fd31..4287d7ea0 100644 --- a/targets/windows/handle_zip.go +++ b/targets/windows/handle_zip.go @@ -289,8 +289,7 @@ func windowsPackages(spec *dalec.Spec, targetKey string) []windowsPackage { }} sub := spec.GetSubPackages(targetKey) - for _, key := range dalec.SortMapKeys(sub) { - p := sub[key] + for key, p := range dalec.SortedMapIter(sub) { var binaries map[string]dalec.ArtifactConfig if p.Artifacts != nil { binaries = p.Artifacts.Binaries @@ -326,9 +325,7 @@ func generateInvocationScript(spec *dalec.Spec, targetKey string) *strings.Build func writePackageArtifacts(script *strings.Builder, destDir string, pkg windowsPackage) { fmt.Fprintf(script, "mkdir -p '%s'\n", destDir) - sorted := dalec.SortMapKeys(pkg.Binaries) - for _, bin := range sorted { - config := pkg.Binaries[bin] + for bin, config := range dalec.SortedMapIter(pkg.Binaries) { dest := path.Join(destDir, config.ResolveName(bin)) fmt.Fprintf(script, "cp -r '%s' '%s'\n", bin, dest) if config.Permissions.Perm() != 0 { diff --git a/targets/windows/validation.go b/targets/windows/validation.go index 8d094b9c8..e38a8c695 100644 --- a/targets/windows/validation.go +++ b/targets/windows/validation.go @@ -15,8 +15,7 @@ func validateRuntimeDeps(s *dalec.Spec, targetKey string) error { } subpackages := s.GetSubPackages(targetKey) - for _, key := range dalec.SortMapKeys(subpackages) { - pkg := subpackages[key] + for key, pkg := range dalec.SortedMapIter(subpackages) { if len(pkg.Dependencies.GetRuntime()) == 0 { continue } From 99375dc6f7aad3fb94417657fa3903e57b493356 Mon Sep 17 00:00:00 2001 From: Brian Goff Date: Fri, 17 Jul 2026 09:12:11 -0700 Subject: [PATCH 5/5] windows: use target subpackage iterator Resolve supplemental packages through the shared target iterator after the package model migration. Signed-off-by: Brian Goff --- targets/windows/handle_zip.go | 3 +-- targets/windows/validation.go | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/targets/windows/handle_zip.go b/targets/windows/handle_zip.go index 4287d7ea0..b4c0a4a3d 100644 --- a/targets/windows/handle_zip.go +++ b/targets/windows/handle_zip.go @@ -288,8 +288,7 @@ func windowsPackages(spec *dalec.Spec, targetKey string) []windowsPackage { Primary: true, }} - sub := spec.GetSubPackages(targetKey) - for key, p := range dalec.SortedMapIter(sub) { + for key, p := range dalec.GetSubPackagesForTarget(spec, targetKey) { var binaries map[string]dalec.ArtifactConfig if p.Artifacts != nil { binaries = p.Artifacts.Binaries diff --git a/targets/windows/validation.go b/targets/windows/validation.go index e38a8c695..984fa8efc 100644 --- a/targets/windows/validation.go +++ b/targets/windows/validation.go @@ -14,8 +14,7 @@ func validateRuntimeDeps(s *dalec.Spec, targetKey string) error { errs = append(errs, fmt.Errorf("package %q: targets with windows output images cannot have runtime dependencies", s.Name)) } - subpackages := s.GetSubPackages(targetKey) - for key, pkg := range dalec.SortedMapIter(subpackages) { + for key, pkg := range dalec.GetSubPackagesForTarget(s, targetKey) { if len(pkg.Dependencies.GetRuntime()) == 0 { continue }