Skip to content

Commit bedfd3f

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

File tree

6 files changed

+182
-1
lines changed

6 files changed

+182
-1
lines changed
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
base := testutil.NewBase(t)
33+
imageName := testutil.Identifier(t)
34+
ociLayout := "parent"
35+
parentImageName := fmt.Sprintf("%s-%s", imageName, ociLayout)
36+
37+
teardown := func() {
38+
base.Cmd("rmi", parentImageName, imageName).Run()
39+
}
40+
t.Cleanup(teardown)
41+
teardown()
42+
43+
dockerfile := fmt.Sprintf(`FROM %s
44+
LABEL layer=oci-layout-parent
45+
CMD ["echo", "test-nerdctl-build-context-oci-layout-parent"]`, testutil.CommonImage)
46+
buildCtx := createBuildContext(t, dockerfile)
47+
48+
ociLayout := "parent"
49+
tarPath := fmt.Sprintf("%s/%s.tar", buildCtx, ociLayout)
50+
51+
// Create OCI archive from parent image.
52+
base.Cmd("build", buildCtx, "--tag", parentImageName).AssertOK()
53+
base.Cmd("image", "save", "--output", tarPath, parentImageName).AssertOK()
54+
55+
// Unpack OCI archive into OCI layout directory.
56+
ociLayoutDir := t.TempDir()
57+
err := extractTarFile(ociLayoutDir, tarPath)
58+
assert.NilError(t, err)
59+
60+
dockerfile = fmt.Sprintf(`FROM %s
61+
CMD ["echo", "test-nerdctl-build-context-oci-layout"]`, ociLayout)
62+
buildCtx = createBuildContext(t, dockerfile)
63+
64+
base.Cmd("build", buildCtx, fmt.Sprintf("--build-context=%s=oci-layout://%s", ociLayout, ociLayoutPath), "--tag", imageName).AssertOK()
65+
base.Cmd("run", "--rm", imageName).AssertOutContains("test-nerdctl-build-context-oci-layout")
66+
}

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+
}

0 commit comments

Comments
 (0)