From f2e337a5015fd86f9b728c08330d4da4e12c601f Mon Sep 17 00:00:00 2001 From: Austin Vazquez Date: Sat, 17 Aug 2024 06:44:33 +0000 Subject: [PATCH] Add builder OCI layout build context Signed-off-by: Austin Vazquez --- cmd/nerdctl/builder_build_linux_test.go | 80 +++++++++++++++++++++++++ docs/command-reference.md | 2 +- pkg/cmd/builder/build.go | 69 +++++++++++++++++++++ pkg/cmd/builder/build_test.go | 39 ++++++++++++ pkg/testutil/testutil.go | 17 +++++- 5 files changed, 205 insertions(+), 2 deletions(-) create mode 100644 cmd/nerdctl/builder_build_linux_test.go diff --git a/cmd/nerdctl/builder_build_linux_test.go b/cmd/nerdctl/builder_build_linux_test.go new file mode 100644 index 00000000000..3b434e4bfd6 --- /dev/null +++ b/cmd/nerdctl/builder_build_linux_test.go @@ -0,0 +1,80 @@ +/* + Copyright The containerd Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package main + +import ( + "fmt" + "testing" + + "github.com/containerd/nerdctl/v2/pkg/testutil" + "gotest.tools/v3/assert" +) + +func TestBuildContextWithOCILayout(t *testing.T) { + testutil.RequiresBuild(t) + testutil.RegisterBuildCacheCleanup(t) + + var dockerBuilderArgs []string + if testutil.IsDocker() { + // Default docker driver does not support OCI exporter. + // Reference: https://docs.docker.com/build/exporters/oci-docker/ + builderName := testutil.SetupDockerContainerBuilder(t) + dockerBuilderArgs = []string{"buildx", "--builder", builderName} + } + + base := testutil.NewBase(t) + imageName := testutil.Identifier(t) + t.Cleanup(func() { base.Cmd("rmi", imageName) }) + + dockerfile := fmt.Sprintf(`FROM %s +LABEL layer=oci-layout-parent +CMD ["echo", "test-nerdctl-build-context-oci-layout-parent"]`, testutil.CommonImage) + buildCtx := createBuildContext(t, dockerfile) + + tarPath := fmt.Sprintf("%s/%s", buildCtx, "test.tar") + + var buildArgs []string + if testutil.IsDocker() { + buildArgs = dockerBuilderArgs + } + + buildArgs = append(buildArgs, "build", buildCtx, fmt.Sprintf("--output=type=oci,dest=%s", tarPath)) + base.Cmd(buildArgs...).Run() + + ociLayoutDir := t.TempDir() + err := extractTarFile(ociLayoutDir, tarPath) + assert.NilError(t, err) + + ociLayout := "parent" + dockerfile = fmt.Sprintf(`FROM %s +CMD ["echo", "test-nerdctl-build-context-oci-layout"]`, ociLayout) + buildCtx = createBuildContext(t, dockerfile) + + buildArgs = []string{} + if testutil.IsDocker() { + buildArgs = dockerBuilderArgs + } + + buildArgs = append(buildArgs, "build", buildCtx, fmt.Sprintf("--build-context=%s=oci-layout://%s", ociLayout, ociLayoutDir)) + if testutil.IsDocker() { + // Need to load the container image from the builder to be able to run it. + buildArgs = append(buildArgs, "--load") + } + + base.Cmd(buildArgs...).Run() + base.Cmd("run", "--rm", imageName).AssertOutContains("test-nerdctl-build-context-oci-layout") +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 4508b65f8c8..ba782efba9a 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -708,7 +708,7 @@ Flags: - :nerd_face: `--ipfs`: Build image with pulling base images from IPFS. See [`ipfs.md`](./ipfs.md) for details. - :whale: `--label`: Set metadata for an image - :whale: `--network=(default|host|none)`: Set the networking mode for the RUN instructions during build.(compatible with `buildctl build`) -- :whale: --build-context: Set additional contexts for build (e.g. dir2=/path/to/dir2, myorg/myapp=docker-image://path/to/myorg/myapp) +- :whale: `--build-context`: Set additional contexts for build (e.g. dir2=/path/to/dir2, myorg/myapp=docker-image://path/to/myorg/myapp) Unimplemented `docker build` flags: `--add-host`, `--squash` diff --git a/pkg/cmd/builder/build.go b/pkg/cmd/builder/build.go index 03d87848fe1..98b4f1f9e6b 100644 --- a/pkg/cmd/builder/build.go +++ b/pkg/cmd/builder/build.go @@ -36,6 +36,7 @@ import ( "github.com/containerd/errdefs" "github.com/containerd/log" "github.com/containerd/platforms" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/buildkitutil" @@ -300,6 +301,16 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option continue } + if isOCILayout := strings.HasPrefix(v, "oci-layout://"); isOCILayout { + args, err := parseBuildContextFromOCILayout(k, v) + if err != nil { + return "", nil, false, "", nil, nil, err + } + + buildctlArgs = append(buildctlArgs, args...) + continue + } + path, err := filepath.Abs(v) if err != nil { return "", nil, false, "", nil, nil, err @@ -534,3 +545,61 @@ func parseContextNames(values []string) (map[string]string, error) { } return result, nil } + +var ( + errOCILayoutPrefixNotFound = errors.New("OCI layout prefix not found") + errOCILayoutEmptyDigest = errors.New("OCI layout cannot have empty digest") +) + +func parseBuildContextFromOCILayout(name, path string) ([]string, error) { + path, found := strings.CutPrefix(path, "oci-layout://") + if !found { + return []string{}, errOCILayoutPrefixNotFound + } + + abspath, err := filepath.Abs(path) + if err != nil { + return []string{}, err + } + + ociIndex, err := readOCIIndexFromPath(abspath) + if err != nil { + return []string{}, err + } + + var digest string + for _, manifest := range ociIndex.Manifests { + if manifest.MediaType == ocispec.MediaTypeImageManifest { + digest = manifest.Digest.String() + } + } + + if digest == "" { + return []string{}, errOCILayoutEmptyDigest + } + + return []string{ + fmt.Sprintf("--oci-layout=parent-image-key=%s", abspath), + fmt.Sprintf("--opt=context:%s=oci-layout:parent-image-key@%s", name, digest), + }, nil +} + +func readOCIIndexFromPath(path string) (*ocispec.Index, error) { + ociIndexJSONFile, err := os.Open(path + "/index.json") + if err != nil { + return nil, err + } + defer ociIndexJSONFile.Close() + + rawBytes, err := io.ReadAll(ociIndexJSONFile) + if err != nil { + return nil, err + } + + var ociIndex *ocispec.Index + err = json.Unmarshal(rawBytes, &ociIndex) + if err != nil { + return nil, err + } + return ociIndex, nil +} diff --git a/pkg/cmd/builder/build_test.go b/pkg/cmd/builder/build_test.go index f7fb00e539f..924487dce10 100644 --- a/pkg/cmd/builder/build_test.go +++ b/pkg/cmd/builder/build_test.go @@ -187,3 +187,42 @@ func TestIsBuildPlatformDefault(t *testing.T) { }) } } + +func TestParseBuildctlArgsForOCILayout(t *testing.T) { + tests := []struct { + name string + ociLayoutName string + ociLayoutPath string + expectedArgs []string + errorIsNil bool + expectedErr string + }{ + { + name: "PrefixNotFoundError", + ociLayoutName: "unit-test", + ociLayoutPath: "/tmp/oci-layout/", + expectedArgs: []string{}, + expectedErr: "OCI layout prefix not found", + }, + { + name: "DirectoryNotFoundError", + ociLayoutName: "unit-test", + ociLayoutPath: "oci-layout:///tmp/oci-layout", + expectedArgs: []string{}, + expectedErr: "open /tmp/oci-layout/index.json: no such file or directory", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + args, err := parseBuildContextFromOCILayout(test.ociLayoutName, test.ociLayoutPath) + if test.errorIsNil { + assert.NilError(t, err) + } else { + assert.Error(t, err, test.expectedErr) + } + assert.Equal(t, len(args), len(test.expectedArgs)) + assert.DeepEqual(t, args, test.expectedArgs) + }) + } +} diff --git a/pkg/testutil/testutil.go b/pkg/testutil/testutil.go index e1d4b1128d3..f75cf733bdc 100644 --- a/pkg/testutil/testutil.go +++ b/pkg/testutil/testutil.go @@ -574,8 +574,12 @@ func GetDaemonIsKillable() bool { return flagTestKillDaemon } +func IsDocker() bool { + return GetTarget() == Docker +} + func DockerIncompatible(t testing.TB) { - if GetTarget() == Docker { + if IsDocker() { t.Skip("test is incompatible with Docker") } } @@ -788,3 +792,14 @@ func KubectlHelper(base *Base, args ...string) *Cmd { Base: base, } } + +func SetupDockerContainerBuilder(t *testing.T) string { + name := fmt.Sprintf("%s-container", Identifier(t)) + base := NewBase(t) + base.Cmd("buildx", "create", "--name", name, "--driver=docker-container").AssertOK() + t.Cleanup(func() { + base.Cmd("buildx", "stop", name).AssertOK() + base.Cmd("buildx", "rm", "--force", name).AssertOK() + }) + return name +}