diff --git a/cmd/nerdctl/builder/builder_build.go b/cmd/nerdctl/builder/builder_build.go index 096a86df668..f22bcb8d517 100644 --- a/cmd/nerdctl/builder/builder_build.go +++ b/cmd/nerdctl/builder/builder_build.go @@ -45,6 +45,7 @@ If Dockerfile is not present and -f is not specified, it will look for Container SilenceErrors: true, } helpers.AddStringFlag(buildCommand, "buildkit-host", nil, "", "BUILDKIT_HOST", "BuildKit address") + buildCommand.Flags().StringArray("add-host", nil, "Add a custom host-to-IP mapping (format: \"host:ip\")") buildCommand.Flags().StringArrayP("tag", "t", nil, "Name and optionally a tag in the 'name:tag' format") buildCommand.Flags().StringP("file", "f", "", "Name of the Dockerfile") buildCommand.Flags().String("target", "", "Set the target build stage to build") @@ -92,6 +93,10 @@ func processBuildCommandFlag(cmd *cobra.Command, args []string) (types.BuilderBu if err != nil { return types.BuilderBuildOptions{}, err } + extraHosts, err := cmd.Flags().GetStringArray("add-host") + if err != nil { + return types.BuilderBuildOptions{}, err + } platform, err := cmd.Flags().GetStringSlice("platform") if err != nil { return types.BuilderBuildOptions{}, err @@ -232,6 +237,7 @@ func processBuildCommandFlag(cmd *cobra.Command, args []string) (types.BuilderBu Stdin: cmd.InOrStdin(), NetworkMode: network, ExtendedBuildContext: extendedBuildCtx, + ExtraHosts: extraHosts, }, nil } diff --git a/cmd/nerdctl/builder/builder_build_test.go b/cmd/nerdctl/builder/builder_build_test.go index e3d736f9435..407804f904d 100644 --- a/cmd/nerdctl/builder/builder_build_test.go +++ b/cmd/nerdctl/builder/builder_build_test.go @@ -957,3 +957,32 @@ func TestBuildAttestation(t *testing.T) { testCase.Run(t) } + +func TestBuildAddHost(t *testing.T) { + nerdtest.Setup() + + testCase := &test.Case{ + Require: test.Require( + nerdtest.Build, + ), + Cleanup: func(data test.Data, helpers test.Helpers) { + helpers.Anyhow("rmi", "-f", data.Identifier()) + }, + Setup: func(data test.Data, helpers test.Helpers) { + dockerfile := fmt.Sprintf(`FROM %s +RUN ping -c 5 alpha +RUN ping -c 5 beta +`, testutil.CommonImage) + buildCtx := data.TempDir() + err := os.WriteFile(filepath.Join(buildCtx, "Dockerfile"), []byte(dockerfile), 0o600) + assert.NilError(helpers.T(), err) + data.Set("buildCtx", buildCtx) + }, + Command: func(data test.Data, helpers test.Helpers) test.TestableCommand { + return helpers.Command("build", data.Get("buildCtx"), "-t", data.Identifier(), "--add-host", "alpha:127.0.0.1", "--add-host", "beta:127.0.0.1") + }, + Expected: test.Expects(0, nil, nil), + } + + testCase.Run(t) +} diff --git a/docs/command-reference.md b/docs/command-reference.md index 28c4da551de..8f7ce1b18a7 100644 --- a/docs/command-reference.md +++ b/docs/command-reference.md @@ -714,8 +714,9 @@ Flags: - :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: `--add-host`: Add a custom host-to-IP mapping (format: `host:ip`) -Unimplemented `docker build` flags: `--add-host`, `--squash` +Unimplemented `docker build` flags: `--squash` ### :whale: nerdctl commit diff --git a/pkg/api/types/builder_types.go b/pkg/api/types/builder_types.go index c68ec7c44b7..b9574aebcc6 100644 --- a/pkg/api/types/builder_types.go +++ b/pkg/api/types/builder_types.go @@ -71,6 +71,8 @@ type BuilderBuildOptions struct { NetworkMode string // Pull determines if we should try to pull latest image from remote. Default is buildkit's default. Pull *bool + // ExtraHosts is a set of custom host-to-IP mappings. + ExtraHosts []string } // BuilderPruneOptions specifies options for `nerdctl builder prune`. diff --git a/pkg/cmd/builder/build.go b/pkg/cmd/builder/build.go index 9d1f0f7b05f..2ab6df0e8cb 100644 --- a/pkg/cmd/builder/build.go +++ b/pkg/cmd/builder/build.go @@ -40,6 +40,7 @@ import ( "github.com/containerd/nerdctl/v2/pkg/api/types" "github.com/containerd/nerdctl/v2/pkg/buildkitutil" "github.com/containerd/nerdctl/v2/pkg/clientutil" + "github.com/containerd/nerdctl/v2/pkg/containerutil" "github.com/containerd/nerdctl/v2/pkg/platformutil" "github.com/containerd/nerdctl/v2/pkg/referenceutil" "github.com/containerd/nerdctl/v2/pkg/strutil" @@ -453,6 +454,14 @@ func generateBuildctlArgs(ctx context.Context, client *containerd.Client, option } } + if len(options.ExtraHosts) > 0 { + extraHosts, err := containerutil.ParseExtraHosts(options.ExtraHosts, options.GOptions.HostGatewayIP, "=") + if err != nil { + return "", nil, false, "", nil, nil, err + } + buildctlArgs = append(buildctlArgs, "--opt=add-hosts="+strings.Join(extraHosts, ",")) + } + return buildctlBinary, buildctlArgs, needsLoading, metaFile, tags, cleanup, nil } diff --git a/pkg/cmd/container/create.go b/pkg/cmd/container/create.go index 946203ad1e3..37d625b1da5 100644 --- a/pkg/cmd/container/create.go +++ b/pkg/cmd/container/create.go @@ -30,7 +30,6 @@ import ( "strings" dockercliopts "github.com/docker/cli/opts" - dockeropts "github.com/docker/docker/opts" "github.com/opencontainers/runtime-spec/specs-go" containerd "github.com/containerd/containerd/v2/client" @@ -323,22 +322,12 @@ func Create(ctx context.Context, client *containerd.Client, args []string, netMa } internalLabels.name = options.Name internalLabels.pidFile = options.PidFile - internalLabels.extraHosts = strutil.DedupeStrSlice(netManager.NetworkOptions().AddHost) - for i, host := range internalLabels.extraHosts { - if _, err := dockercliopts.ValidateExtraHost(host); err != nil { - return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err - } - parts := strings.SplitN(host, ":", 2) - // If the IP Address is a string called "host-gateway", replace this value with the IP address stored - // in the daemon level HostGateway IP config variable. - if len(parts) == 2 && parts[1] == dockeropts.HostGatewayName { - if options.GOptions.HostGatewayIP == "" { - return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), fmt.Errorf("unable to derive the IP value for host-gateway") - } - parts[1] = options.GOptions.HostGatewayIP - internalLabels.extraHosts[i] = fmt.Sprintf(`%s:%s`, parts[0], parts[1]) - } + + extraHosts, err := containerutil.ParseExtraHosts(netManager.NetworkOptions().AddHost, options.GOptions.HostGatewayIP, ":") + if err != nil { + return nil, generateRemoveOrphanedDirsFunc(ctx, id, dataStore, internalLabels), err } + internalLabels.extraHosts = extraHosts internalLabels.rm = containerutil.EncodeContainerRmOptLabel(options.Rm) diff --git a/pkg/containerutil/containerutil.go b/pkg/containerutil/containerutil.go index 4f21fa70546..fca15cb6669 100644 --- a/pkg/containerutil/containerutil.go +++ b/pkg/containerutil/containerutil.go @@ -28,6 +28,8 @@ import ( "strings" "time" + dockercliopts "github.com/docker/cli/opts" + dockeropts "github.com/docker/docker/opts" "github.com/moby/sys/signal" "github.com/opencontainers/runtime-spec/specs-go" @@ -49,6 +51,7 @@ import ( "github.com/containerd/nerdctl/v2/pkg/portutil" "github.com/containerd/nerdctl/v2/pkg/rootlessutil" "github.com/containerd/nerdctl/v2/pkg/signalutil" + "github.com/containerd/nerdctl/v2/pkg/strutil" "github.com/containerd/nerdctl/v2/pkg/taskutil" ) @@ -606,3 +609,34 @@ func EncodeContainerRmOptLabel(rmOpt bool) string { func DecodeContainerRmOptLabel(rmOptLabel string) (bool, error) { return strconv.ParseBool(rmOptLabel) } + +// ParseExtraHosts takes an array of host-to-IP mapping strings, e.g. "localhost:127.0.0.1", +// and a hostGatewayIP for resolving mappings to "host-gateway". +// +// Returns a map of host-to-IPs or errors if any mapping strings are not correctly formatted. +func ParseExtraHosts(extraHosts []string, hostGatewayIP, separator string) ([]string, error) { + hosts := make([]string, 0, len(extraHosts)) + for _, hostToIP := range strutil.DedupeStrSlice(extraHosts) { + if _, err := dockercliopts.ValidateExtraHost(hostToIP); err != nil { + return nil, err + } + + parts := strings.SplitN(hostToIP, ":", 2) + if len(parts) != 2 { + return nil, fmt.Errorf("invalid host-to-IP map %s", hostToIP) + } + + host, ip := parts[0], parts[1] + + // If the IP address is a string called "host-gateway", replace this value with the IP address stored + // in the daemon level HostGatewayIP config variable. + if ip == dockeropts.HostGatewayName && hostGatewayIP == "" { + return nil, errors.New("unable to derive the IP value for host-gateway") + } else if ip == dockeropts.HostGatewayName { + ip = hostGatewayIP + } + + hosts = append(hosts, host+separator+ip) + } + return hosts, nil +} diff --git a/pkg/containerutil/containerutil_test.go b/pkg/containerutil/containerutil_test.go new file mode 100644 index 00000000000..88d6c42be94 --- /dev/null +++ b/pkg/containerutil/containerutil_test.go @@ -0,0 +1,83 @@ +/* + 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 containerutil + +import ( + "reflect" + "testing" +) + +func TestParseExtraHosts(t *testing.T) { + tests := []struct { + name string + extraHosts []string + hostGateway string + separator string + expected []string + expectedErrStr string + }{ + { + name: "NoExtraHosts", + expected: []string{}, + }, + { + name: "ExtraHosts", + extraHosts: []string{"localhost:127.0.0.1", "localhost:[::1]"}, + separator: ":", + expected: []string{"localhost:127.0.0.1", "localhost:[::1]"}, + }, + { + name: "EqualsSeperator", + extraHosts: []string{"localhost:127.0.0.1", "localhost:[::1]"}, + separator: "=", + expected: []string{"localhost=127.0.0.1", "localhost=[::1]"}, + }, + { + name: "InvalidExtraHostFormat", + extraHosts: []string{"localhost"}, + expectedErrStr: "bad format for add-host: \"localhost\"", + }, + { + name: "ErrorOnHostGatewayExtraHostWithNoHostGatewayIPSet", + extraHosts: []string{"localhost:host-gateway"}, + separator: ":", + expectedErrStr: "unable to derive the IP value for host-gateway", + }, + { + name: "HostGatewayIP", + extraHosts: []string{"localhost:host-gateway"}, + hostGateway: "10.10.0.1", + separator: ":", + expected: []string{"localhost:10.10.0.1"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + extraHosts, err := ParseExtraHosts(test.extraHosts, test.hostGateway, test.separator) + if err != nil && err.Error() != test.expectedErrStr { + t.Fatalf("expected '%s', actual '%v'", test.expectedErrStr, err) + } else if err == nil && test.expectedErrStr != "" { + t.Fatalf("expected error '%s' but got none", test.expectedErrStr) + } + + if !reflect.DeepEqual(test.expected, extraHosts) { + t.Fatalf("expected %v, actual %v", test.expected, extraHosts) + } + }) + } +}