Skip to content

Commit 669e810

Browse files
committed
Add builder OCI layout build context
Signed-off-by: Austin Vazquez <[email protected]>
1 parent 7dd3050 commit 669e810

File tree

5 files changed

+212
-2
lines changed

5 files changed

+212
-2
lines changed
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
"github.com/containerd/nerdctl/v2/pkg/testutil"
24+
"gotest.tools/v3/assert"
25+
)
26+
27+
func TestBuildContextWithOCILayout(t *testing.T) {
28+
testutil.RequiresBuild(t)
29+
testutil.RegisterBuildCacheCleanup(t)
30+
31+
var dockerBuilderArgs []string
32+
if testutil.IsDocker() {
33+
// Default docker driver does not support OCI exporter.
34+
// Reference: https://docs.docker.com/build/exporters/oci-docker/
35+
builderName := testutil.SetupDockerContainerBuilder(t)
36+
dockerBuilderArgs = []string{"buildx", "--builder", builderName}
37+
}
38+
39+
base := testutil.NewBase(t)
40+
imageName := testutil.Identifier(t)
41+
t.Cleanup(func() { base.Cmd("rmi", imageName).AssertOK() })
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+
var buildArgs []string
52+
if testutil.IsDocker() {
53+
buildArgs = dockerBuilderArgs
54+
}
55+
56+
buildArgs = append(buildArgs, "build", buildCtx, fmt.Sprintf("--output=type=oci,dest=%s", tarPath))
57+
base.Cmd(buildArgs...).Run()
58+
59+
ociLayoutDir := t.TempDir()
60+
err := extractTarFile(ociLayoutDir, tarPath)
61+
assert.NilError(t, err)
62+
63+
dockerfile = fmt.Sprintf(`FROM %s
64+
CMD ["echo", "test-nerdctl-build-context-oci-layout"]`, ociLayout)
65+
buildCtx = createBuildContext(t, dockerfile)
66+
67+
buildArgs = []string{}
68+
if testutil.IsDocker() {
69+
buildArgs = dockerBuilderArgs
70+
}
71+
72+
buildArgs = append(buildArgs, "build", buildCtx, fmt.Sprintf("--build-context=%s=oci-layout://%s", ociLayout, ociLayoutDir), "-t", imageName)
73+
if testutil.IsDocker() {
74+
// Need to load the container image from the builder to be able to run it.
75+
buildArgs = append(buildArgs, "--load")
76+
}
77+
78+
base.Cmd(buildArgs...).Run()
79+
base.Cmd("run", "--rm", imageName).AssertOutContains("test-nerdctl-build-context-oci-layout")
80+
}

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

pkg/cmd/builder/build.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import (
3636
"github.com/containerd/errdefs"
3737
"github.com/containerd/log"
3838
"github.com/containerd/platforms"
39+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
3940

4041
"github.com/containerd/nerdctl/v2/pkg/api/types"
4142
"github.com/containerd/nerdctl/v2/pkg/buildkitutil"
@@ -300,6 +301,16 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option
300301
continue
301302
}
302303

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

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: "OCI layout prefix not found",
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
@@ -574,8 +574,12 @@ func GetDaemonIsKillable() bool {
574574
return flagTestKillDaemon
575575
}
576576

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

0 commit comments

Comments
 (0)