Skip to content

Commit de16ae6

Browse files
authored
Merge pull request #3327 from austinvazquez/feat-add-builder-build-oci-layout-build-context
Add builder OCI layout build context
2 parents ed37a4d + 1201665 commit de16ae6

File tree

5 files changed

+216
-2
lines changed

5 files changed

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

pkg/cmd/builder/build.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"strings"
3030

3131
distributionref "github.com/distribution/reference"
32+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
3233

3334
containerd "github.com/containerd/containerd/v2/client"
3435
"github.com/containerd/containerd/v2/core/images"
@@ -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 images.IsManifestType(manifest.MediaType) {
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(filepath.Join(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: 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)