Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 24 additions & 2 deletions cache/blobs.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ func (sr *immutableRef) computeBlobChain(ctx context.Context, createIfNeeded boo

if isTypeWindows(sr) {
ctx = winlayers.UseWindowsLayerMode(ctx)
} else if isCrossPlatformLayer(sr) {
ctx = winlayers.SetLayerOS(ctx, sr.GetLayerType())
}

// filter keeps track of which layers should actually be included in the blob chain.
Expand Down Expand Up @@ -171,7 +173,7 @@ func computeBlobChain(ctx context.Context, sr *immutableRef, createIfNeeded bool
return nil, errors.Wrapf(err, "invalid boolean in BUILDKIT_DEBUG_FORCE_OVERLAY_DIFF")
}
fallback = false // prohibit fallback on debug
} else if !isTypeWindows(sr) {
} else if !isCrossPlatformLayer(sr) {
enableOverlay, fallback = true, true
switch sr.cm.Snapshotter.Name() {
case "overlayfs", "stargz":
Expand Down Expand Up @@ -220,7 +222,7 @@ func computeBlobChain(ctx context.Context, sr *immutableRef, createIfNeeded bool
}
}

if desc.Digest == "" && !isTypeWindows(sr) && comp.Type.NeedsComputeDiffBySelf(comp) {
if desc.Digest == "" && !isCrossPlatformLayer(sr) && comp.Type.NeedsComputeDiffBySelf(comp) {
// These compression types aren't supported by containerd differ. So try to compute diff on buildkit side.
// This case can be happen on containerd worker + non-overlayfs snapshotter (e.g. native).
// See also: https://github.com/containerd/containerd/issues/4263
Expand Down Expand Up @@ -447,6 +449,26 @@ func isTypeWindows(sr *immutableRef) bool {
return false
}

// isCrossPlatformLayer returns true if the layer has a non-empty type that
// indicates it was pulled for a different OS than the host.
func isCrossPlatformLayer(sr *immutableRef) bool {
lt := sr.GetLayerType()
if lt != "" {
return true
}
switch sr.kind() {
case Merge:
if slices.ContainsFunc(sr.mergeParents, isCrossPlatformLayer) {
return true
}
case Layer:
if sr.layerParent != nil {
return isCrossPlatformLayer(sr.layerParent)
}
}
return false
}

// ensureCompression ensures the specified ref has the blob of the specified compression Type.
func ensureCompression(ctx context.Context, ref *immutableRef, comp compression.Config, s session.Group) error {
l, err := g.Do(ctx, fmt.Sprintf("ensureComp-%s-%s", ref.ID(), comp.Type), func(ctx context.Context) (_ *leaseutil.LeaseRef, err error) {
Expand Down
106 changes: 106 additions & 0 deletions cache/manager_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -857,6 +857,112 @@ func TestSetBlob(t *testing.T) {
// snap.SetBlob()
}

func TestCrossPlatformLayerType(t *testing.T) {
t.Parallel()
ctx := namespaces.WithNamespace(context.Background(), "buildkit-test")

tmpdir := t.TempDir()

snapshotter, err := native.NewSnapshotter(filepath.Join(tmpdir, "snapshots"))
require.NoError(t, err)
t.Cleanup(func() {
require.NoError(t, snapshotter.Close())
})

co, cleanup, err := newCacheManager(ctx, t, cmOpt{
snapshotter: snapshotter,
snapshotterName: "native",
})
require.NoError(t, err)
t.Cleanup(cleanup)

ctx, done, err := leaseutil.WithLease(ctx, co.lm, leaseutil.MakeTemporary)
require.NoError(t, err)
defer done(context.TODO())

cm := co.manager

ctx, clean, err := leaseutil.WithLease(ctx, co.lm)
require.NoError(t, err)
defer clean(context.TODO())

// Create a blob and ref
b1, desc1, err := mapToBlob(map[string]string{"foo": "bar"}, true)
require.NoError(t, err)
err = content.WriteBlob(ctx, co.cs, "ref1", bytes.NewBuffer(b1), desc1)
require.NoError(t, err)

descHandlers := DescHandlers(make(map[digest.Digest]*DescHandler))
descHandlers[desc1.Digest] = &DescHandler{}

ref1, err := cm.GetByBlob(ctx, desc1, nil, descHandlers)
require.NoError(t, err)

// Default layer type should be empty
require.Equal(t, "", ref1.(*immutableRef).GetLayerType())
require.False(t, isTypeWindows(ref1.(*immutableRef)))
require.False(t, isCrossPlatformLayer(ref1.(*immutableRef)))

// Set layer type to "windows" (Windows image pulled on Linux host)
err = ref1.(*immutableRef).SetLayerType("windows")
require.NoError(t, err)
require.Equal(t, "windows", ref1.(*immutableRef).GetLayerType())
require.True(t, isTypeWindows(ref1.(*immutableRef)))
require.True(t, isCrossPlatformLayer(ref1.(*immutableRef)))

// Create a second ref with "linux" layer type (Linux image pulled on Windows host)
b2, desc2, err := mapToBlob(map[string]string{"foo2": "bar2"}, true)
require.NoError(t, err)
err = content.WriteBlob(ctx, co.cs, "ref2", bytes.NewBuffer(b2), desc2)
require.NoError(t, err)

descHandlers2 := DescHandlers(make(map[digest.Digest]*DescHandler))
descHandlers2[desc2.Digest] = &DescHandler{}

ref2, err := cm.GetByBlob(ctx, desc2, nil, descHandlers2)
require.NoError(t, err)

err = ref2.(*immutableRef).SetLayerType("linux")
require.NoError(t, err)
require.Equal(t, "linux", ref2.(*immutableRef).GetLayerType())
require.False(t, isTypeWindows(ref2.(*immutableRef)))
require.True(t, isCrossPlatformLayer(ref2.(*immutableRef)))

// Create a child ref of the Windows-typed ref - verify parent type propagation
b3, desc3, err := mapToBlob(map[string]string{"foo3": "bar3"}, true)
require.NoError(t, err)
err = content.WriteBlob(ctx, co.cs, "ref3", bytes.NewBuffer(b3), desc3)
require.NoError(t, err)

descHandlers3 := DescHandlers(make(map[digest.Digest]*DescHandler))
descHandlers3[desc3.Digest] = &DescHandler{}

ref3, err := cm.GetByBlob(ctx, desc3, ref1, descHandlers3)
require.NoError(t, err)

// Child doesn't have its own layer type set, but parent does
require.Equal(t, "", ref3.(*immutableRef).GetLayerType())
// isTypeWindows should propagate through layer parent
require.True(t, isTypeWindows(ref3.(*immutableRef)))
require.True(t, isCrossPlatformLayer(ref3.(*immutableRef)))

// Ref with no layer type and no parent type
b4, desc4, err := mapToBlob(map[string]string{"foo4": "bar4"}, true)
require.NoError(t, err)
err = content.WriteBlob(ctx, co.cs, "ref4", bytes.NewBuffer(b4), desc4)
require.NoError(t, err)

descHandlers4 := DescHandlers(make(map[digest.Digest]*DescHandler))
descHandlers4[desc4.Digest] = &DescHandler{}

ref4, err := cm.GetByBlob(ctx, desc4, nil, descHandlers4)
require.NoError(t, err)

require.Equal(t, "", ref4.(*immutableRef).GetLayerType())
require.False(t, isTypeWindows(ref4.(*immutableRef)))
require.False(t, isCrossPlatformLayer(ref4.(*immutableRef)))
}

func TestPrune(t *testing.T) {
t.Parallel()
ctx := namespaces.WithNamespace(context.Background(), "buildkit-test")
Expand Down
4 changes: 2 additions & 2 deletions cache/refs.go
Original file line number Diff line number Diff line change
Expand Up @@ -1327,8 +1327,8 @@ func (sr *immutableRef) unlazyLayer(ctx context.Context, dhs DescHandlers, pg pr
ctx = leaseCtx
}

if sr.GetLayerType() == "windows" {
ctx = winlayers.UseWindowsLayerMode(ctx)
if layerType := sr.GetLayerType(); layerType != "" {
ctx = winlayers.SetLayerOS(ctx, layerType)
}

eg, egctx := errgroup.WithContext(ctx)
Expand Down
63 changes: 63 additions & 0 deletions frontend/dockerfile/dockerfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ var allTests = integration.TestFuncs(
testPlatformWithOSVersion,
testMaintainBaseOSVersion,
testTargetMistype,
testCopyCrossPlatformMultiStage,
)

// Tests that depend on the `security.*` entitlements
Expand Down Expand Up @@ -11463,6 +11464,68 @@ COPY --from=build C:\out C:\
require.Contains(t, err.Error(), "target stage \"bulid\" could not be found (did you mean build?)")
}

func testCopyCrossPlatformMultiStage(t *testing.T, sb integration.Sandbox) {
// Cross-platform COPY exercises bidirectional layer support (#4537):
// on Linux the source stage is windows/amd64; on Windows it is
// linux/amd64 (the new applyLinuxLayer path).
//
// The source stage must not RUN — the worker can't execute the other
// OS. COPY --from reads files straight out of the transformed layer.
// Image refs are fully qualified: the test mirror map is OS-specific.
f := getFrontend(t, sb)

dockerfile := []byte(integration.UnixOrWindows(
// Linux host: windows/amd64 source -> linux final.
`
FROM --platform=windows/amd64 mcr.microsoft.com/windows/nanoserver:ltsc2022 AS winsource

FROM busybox
COPY --from=winsource /Windows/System32/cmd.exe /from-win/cmd.exe
RUN test -s /from-win/cmd.exe && echo -n cross-platform-data > /result.txt
`,
// Windows host: linux/amd64 source -> windows final.
// Doubled backslashes: the Dockerfile escape token is "\", so the
// parser sees C:\from-linux\alpine-release. RUN bodies aren't expanded.
`
FROM --platform=linux/amd64 docker.io/library/alpine:3.20 AS linuxsource

FROM nanoserver
USER ContainerAdministrator
COPY --from=linuxsource /etc/alpine-release C:\\from-linux\\alpine-release
RUN cmd /c "(if not exist C:\from-linux\alpine-release exit /b 1) && echo cross-platform-data> C:\result.txt"
`,
))

dir := integration.Tmpdir(
t,
fstest.CreateFile("Dockerfile", dockerfile, 0600),
)

c, err := client.New(sb.Context(), sb.Address())
require.NoError(t, err)
defer c.Close()

destDir := t.TempDir()

_, err = f.Solve(sb.Context(), c, client.SolveOpt{
Exports: []client.ExportEntry{
{
Type: client.ExporterLocal,
OutputDir: destDir,
},
},
LocalMounts: map[string]fsutil.FS{
dockerui.DefaultLocalNameDockerfile: dir,
dockerui.DefaultLocalNameContext: dir,
},
}, nil)
require.NoError(t, err)

dt, err := os.ReadFile(filepath.Join(destDir, "result.txt"))
require.NoError(t, err)
require.Contains(t, string(dt), "cross-platform-data")
}

func runShell(dir string, cmds ...string) error {
for _, args := range cmds {
var cmd *exec.Cmd
Expand Down
64 changes: 63 additions & 1 deletion snapshot/localmounter_windows.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package snapshot

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"time"

Expand All @@ -12,6 +14,13 @@ import (
"golang.org/x/sys/windows"
)

// crossOSLinuxMarker is the basename of the marker file util/winlayers writes
// into a snapshot whose Files/ tree holds Linux content wrapped as a Windows
// layer. When a parent carries it, Mount bypasses HCS and bind-mounts the
// parent's Files/ directly (HCS PrepareLayer can't expose Linux content
// without Hyper-V).
const crossOSLinuxMarker = ".cross-os-linux"

func (lm *localMounter) Mount() (string, error) {
lm.mu.Lock()
defer lm.mu.Unlock()
Expand Down Expand Up @@ -54,6 +63,12 @@ func (lm *localMounter) Mount() (string, error) {
if err := bindfilter.ApplyFileBinding(dir, m.Source, m.ReadOnly()); err != nil {
return "", errors.Wrapf(err, "failed to mount %v", m)
}
} else if src, ok := crossOSLinuxSource(m); ok {
// Cross-OS Linux source: bypass HCS (which can't expose Linux content
// without Hyper-V) and bind-mount the parent's Files/ tree read-only.
if err := bindfilter.ApplyFileBinding(dir, src, true); err != nil {
return "", errors.Wrapf(err, "failed to bind-mount cross-OS layer files %s", src)
}
} else {
// see https://github.com/moby/buildkit/issues/5807
// if it's a race condition issue, do max 2 retries with some backoff
Expand Down Expand Up @@ -102,7 +117,10 @@ func (lm *localMounter) Unmount() error {
m := lm.mounts[0]

if lm.target != "" {
if m.Type == "bind" || m.Type == "rbind" {
// A cross-OS Linux source is bind-filter mounted even though its mount
// Type is "windows-layer", so re-derive that to pick the right teardown.
// Checked last so the os.Stat is short-circuited for plain bind mounts.
if m.Type == "bind" || m.Type == "rbind" || isCrossOSLinuxMount(m) {
if err := bindfilter.RemoveFileBinding(lm.target); err != nil {
// The following two errors denote that lm.target is not a mount point.
if !errors.Is(err, windows.ERROR_INVALID_PARAMETER) && !errors.Is(err, windows.ERROR_NOT_FOUND) {
Expand Down Expand Up @@ -130,3 +148,47 @@ func (lm *localMounter) Unmount() error {

return nil
}

// crossOSLinuxSource returns the parent's Files/ directory to bind-mount in
// lieu of HCS, when m is a windows-layer mount whose first parent carries the
// cross-OS marker.
func crossOSLinuxSource(m mount.Mount) (string, bool) {
if m.Type != "windows-layer" {
return "", false
}
parents := parseParentLayerPaths(m.Options)
if len(parents) == 0 {
return "", false
}
parent := parents[0]
if _, err := os.Stat(filepath.Join(parent, crossOSLinuxMarker)); err != nil {
Comment on lines +159 to +164
return "", false
}
files := filepath.Join(parent, "Files")
if _, err := os.Stat(files); err != nil {
return "", false
}
return files, true
}

// isCrossOSLinuxMount reports whether the mount is a cross-OS Linux source
// layer (a windows-layer mount whose parent carries the cross-OS marker).
func isCrossOSLinuxMount(m mount.Mount) bool {
_, ok := crossOSLinuxSource(m)
return ok
}

// parseParentLayerPaths decodes the parentLayerPaths= option (a JSON list of
// absolute layer dirs, parent-first) the containerd snapshotter emits.
func parseParentLayerPaths(opts []string) []string {
const prefix = "parentLayerPaths="
for _, opt := range opts {
if after, ok := strings.CutPrefix(opt, prefix); ok {
var paths []string
if err := json.Unmarshal([]byte(after), &paths); err == nil {
return paths
}
}
}
return nil
}
11 changes: 8 additions & 3 deletions source/containerimage/pull.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,12 @@ func (p *puller) Snapshot(ctx context.Context, jobCtx solver.JobContext) (ir cac
}()

var parent cache.ImmutableRef
setWindowsLayerType := p.Platform.OS == "windows" && runtime.GOOS != "windows"
// Set layer type when pulling cross-platform images so that the winlayers
// package can apply the appropriate transformation on apply/diff.
var crossPlatformLayerType string
if p.Platform.OS != runtime.GOOS {
crossPlatformLayerType = p.Platform.OS
}
for _, layerDesc := range p.manifest.Descriptors {
parent = current
current, err = p.CacheAccessor.GetByBlob(ctx, layerDesc, parent,
Expand All @@ -257,8 +262,8 @@ func (p *puller) Snapshot(ctx context.Context, jobCtx solver.JobContext) (ir cac
if err != nil {
return nil, err
}
if setWindowsLayerType {
if err := current.SetLayerType("windows"); err != nil {
if crossPlatformLayerType != "" {
if err := current.SetLayerType(crossPlatformLayerType); err != nil {
return nil, err
}
}
Expand Down
Loading
Loading