Skip to content

Commit

Permalink
Make solving multi-architecture aware
Browse files Browse the repository at this point in the history
When building an image with multiple architectures, we now disqualify
any packages that aren't available on every architecture.

An example given wolfi trying to solve for `trivy=0.36.1-r2`, which was
only built for arm64, probably because there was a bug in the build that
was fixed in a subsequent epoch:

  for arch "arm64": installing apk packages:
  error getting package dependencies:
  solving "trivy=0.36.1-r2" constraint:
  ...
  trivy-0.36.1-r2.apk disqualified because package "trivy-0.36.1-r2.apk"
    not available for arch "amd64"

Signed-off-by: Jon Johnson <[email protected]>
  • Loading branch information
jonjohnsonjr committed Jun 25, 2024
1 parent 4a1135f commit 0d3dba8
Show file tree
Hide file tree
Showing 5 changed files with 131 additions and 23 deletions.
8 changes: 7 additions & 1 deletion internal/cli/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ func buildImageComponents(ctx context.Context, workDir string, archs []types.Arc
// computation.
multiArchBDE := o.SourceDateEpoch

mc, err := build.NewMulti(ctx, archs, opts...)
mc, err := build.NewMultiArch(ctx, archs, opts...)
if err != nil {
return nil, nil, err
}
Expand All @@ -257,6 +257,12 @@ func buildImageComponents(ctx context.Context, workDir string, archs []types.Arc
}
}

// This is a little different, but we use a multiarch builder to call BuildLayers because we want
// each architecture to be aware of the other architectures during the solve stage. We don't want
// to select any packages unless they are available on every architecture because we want solutions
// to match across architectures.
//
// Eventually, we probably want to do something similar for all this logic around stitching images together.
layers, err := mc.BuildLayers(ctx)
if err != nil {
return nil, nil, fmt.Errorf("building layers: %w", err)
Expand Down
22 changes: 21 additions & 1 deletion pkg/apk/apk/implementation.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,10 @@ type APK struct {

// filename to owning package, last write wins
installedFiles map[string]*Package

// This is a map of arch to apk.APK for every arch in a mult-arch situation.
// It's stuffed here to avoid plumbing it across every method, but it's optional.
Others map[string]*APK
}

func New(options ...Option) (*APK, error) {
Expand Down Expand Up @@ -471,7 +475,23 @@ func (a *APK) ResolveWorld(ctx context.Context) (toInstall []*RepositoryPackage,
return toInstall, conflicts, fmt.Errorf("error getting world packages: %w", err)
}
resolver := NewPkgResolver(ctx, indexes)
toInstall, conflicts, err = resolver.GetPackagesWithDependencies(ctx, directPkgs)

// For other architectures we're building (if any), we want to disqualify any packages not present in all archs.
others := map[string][]NamedIndex{}
for otherArch, otherAPK := range a.Others {
if otherArch == a.arch {
// No need to do this on ourselves.
continue
}

indexes, err := otherAPK.GetRepositoryIndexes(ctx, a.ignoreSignatures)
if err != nil {
return toInstall, conflicts, fmt.Errorf("getting indexes for %q sibling: %w", otherArch, err)
}
others[otherArch] = indexes
}

toInstall, conflicts, err = resolver.GetPackagesWithDependencies(ctx, directPkgs, others)
if err != nil {
return
}
Expand Down
43 changes: 42 additions & 1 deletion pkg/apk/apk/repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -433,15 +433,56 @@ func (p *PkgResolver) constrain(constraints []string, dq map[*RepositoryPackage]
return nil
}

func (p *PkgResolver) disqualifyMissingArchs(otherArchs map[string][]NamedIndex, dq map[*RepositoryPackage]string) {
// arch -> name -> set[version]
allowablePackages := map[string]map[string]map[string]struct{}{}

// Build up a map per arch to quickly check existence of a package name+version.
for arch, indexes := range otherArchs {
allowed := map[string]map[string]struct{}{}
for _, index := range indexes {
for _, pkg := range index.Packages() {
versions, ok := allowed[pkg.Name]
if !ok {
versions = map[string]struct{}{}
}
versions[pkg.Version] = struct{}{}
allowed[pkg.Name] = versions
}
}
allowablePackages[arch] = allowed
}

// Check each package in this arch against every other arch, disqualifying any that are missing.
for _, pkgVersions := range p.nameMap {
for _, pkg := range pkgVersions {
for arch, allowed := range allowablePackages {
versions, ok := allowed[pkg.Name]
if !ok {
p.disqualify(dq, pkg.RepositoryPackage, fmt.Sprintf("package %q not available for arch %q", pkg.Filename(), arch))
}
if _, ok := versions[pkg.Version]; !ok {
p.disqualify(dq, pkg.RepositoryPackage, fmt.Sprintf("package %q not available for arch %q", pkg.Filename(), arch))
}
}
}
}
}

// GetPackagesWithDependencies get all of the dependencies for the given packages based on the
// indexes. Does not filter for installed already or not.
func (p *PkgResolver) GetPackagesWithDependencies(ctx context.Context, packages []string) (toInstall []*RepositoryPackage, conflicts []string, err error) {
func (p *PkgResolver) GetPackagesWithDependencies(ctx context.Context, packages []string, otherArchs map[string][]NamedIndex) (toInstall []*RepositoryPackage, conflicts []string, err error) {
_, span := otel.Tracer("go-apk").Start(ctx, "GetPackageWithDependencies")
defer span.End()

// Tracks all the packages we have disqualified and the reason we disqualified them.
dq := map[*RepositoryPackage]string{}

// If we are solving in the context of other architectures, we want to disqualify any packages that aren't available in all architectures.
if len(otherArchs) != 0 {
p.disqualifyMissingArchs(otherArchs, dq)
}

// We're going to mutate this as our set of input packages to install, so make a copy.
constraints := slices.Clone(packages)

Expand Down
47 changes: 34 additions & 13 deletions pkg/apk/apk/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (

"github.com/stretchr/testify/require"
"golang.org/x/exp/maps"
"golang.org/x/exp/slices"
"golang.org/x/sync/errgroup"

apkfs "chainguard.dev/apko/pkg/apk/fs"
Expand Down Expand Up @@ -458,7 +459,7 @@ func TestGetPackagesWithDependences(t *testing.T) {
// - eliminate duplicates
// - reverse the order, so that it is in order of installation
resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes(index))
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), names)
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), names, nil)
require.NoErrorf(t, err, "unable to get packages")
actual := make([]string, 0, len(pkgs))
for _, pkg := range pkgs {
Expand All @@ -479,7 +480,7 @@ func TestGetPackagesWithDependences(t *testing.T) {
name, version := "package5", "2.0.0" //nolint:goconst // no, we do not want to make it a constant
names := []string{name, "abc9"}
sort.Strings(names)
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), names)
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), names, nil)
require.NoErrorf(t, err, "unable to get packages")
require.Len(t, pkgs, 2)
for _, pkg := range pkgs {
Expand All @@ -493,7 +494,7 @@ func TestGetPackagesWithDependences(t *testing.T) {
name, version := "package5", "1.5.1"
names := []string{fmt.Sprintf("%s=%s", name, version), "abc9"}
sort.Strings(names)
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), names)
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), names, nil)
require.NoErrorf(t, err, "unable to get packages")
require.Len(t, pkgs, 2)
for _, pkg := range pkgs {
Expand All @@ -507,7 +508,7 @@ func TestGetPackagesWithDependences(t *testing.T) {
providesName, version := "package5-special", "1.2.0"
names := []string{providesName, "abc9"}
sort.Strings(names)
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), names)
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), names, nil)
require.NoErrorf(t, err, "unable to get packages")
require.Len(t, pkgs, 2)
for _, pkg := range pkgs {
Expand All @@ -525,7 +526,7 @@ func TestGetPackagesWithDependences(t *testing.T) {
resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes(index))
names := []string{"package5-special", "package5-noconflict", "abc9"}
sort.Strings(names)
_, _, err := resolver.GetPackagesWithDependencies(context.Background(), names)
_, _, err := resolver.GetPackagesWithDependencies(context.Background(), names, nil)
require.NoError(t, err, "provided package should not conflict")
})
t.Run("conflicting provides", func(t *testing.T) {
Expand All @@ -535,7 +536,7 @@ func TestGetPackagesWithDependences(t *testing.T) {
resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes(index))
names := []string{"package5-special", "package5-conflict", "abc9"}
sort.Strings(names)
_, _, err := resolver.GetPackagesWithDependencies(context.Background(), names)
_, _, err := resolver.GetPackagesWithDependencies(context.Background(), names, nil)
require.Error(t, err, "provided package should conflict")
})
t.Run("locked versions", func(t *testing.T) {
Expand All @@ -545,7 +546,7 @@ func TestGetPackagesWithDependences(t *testing.T) {
resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes(index))
names := []string{"package5", "locked-dep"}
sort.Strings(names)
install, _, err := resolver.GetPackagesWithDependencies(context.Background(), names)
install, _, err := resolver.GetPackagesWithDependencies(context.Background(), names, nil)
require.NoError(t, err)
want := []string{
"package5-1.5.1",
Expand All @@ -562,7 +563,7 @@ func TestGetPackagesWithDependences(t *testing.T) {
resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes(index))
names := []string{"package5>1.0.0", "package5=1.5.1"}
sort.Strings(names)
install, _, err := resolver.GetPackagesWithDependencies(context.Background(), names)
install, _, err := resolver.GetPackagesWithDependencies(context.Background(), names, nil)
require.NoError(t, err)
want := []string{
"package5-1.5.1",
Expand All @@ -578,7 +579,7 @@ func TestGetPackagesWithDependences(t *testing.T) {
resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes(index))
names := []string{"package5=1.0.0", "package5=1.5.1"}
sort.Strings(names)
_, _, err := resolver.GetPackagesWithDependencies(context.Background(), names)
_, _, err := resolver.GetPackagesWithDependencies(context.Background(), names, nil)
require.Error(t, err, "Packages should conflict")
})
}
Expand Down Expand Up @@ -852,7 +853,7 @@ func TestExcludedDeps(t *testing.T) {
}

resolver := makeResolver(providers, dependers)
pkgs, conflicts, err := resolver.GetPackagesWithDependencies(context.Background(), []string{"glibc"})
pkgs, conflicts, err := resolver.GetPackagesWithDependencies(context.Background(), []string{"glibc"}, nil)
require.NoError(t, err)

wantPkgs := []string{
Expand All @@ -879,7 +880,7 @@ func TestSameProvidedVersion(t *testing.T) {
}

resolver := makeResolver(providers, dependers)
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), []string{"glibc"})
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), []string{"glibc"}, nil)
require.NoError(t, err)

// When two options provide the same version of a virtual, we expect to take the higher version package.
Expand All @@ -904,7 +905,7 @@ func TestHigherProvidedVersion(t *testing.T) {
}

resolver := makeResolver(providers, dependers)
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), []string{"glibc"})
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), []string{"glibc"}, nil)
require.NoError(t, err)

// When two options provide the different versions of a virtual, we expect to take the higher virtual version.
Expand All @@ -931,7 +932,7 @@ func TestConstrains(t *testing.T) {
}

resolver := makeResolver(providers, dependers)
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), []string{"glibc~2.38", "foo"})
pkgs, _, err := resolver.GetPackagesWithDependencies(context.Background(), []string{"glibc~2.38", "foo"}, nil)
require.NoError(t, err)

// We expect to get the r10 of ld-linux because glibc~2.38 should constraint the solution to that, even though "foo" doesn't care.
Expand Down Expand Up @@ -999,3 +1000,23 @@ func makeResolver(provs, deps map[string][]string) *PkgResolver {
})
return NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes([]*RepositoryWithIndex{repoWithIndex}))
}

func TestDisqualifyingOtherArchitectures(t *testing.T) {
names := []string{"package1", "package2", "onlyinarm64"}
_, index := testGetPackagesAndIndex()

others := map[string][]NamedIndex{
"x86_64": testNamedRepositoryFromIndexes(index),
}

arm64 := slices.Clone(index)
repo := Repository{}
repoWithIndex := repo.WithIndex(&APKIndex{
Packages: []*Package{{Name: "onlyinarm64", Version: "1.0.0"}},
})
arm64 = append(arm64, repoWithIndex)

resolver := NewPkgResolver(context.Background(), testNamedRepositoryFromIndexes(arm64))
_, _, err := resolver.GetPackagesWithDependencies(context.Background(), names, others)
require.ErrorContains(t, err, "package \"onlyinarm64-1.0.0.apk\" not available for arch \"x86_64\"")
}
34 changes: 27 additions & 7 deletions pkg/build/multi.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,27 @@ package build

import (
"context"
"errors"
"fmt"
"slices"
"sync"

"chainguard.dev/apko/pkg/apk/apk"
"chainguard.dev/apko/pkg/build/types"
"chainguard.dev/apko/pkg/tarfs"
v1 "github.com/google/go-containerregistry/pkg/v1"
"golang.org/x/sync/errgroup"
)

// Multi is a build context that can be used to build a multi-architecture image.
// MultiArch is a build context that can be used to build a multi-architecture image.
// It is used to coordinate the solvers across architectures to ensure they have a consistent solution.
// It does this by disqualifying any packages that are not present in other architectures.
type Multi struct {
type MultiArch struct {
Contexts map[types.Architecture]*Context
}

func NewMulti(ctx context.Context, archs []types.Architecture, opts ...Option) (*Multi, error) {
m := &Multi{
func NewMultiArch(ctx context.Context, archs []types.Architecture, opts ...Option) (*MultiArch, error) {
m := &MultiArch{
Contexts: make(map[types.Architecture]*Context),
}

Expand All @@ -48,30 +51,47 @@ func NewMulti(ctx context.Context, archs []types.Architecture, opts ...Option) (
m.Contexts[arch] = c
}

apks := map[string]*apk.APK{}
for arch, bc := range m.Contexts {
apks[arch.String()] = bc.apk
}

for _, bc := range m.Contexts {
bc.apk.Others = apks
}

return m, nil
}

func (m *Multi) BuildLayers(ctx context.Context) (map[types.Architecture]v1.Layer, error) {
func (m *MultiArch) BuildLayers(ctx context.Context) (map[types.Architecture]v1.Layer, error) {
var (
g errgroup.Group
mu sync.Mutex
)
layers := map[types.Architecture]v1.Layer{}
errs := []error{}
for arch, bc := range m.Contexts {
arch, bc := arch, bc

g.Go(func() error {
_, layer, err := bc.BuildLayer(ctx)
if err != nil {
return err
errs = append(errs, fmt.Errorf("for arch %q: %w", arch, err))
return nil
}

mu.Lock()
defer mu.Unlock()
layers[arch] = layer

return nil
})

}

if err := g.Wait(); err != nil {
return nil, err
}

return layers, g.Wait()
return layers, errors.Join(errs...)
}

0 comments on commit 0d3dba8

Please sign in to comment.