Skip to content

Commit 03fbad3

Browse files
committed
Add builder OCI layout build context
Signed-off-by: Austin Vazquez <[email protected]>
1 parent 25ef7e3 commit 03fbad3

File tree

7 files changed

+223
-2
lines changed

7 files changed

+223
-2
lines changed
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*
2+
Copyright The containerd Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package main
18+
19+
import (
20+
"fmt"
21+
"testing"
22+
23+
"gotest.tools/v3/assert"
24+
25+
"github.com/containerd/nerdctl/v2/pkg/testutil"
26+
)
27+
28+
func TestBuildContextWithOCILayout(t *testing.T) {
29+
testutil.RequiresBuild(t)
30+
testutil.RegisterBuildCacheCleanup(t)
31+
32+
var dockerBuilderArgs []string
33+
if testutil.IsDocker() {
34+
// Default docker driver does not support OCI exporter.
35+
// Reference: https://docs.docker.com/build/exporters/oci-docker/
36+
builderName := testutil.SetupDockerContainerBuilder(t)
37+
dockerBuilderArgs = []string{"buildx", "--builder", builderName}
38+
}
39+
40+
base := testutil.NewBase(t)
41+
imageName := testutil.Identifier(t)
42+
ociLayout := "parent"
43+
parentImageName := fmt.Sprintf("%s-%s", imageName, ociLayout)
44+
45+
teardown := func() {
46+
base.Cmd("rmi", parentImageName, imageName).Run()
47+
}
48+
t.Cleanup(teardown)
49+
teardown()
50+
51+
dockerfile := fmt.Sprintf(`FROM %s
52+
LABEL layer=oci-layout-parent
53+
CMD ["echo", "test-nerdctl-build-context-oci-layout-parent"]`, testutil.CommonImage)
54+
buildCtx := createBuildContext(t, dockerfile)
55+
56+
tarPath := fmt.Sprintf("%s/%s.tar", buildCtx, ociLayout)
57+
58+
// Create OCI archive from parent image.
59+
base.Cmd("build", buildCtx, "--tag", parentImageName).AssertOK()
60+
base.Cmd("image", "save", "--output", tarPath, parentImageName).AssertOK()
61+
62+
// Unpack OCI archive into OCI layout directory.
63+
ociLayoutDir := t.TempDir()
64+
err := extractTarFile(ociLayoutDir, tarPath)
65+
assert.NilError(t, err)
66+
67+
dockerfile = fmt.Sprintf(`FROM %s
68+
CMD ["echo", "test-nerdctl-build-context-oci-layout"]`, ociLayout)
69+
buildCtx = createBuildContext(t, dockerfile)
70+
71+
var buildArgs = []string{}
72+
if testutil.IsDocker() {
73+
buildArgs = dockerBuilderArgs
74+
}
75+
76+
buildArgs = append(buildArgs, "build", buildCtx, fmt.Sprintf("--build-context=%s=oci-layout://%s", ociLayout, ociLayoutDir), "--tag", imageName)
77+
if testutil.IsDocker() {
78+
// Need to load the container image from the builder to be able to run it.
79+
buildArgs = append(buildArgs, "--load")
80+
}
81+
82+
base.Cmd(buildArgs...).AssertOK()
83+
base.Cmd("run", "--rm", imageName).AssertOutContains("test-nerdctl-build-context-oci-layout")
84+
}

docs/command-reference.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,7 +708,7 @@ Flags:
708708
- :nerd_face: `--ipfs`: Build image with pulling base images from IPFS. See [`ipfs.md`](./ipfs.md) for details.
709709
- :whale: `--label`: Set metadata for an image
710710
- :whale: `--network=(default|host|none)`: Set the networking mode for the RUN instructions during build.(compatible with `buildctl build`)
711-
- :whale: --build-context: Set additional contexts for build (e.g. dir2=/path/to/dir2, myorg/myapp=docker-image://path/to/myorg/myapp)
711+
- :whale: `--build-context`: Set additional contexts for build (e.g. dir2=/path/to/dir2, myorg/myapp=docker-image://path/to/myorg/myapp)
712712

713713
Unimplemented `docker build` flags: `--add-host`, `--squash`
714714

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ require (
3434
github.com/coreos/go-iptables v0.7.0
3535
github.com/coreos/go-systemd/v22 v22.5.0
3636
github.com/cyphar/filepath-securejoin v0.3.1
37+
github.com/distribution/distribution v2.8.3+incompatible
3738
github.com/distribution/reference v0.6.0
3839
github.com/docker/cli v27.1.2+incompatible
3940
github.com/docker/docker v27.1.2+incompatible
@@ -86,6 +87,7 @@ require (
8687
github.com/containerd/ttrpc v1.2.5 // indirect
8788
github.com/containers/ocicrypt v1.2.0 // indirect
8889
github.com/djherbis/times v1.6.0 // indirect
90+
github.com/docker/distribution v2.8.3+incompatible // indirect
8991
github.com/docker/docker-credential-helpers v0.8.2 // indirect
9092
github.com/felixge/httpsnoop v1.0.4 // indirect
9193
github.com/go-jose/go-jose/v4 v4.0.4 // indirect

go.sum

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,16 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
8484
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
8585
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
8686
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
87+
github.com/distribution/distribution v2.8.3+incompatible h1:RlpEXBLq/WPXYvBYMDAmBX/SnhD67qwtvW/DzKc8pAo=
88+
github.com/distribution/distribution v2.8.3+incompatible/go.mod h1:EgLm2NgWtdKgzF9NpMzUKgzmR7AMmb0VQi2B+ZzDRjc=
8789
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
8890
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
8991
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
9092
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
9193
github.com/docker/cli v27.1.2+incompatible h1:nYviRv5Y+YAKx3dFrTvS1ErkyVVunKOhoweCTE1BsnI=
9294
github.com/docker/cli v27.1.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
95+
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
96+
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
9397
github.com/docker/docker v27.1.2+incompatible h1:AhGzR1xaQIy53qCkxARaFluI00WPGtXn0AJuoQsVYTY=
9498
github.com/docker/docker v27.1.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
9599
github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=

pkg/cmd/builder/build.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ import (
2828
"strconv"
2929
"strings"
3030

31+
"github.com/distribution/distribution/manifest/schema2"
3132
distributionref "github.com/distribution/reference"
33+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
3234

3335
containerd "github.com/containerd/containerd/v2/client"
3436
"github.com/containerd/containerd/v2/core/images"
@@ -300,6 +302,16 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option
300302
continue
301303
}
302304

305+
if isOCILayout := strings.HasPrefix(v, "oci-layout://"); isOCILayout {
306+
args, err := parseBuildContextFromOCILayout(k, v)
307+
if err != nil {
308+
return "", nil, false, "", nil, nil, err
309+
}
310+
311+
buildctlArgs = append(buildctlArgs, args...)
312+
continue
313+
}
314+
303315
path, err := filepath.Abs(v)
304316
if err != nil {
305317
return "", nil, false, "", nil, nil, err
@@ -534,3 +546,61 @@ func parseContextNames(values []string) (map[string]string, error) {
534546
}
535547
return result, nil
536548
}
549+
550+
var (
551+
ErrOCILayoutPrefixNotFound = errors.New("OCI layout prefix not found")
552+
ErrOCILayoutEmptyDigest = errors.New("OCI layout cannot have empty digest")
553+
)
554+
555+
func parseBuildContextFromOCILayout(name, path string) ([]string, error) {
556+
path, found := strings.CutPrefix(path, "oci-layout://")
557+
if !found {
558+
return []string{}, ErrOCILayoutPrefixNotFound
559+
}
560+
561+
abspath, err := filepath.Abs(path)
562+
if err != nil {
563+
return []string{}, err
564+
}
565+
566+
ociIndex, err := readOCIIndexFromPath(abspath)
567+
if err != nil {
568+
return []string{}, err
569+
}
570+
571+
var digest string
572+
for _, manifest := range ociIndex.Manifests {
573+
if manifest.MediaType == schema2.MediaTypeManifest || manifest.MediaType == ocispec.MediaTypeImageManifest {
574+
digest = manifest.Digest.String()
575+
}
576+
}
577+
578+
if digest == "" {
579+
return []string{}, ErrOCILayoutEmptyDigest
580+
}
581+
582+
return []string{
583+
fmt.Sprintf("--oci-layout=parent-image-key=%s", abspath),
584+
fmt.Sprintf("--opt=context:%s=oci-layout:parent-image-key@%s", name, digest),
585+
}, nil
586+
}
587+
588+
func readOCIIndexFromPath(path string) (*ocispec.Index, error) {
589+
ociIndexJSONFile, err := os.Open(filepath.Join(path, "index.json"))
590+
if err != nil {
591+
return nil, err
592+
}
593+
defer ociIndexJSONFile.Close()
594+
595+
rawBytes, err := io.ReadAll(ociIndexJSONFile)
596+
if err != nil {
597+
return nil, err
598+
}
599+
600+
var ociIndex *ocispec.Index
601+
err = json.Unmarshal(rawBytes, &ociIndex)
602+
if err != nil {
603+
return nil, err
604+
}
605+
return ociIndex, nil
606+
}

pkg/cmd/builder/build_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,3 +187,42 @@ func TestIsBuildPlatformDefault(t *testing.T) {
187187
})
188188
}
189189
}
190+
191+
func TestParseBuildctlArgsForOCILayout(t *testing.T) {
192+
tests := []struct {
193+
name string
194+
ociLayoutName string
195+
ociLayoutPath string
196+
expectedArgs []string
197+
errorIsNil bool
198+
expectedErr string
199+
}{
200+
{
201+
name: "PrefixNotFoundError",
202+
ociLayoutName: "unit-test",
203+
ociLayoutPath: "/tmp/oci-layout/",
204+
expectedArgs: []string{},
205+
expectedErr: ErrOCILayoutPrefixNotFound.Error(),
206+
},
207+
{
208+
name: "DirectoryNotFoundError",
209+
ociLayoutName: "unit-test",
210+
ociLayoutPath: "oci-layout:///tmp/oci-layout",
211+
expectedArgs: []string{},
212+
expectedErr: "open /tmp/oci-layout/index.json: no such file or directory",
213+
},
214+
}
215+
216+
for _, test := range tests {
217+
t.Run(test.name, func(t *testing.T) {
218+
args, err := parseBuildContextFromOCILayout(test.ociLayoutName, test.ociLayoutPath)
219+
if test.errorIsNil {
220+
assert.NilError(t, err)
221+
} else {
222+
assert.Error(t, err, test.expectedErr)
223+
}
224+
assert.Equal(t, len(args), len(test.expectedArgs))
225+
assert.DeepEqual(t, args, test.expectedArgs)
226+
})
227+
}
228+
}

pkg/testutil/testutil.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -575,8 +575,12 @@ func GetDaemonIsKillable() bool {
575575
return flagTestKillDaemon
576576
}
577577

578+
func IsDocker() bool {
579+
return GetTarget() == Docker
580+
}
581+
578582
func DockerIncompatible(t testing.TB) {
579-
if GetTarget() == Docker {
583+
if IsDocker() {
580584
t.Skip("test is incompatible with Docker")
581585
}
582586
}
@@ -789,3 +793,21 @@ func KubectlHelper(base *Base, args ...string) *Cmd {
789793
Base: base,
790794
}
791795
}
796+
797+
// SetupDockerContinerBuilder creates a Docker builder using the docker-container driver
798+
// and adds cleanup steps to test cleanup. The builder name is returned as output.
799+
//
800+
// If not docker, this function returns an empty string as the builder name.
801+
func SetupDockerContainerBuilder(t *testing.T) string {
802+
var name string
803+
if IsDocker() {
804+
name = fmt.Sprintf("%s-container", Identifier(t))
805+
base := NewBase(t)
806+
base.Cmd("buildx", "create", "--name", name, "--driver=docker-container").AssertOK()
807+
t.Cleanup(func() {
808+
base.Cmd("buildx", "stop", name).AssertOK()
809+
base.Cmd("buildx", "rm", "--force", name).AssertOK()
810+
})
811+
}
812+
return name
813+
}

0 commit comments

Comments
 (0)