diff --git a/cmd/unikraft/testdata/TestHelp/build b/cmd/unikraft/testdata/TestHelp/build index 43b94e84..05cbeffd 100644 --- a/cmd/unikraft/testdata/TestHelp/build +++ b/cmd/unikraft/testdata/TestHelp/build @@ -43,6 +43,8 @@ Flags: Secret to expose to the build (format: "id=mysecret[,src=/local/secret]"). --ssh SSH agent socket or keys to expose to the build (format: "default|[=|[,]]"). + --build-context + Additional build contexts (e.g., name=path). --insecure Allow insecure (HTTP/unverified TLS) connections to registries. Specify hostnames to restrict, or omit to apply to all. diff --git a/cmd/unikraft/testdata/TestHelp/images b/cmd/unikraft/testdata/TestHelp/images index 0dca8ca7..95df96c2 100644 --- a/cmd/unikraft/testdata/TestHelp/images +++ b/cmd/unikraft/testdata/TestHelp/images @@ -87,6 +87,8 @@ Flags: Secret to expose to the build (format: "id=mysecret[,src=/local/secret]"). --ssh SSH agent socket or keys to expose to the build (format: "default|[=|[,]]"). + --build-context + Additional build contexts (e.g., name=path). --insecure Allow insecure (HTTP/unverified TLS) connections to registries. Specify hostnames to restrict, or omit to apply to all. diff --git a/go.mod b/go.mod index f7ab9f70..ddea20d5 100644 --- a/go.mod +++ b/go.mod @@ -139,7 +139,7 @@ require ( github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect github.com/pelletier/go-toml/v2 v2.3.0 // indirect - github.com/pkg/errors v0.9.1 // indirect + github.com/pkg/errors v0.9.1 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect diff --git a/internal/builder/build.go b/internal/builder/build.go index 6f7f9b67..688ef3a9 100644 --- a/internal/builder/build.go +++ b/internal/builder/build.go @@ -34,10 +34,11 @@ type BuildOpts struct { Labels map[string]string // Buildkit params - BuildArg []string - Target string - Secrets []*buildflags.Secret - SSH []*buildflags.SSH + BuildArg []string + Target string + BuildContexts map[string]string + Secrets []*buildflags.Secret + SSH []*buildflags.SSH NoCache bool } diff --git a/internal/builder/context.go b/internal/builder/context.go new file mode 100644 index 00000000..5856db2d --- /dev/null +++ b/internal/builder/context.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2025, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package builder + +import ( + "strings" + + "github.com/distribution/reference" + "github.com/pkg/errors" +) + +// ParseContextNames parses --build-context values in the form name=value. +func ParseContextNames(values []string) (map[string]string, error) { + if len(values) == 0 { + return nil, nil + } + + result := make(map[string]string, len(values)) + for _, value := range values { + if value == "" { + continue + } + + kv := strings.SplitN(value, "=", 2) + if len(kv) != 2 { + return nil, errors.Errorf( + "invalid context value: %s, expected key=value", + value, + ) + } + + named, err := reference.ParseNormalizedNamed(kv[0]) + if err != nil { + return nil, errors.Wrapf(err, "invalid context name %s", kv[0]) + } + + name := strings.TrimSuffix( + reference.FamiliarString(named), + ":latest", + ) + + result[name] = kv[1] + } + + return result, nil +} diff --git a/internal/builder/context_test.go b/internal/builder/context_test.go new file mode 100644 index 00000000..7e8359bd --- /dev/null +++ b/internal/builder/context_test.go @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: BSD-3-Clause +// Copyright (c) 2025, Unikraft GmbH and The Unikraft CLI Authors. +// Licensed under the BSD-3-Clause License (the "License"). +// You may not use this file except in compliance with the License. + +package builder + +import ( + "testing" +) + +func TestParseContextNames(t *testing.T) { + tests := []struct { + name string + values []string + want map[string]string + wantErr bool + }{ + { + name: "single context", + values: []string{"foo=./foo"}, + want: map[string]string{ + "foo": "./foo", + }, + }, + { + name: "multiple contexts", + values: []string{ + "foo=./foo", + "bar=../bar", + }, + want: map[string]string{ + "foo": "./foo", + "bar": "../bar", + }, + }, + { + name: "missing value", + values: []string{"foo"}, + wantErr: true, + }, + { + name: "empty", + values: []string{""}, + want: map[string]string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseContextNames(tt.values) + + if (err != nil) != tt.wantErr { + t.Fatalf("ParseContextNames() error = %v, wantErr %v", err, tt.wantErr) + } + + if !tt.wantErr { + if len(got) != len(tt.want) { + t.Fatalf("got %v, want %v", got, tt.want) + } + + for name, wantPath := range tt.want { + if got[name] != wantPath { + t.Errorf("context %q = %q, want %q", name, got[name], wantPath) + } + } + } + }) + } +} diff --git a/internal/builder/rootfs.go b/internal/builder/rootfs.go index 532b4944..a0020220 100644 --- a/internal/builder/rootfs.go +++ b/internal/builder/rootfs.go @@ -562,6 +562,11 @@ func applyBuildOpts(attrs map[string]string, localDirs map[string]string, sessio attrs["no-cache"] = "" } + for name, path := range opts.BuildContexts { + localDirs[name] = path + attrs["context:"+name] = "local:" + name + } + for _, buildArg := range opts.BuildArg { if buildArg == "" { continue diff --git a/internal/builder/rootfs_test.go b/internal/builder/rootfs_test.go index b433f3fd..7d7cc022 100644 --- a/internal/builder/rootfs_test.go +++ b/internal/builder/rootfs_test.go @@ -837,3 +837,39 @@ func runBuildRootfs(t *testing.T, opts BuildOpts) []*imagespec.Image { }) return imgs } + +func TestRootfsDockerfileBuildContextIntegration(t *testing.T) { + ctx := rootfsIntegrationContext(t) + + contextDir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(contextDir, "hello.txt"), + []byte("hello from context\n"), + 0o644, + )) + + dockerfile := ` +FROM scratch +COPY --from=shared hello.txt /hello.txt +` + + rootfsPath := writeDockerfile(t, dockerfile) + + imgs := runBuildRootfsIntegration(t, ctx, BuildOpts{ + Rootfs: FSOpts{ + Format: kraftfile.FsTypeCpio, + Path: rootfsPath, + Type: kraftfile.SourceTypeDockerfile, + }, + BuildContexts: map[string]string{ + "shared": contextDir, + }, + Platform: []ocispec.Platform{{OS: "fc", Architecture: "x86_64"}}, + }) + + require.Len(t, imgs, 1) + + files := readCpioInitrd(t, imgs[0]) + require.Contains(t, files, "./hello.txt") + require.Equal(t, "hello from context\n", files["./hello.txt"]) +} diff --git a/internal/cmd/build.go b/internal/cmd/build.go index 26220f57..14023b12 100644 --- a/internal/cmd/build.go +++ b/internal/cmd/build.go @@ -28,10 +28,11 @@ type ImageBuildCmd struct { Arch []string `help:"Only build the Kraftfile targets of these architectures. Defaults to every declared target; required when none are declared." example:"x86_64,arm64"` // similar to docker compose build - BuildArg []string `sep:"none" help:"Set build-time variables."` - NoCache bool `help:"Do not use cache when building the image."` - Secret []string `sep:"none" help:"Secret to expose to the build (format: \"id=mysecret[,src=/local/secret]\")."` - SSH []string `sep:"none" help:"SSH agent socket or keys to expose to the build (format: \"default|[=|[,]]\")."` + BuildArg []string `sep:"none" help:"Set build-time variables."` + NoCache bool `help:"Do not use cache when building the image."` + Secret []string `sep:"none" help:"Secret to expose to the build (format: \"id=mysecret[,src=/local/secret]\")."` + SSH []string `sep:"none" help:"SSH agent socket or keys to expose to the build (format: \"default|[=|[,]]\")."` + BuildContext []string `sep:"none" help:"Additional build contexts (e.g., name=path)."` Insecure []string `help:"Allow insecure (HTTP/unverified TLS) connections to registries. Specify hostnames to restrict, or omit to apply to all." type:"optional"` } @@ -115,6 +116,13 @@ func (c *ImageBuildCmd) Run(ctx context.Context, cfg *config.Config, sandbox *re if len(c.BuildArg) > 0 { buildOpts.BuildArg = append(buildOpts.BuildArg, c.BuildArg...) } + if len(c.BuildContext) > 0 { + contexts, err := builder.ParseContextNames(c.BuildContext) + if err != nil { + return err + } + buildOpts.BuildContexts = contexts + } if len(c.Secret) > 0 { secrets, err := buildflags.ParseSecretSpecs(c.Secret) if err != nil {