diff --git a/targets/windows/handle_container.go b/targets/windows/handle_container.go index e0db28bd5..02e788115 100644 --- a/targets/windows/handle_container.go +++ b/targets/windows/handle_container.go @@ -144,9 +144,20 @@ 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. Always copy the primary package so the build + // step is realized even when it has no binaries. + pkgs := windowsPackages(spec, targetKey) + out := baseImage + for _, pkg := range pkgs { + if !pkg.Primary && len(pkg.Binaries) == 0 { + continue + } + out = out.File(llb.Copy(bin, pkg.internalDir(), 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..b4c0a4a3d 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 { @@ -167,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)) @@ -177,11 +183,11 @@ 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) + 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...), @@ -203,37 +209,128 @@ 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())) + } + + 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 frontend.MaybeSign(ctx, client, st, spec, targetKey, sOpt) + return dalec.MergeAtPath(llb.Scratch(), packageStates, "/", packageOpts...) } -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. + 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) + 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( - 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 { + // 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. + 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, + }} + + for key, p := range dalec.GetSubPackagesForTarget(spec, targetKey) { + var binaries map[string]dalec.ArtifactConfig + if p.Artifacts != nil { + binaries = p.Artifacts.Binaries + } + pkgs = append(pkgs, windowsPackage{ + Index: len(pkgs), + 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) - for _, bin := range sorted { - config := binaries[bin] - fmt.Fprintf(script, "mv '%s' '%s'\n", bin, outputDir) + + // 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) { + writePackageArtifacts(script, pkg.buildOutputDir(), 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) + + 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 { - 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..04690bcf0 --- /dev/null +++ b/targets/windows/handle_zip_test.go @@ -0,0 +1,152 @@ +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) { + 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) { + 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) { + 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+"/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) { + 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..984fa8efc 100644 --- a/targets/windows/validation.go +++ b/targets/windows/validation.go @@ -1,16 +1,38 @@ package windows import ( + goerrors "errors" "fmt" "github.com/project-dalec/dalec" ) 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 + for key, pkg := range dalec.GetSubPackagesForTarget(s, targetKey) { + 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 +// 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/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 c392f182c..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" @@ -131,6 +134,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 +840,244 @@ 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("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) + 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 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{ diff --git a/website/content/targets.md b/website/content/targets.md index 19eb85769..f04996647 100644 --- a/website/content/targets.md +++ b/website/content/targets.md @@ -188,6 +188,83 @@ 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. + +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