diff --git a/build/opt.go b/build/opt.go index ed9ca5a02..be50b8187 100644 --- a/build/opt.go +++ b/build/opt.go @@ -917,29 +917,33 @@ func loadInputs(ctx context.Context, d *driver.DriverHandle, inp *Inputs, pw pro } // handle OCI layout - if localPath, ok := strings.CutPrefix(v.Path, "oci-layout://"); ok { - ref, _, err := ocilayout.Parse("oci-layout://" + localPath) + if ref, ok, err := ocilayout.Parse(v.Path); ok { if err != nil { return nil, err } - localPath, dig, tag := ref.Path, ref.Digest.String(), ref.Tag - if dig == "" { - dig, err = resolveDigest(localPath, tag) + localPath := ref.Path + + if ref.Digest == "" { + dig, err := resolveDigest(localPath, ref.Tag) if err != nil { return nil, errors.Wrapf(err, "oci-layout reference %q could not be resolved", v.Path) } + ref.Digest = digest.Digest(dig) } + store, err := local.NewStore(localPath) if err != nil { return nil, errors.Wrapf(err, "invalid store at %s", localPath) } + storeName := identity.NewID() if target.OCIStores == nil { target.OCIStores = map[string]content.Store{} } target.OCIStores[storeName] = store - target.FrontendAttrs["context:"+k] = "oci-layout://" + storeName + ":" + tag + "@" + dig + ref.Path = storeName + target.FrontendAttrs["context:"+k] = ref.String() continue } diff --git a/build/opt_test.go b/build/opt_test.go index 0e4499a98..6f522c543 100644 --- a/build/opt_test.go +++ b/build/opt_test.go @@ -1,10 +1,16 @@ package build import ( + "context" "testing" "github.com/docker/buildx/util/buildflags" + "github.com/docker/buildx/util/ocilayout" + "github.com/docker/buildx/util/progress" "github.com/moby/buildkit/client" + "github.com/moby/buildkit/client/ociindex" + "github.com/opencontainers/go-digest" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -154,3 +160,91 @@ func TestProxyArgKeyExists(t *testing.T) { }) } } + +func TestLoadInputsOCILayoutNamedContext(t *testing.T) { + layoutPath := t.TempDir() + + idx := ociindex.NewStoreIndex(layoutPath) + manifestDigest := digest.FromString("manifest") + err := idx.Put(ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: manifestDigest, + Size: 1, + }, ociindex.Tag("latest")) + require.NoError(t, err) + + tests := []struct { + name string + ref string + wantRef ocilayout.Ref + }{ + { + name: "digest only", + ref: "oci-layout://" + layoutPath + "@" + manifestDigest.String(), + wantRef: ocilayout.Ref{ + Digest: manifestDigest, + }, + }, + { + name: "tag only", + ref: "oci-layout://" + layoutPath + ":latest", + wantRef: ocilayout.Ref{ + Tag: "latest", + Digest: manifestDigest, + }, + }, + { + name: "tag and digest", + ref: "oci-layout://" + layoutPath + ":latest@" + manifestDigest.String(), + wantRef: ocilayout.Ref{ + Tag: "latest", + Digest: manifestDigest, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + target := &client.SolveOpt{ + FrontendAttrs: map[string]string{}, + } + inp := &Inputs{ + ContextPath: "https://example.com/context.tar.gz", + NamedContexts: map[string]NamedContext{ + "proxy": { + Path: tt.ref, + }, + }, + } + + release, err := loadInputs(context.Background(), nil, inp, testProgressWriter{}, target) + require.NoError(t, err) + require.NotNil(t, release) + t.Cleanup(release) + + attr, ok := target.FrontendAttrs["context:proxy"] + require.True(t, ok) + require.Len(t, target.OCIStores, 1) + + parsed, ok, err := ocilayout.Parse(attr) + require.True(t, ok) + require.NoError(t, err) + require.NotEmpty(t, parsed.Path) + assert.Equal(t, tt.wantRef.Tag, parsed.Tag) + assert.Equal(t, tt.wantRef.Digest, parsed.Digest) + target.OCIStores = nil + }) + } +} + +type testProgressWriter struct{} + +func (testProgressWriter) Write(*client.SolveStatus) {} + +func (testProgressWriter) WriteBuildRef(string, string) {} + +func (testProgressWriter) ValidateLogSource(digest.Digest, any) bool { return true } + +func (testProgressWriter) ClearLogSource(any) {} + +var _ progress.Writer = testProgressWriter{} diff --git a/tests/build.go b/tests/build.go index 6c2d22972..95c4c8579 100644 --- a/tests/build.go +++ b/tests/build.go @@ -1,7 +1,9 @@ package tests import ( + "archive/tar" "bytes" + "context" "encoding/json" "fmt" "io" @@ -13,6 +15,8 @@ import ( "strings" "testing" + "github.com/containerd/containerd/v2/core/content" + "github.com/containerd/containerd/v2/plugins/content/local" "github.com/containerd/continuity/fs/fstest" "github.com/containerd/platforms" "github.com/creack/pty" @@ -21,6 +25,7 @@ import ( "github.com/docker/buildx/util/gitutil" "github.com/docker/buildx/util/gitutil/gittestutil" "github.com/moby/buildkit/client" + "github.com/moby/buildkit/client/ociindex" "github.com/moby/buildkit/frontend/subrequests/lint" "github.com/moby/buildkit/frontend/subrequests/outline" "github.com/moby/buildkit/frontend/subrequests/targets" @@ -32,6 +37,8 @@ import ( "github.com/moby/buildkit/util/testutil" "github.com/moby/buildkit/util/testutil/integration" "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" + ocispecs "github.com/opencontainers/image-spec/specs-go/v1" "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -50,6 +57,7 @@ var buildTests = []func(t *testing.T, sb integration.Sandbox){ testBuildStdin, testBuildRemote, testBuildRemoteAuth, + testBuildNamedContextOCILayoutDigestOnly, testBuildLocalState, testBuildLocalStateStdin, testBuildLocalStateRemote, @@ -305,6 +313,35 @@ COPY foo /foo require.FileExists(t, filepath.Join(dirDest, "foo")) } +func testBuildNamedContextOCILayoutDigestOnly(t *testing.T, sb integration.Sandbox) { + if isMobyWorker(sb) { + t.Skip("oci-layout named contexts are not supported by the docker worker") + } + + dir := tmpdir(t, fstest.CreateFile("Dockerfile", []byte(` +FROM scratch +COPY --from=proxy /foo /foo +`), 0o600)) + layoutPath := filepath.Join(dir, "layout") + expected := "from-oci-layout" + manifestDigest := createOCILayoutImage(t, layoutPath, "foo", []byte(expected), "latest") + dirDest := t.TempDir() + + out, err := buildCmd(sb, + withDir(dir), + withArgs( + "--build-context", "proxy=oci-layout://layout@"+manifestDigest.String(), + "--output=type=local,dest="+dirDest, + dir, + ), + ) + require.NoError(t, err, out) + + dt, err := os.ReadFile(filepath.Join(dirDest, "foo")) + require.NoError(t, err) + require.Equal(t, expected, string(dt)) +} + func testBuildLocalState(t *testing.T, sb integration.Sandbox) { dockerfile := []byte(` FROM busybox:latest AS base @@ -1665,3 +1702,76 @@ COPY --from=base /etc/bar /bar ) return dir } + +func createOCILayoutImage(t *testing.T, layoutPath, fileName string, fileContents []byte, tag string) digest.Digest { + t.Helper() + + store, err := local.NewStore(layoutPath) + require.NoError(t, err) + + layerBytes := bytes.NewBuffer(nil) + tw := tar.NewWriter(layerBytes) + err = tw.WriteHeader(&tar.Header{ + Name: fileName, + Mode: 0o644, + Size: int64(len(fileContents)), + }) + require.NoError(t, err) + _, err = tw.Write(fileContents) + require.NoError(t, err) + require.NoError(t, tw.Close()) + + ctx := context.Background() + layerDesc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageLayer, + Digest: digest.FromBytes(layerBytes.Bytes()), + Size: int64(layerBytes.Len()), + } + err = content.WriteBlob(ctx, store, "layer-"+layerDesc.Digest.String(), bytes.NewReader(layerBytes.Bytes()), layerDesc) + require.NoError(t, err) + + cfgBytes, err := json.Marshal(ocispecs.Image{ + Platform: ocispecs.Platform{ + Architecture: "amd64", + OS: "linux", + }, + Config: ocispecs.ImageConfig{ + WorkingDir: "/", + }, + RootFS: ocispecs.RootFS{ + Type: "layers", + DiffIDs: []digest.Digest{layerDesc.Digest}, + }, + }) + require.NoError(t, err) + cfgDesc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageConfig, + Digest: digest.FromBytes(cfgBytes), + Size: int64(len(cfgBytes)), + } + err = content.WriteBlob(ctx, store, "config-"+cfgDesc.Digest.String(), bytes.NewReader(cfgBytes), cfgDesc) + require.NoError(t, err) + + manifestBytes, err := json.Marshal(ocispecs.Manifest{ + Versioned: specs.Versioned{ + SchemaVersion: 2, + }, + MediaType: ocispecs.MediaTypeImageManifest, + Config: cfgDesc, + Layers: []ocispecs.Descriptor{layerDesc}, + }) + require.NoError(t, err) + manifestDesc := ocispecs.Descriptor{ + MediaType: ocispecs.MediaTypeImageManifest, + Digest: digest.FromBytes(manifestBytes), + Size: int64(len(manifestBytes)), + } + err = content.WriteBlob(ctx, store, "manifest-"+manifestDesc.Digest.String(), bytes.NewReader(manifestBytes), manifestDesc) + require.NoError(t, err) + + idx := ociindex.NewStoreIndex(layoutPath) + err = idx.Put(manifestDesc, ociindex.Tag(tag)) + require.NoError(t, err) + + return manifestDesc.Digest +} diff --git a/util/imagetools/location.go b/util/imagetools/location.go index 737f3044d..215cd64d5 100644 --- a/util/imagetools/location.go +++ b/util/imagetools/location.go @@ -1,8 +1,6 @@ package imagetools import ( - "strings" - "github.com/distribution/reference" "github.com/docker/buildx/util/ocilayout" digest "github.com/opencontainers/go-digest" @@ -181,7 +179,3 @@ func (l *Location) ValidateTargetDigest(desc digest.Digest) error { } return nil } - -func IsOCILayout(s string) bool { - return strings.HasPrefix(s, "oci-layout://") -} diff --git a/util/ocilayout/parse.go b/util/ocilayout/parse.go index 87796f7e0..2b716d48f 100644 --- a/util/ocilayout/parse.go +++ b/util/ocilayout/parse.go @@ -34,7 +34,7 @@ func Parse(s string) (Ref, bool, error) { } } - if i := strings.LastIndex(localPath, ":"); i >= 0 { + if i := strings.LastIndex(localPath, ":"); i >= 0 && !isWindowsDrivePath(localPath, i) { after := localPath[i+1:] if reference.TagRegexp.MatchString(after) { localPath, out.Tag = localPath[:i], after @@ -51,10 +51,18 @@ func Parse(s string) (Ref, bool, error) { func (r Ref) String() string { s := prefix + r.Path if r.Tag != "" { - return s + ":" + r.Tag + s += ":" + r.Tag } if r.Digest != "" { - return s + "@" + r.Digest.String() + s += "@" + r.Digest.String() } return s } + +func isWindowsDrivePath(path string, colon int) bool { + if colon != 1 || len(path) < 2 { + return false + } + c := path[0] + return ('a' <= c && c <= 'z') || ('A' <= c && c <= 'Z') +} diff --git a/util/ocilayout/parse_test.go b/util/ocilayout/parse_test.go index 1370d512d..d2ecf5153 100644 --- a/util/ocilayout/parse_test.go +++ b/util/ocilayout/parse_test.go @@ -3,6 +3,7 @@ package ocilayout import ( "testing" + digest "github.com/opencontainers/go-digest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -39,6 +40,27 @@ func TestParse(t *testing.T) { path: "/path/to/oci/@/layout", tag: "latest", }, + { + s: `oci-layout://C:\path\to\oci\layout`, + path: `C:\path\to\oci\layout`, + tag: "latest", + }, + { + s: `oci-layout://C:\path\to\oci\layout:1.3`, + path: `C:\path\to\oci\layout`, + tag: "1.3", + }, + { + s: `oci-layout://C:\path\to\oci\layout@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`, + path: `C:\path\to\oci\layout`, + dgst: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, + { + s: `oci-layout://C:\path\to\oci\layout:1.3@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa`, + path: `C:\path\to\oci\layout`, + tag: "1.3", + dgst: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }, } { ref, ok, err := Parse(tt.s) require.True(t, ok) @@ -48,3 +70,13 @@ func TestParse(t *testing.T) { assert.Equal(t, tt.tag, ref.Tag, "comparing tag: %s", tt.s) } } + +func TestRefString(t *testing.T) { + ref := Ref{ + Path: "/path/to/oci/layout", + Tag: "1.3", + Digest: digest.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + } + + assert.Equal(t, "oci-layout:///path/to/oci/layout:1.3@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ref.String()) +}