diff --git a/.golangci.yml b/.golangci.yml index 8fe8b46a..d54e3915 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -15,6 +15,12 @@ linters: enable-all-rules: true rules: - name: dot-imports + - name: add-constant + disabled: false + arguments: + - allowInts: "0,1,2,3,4,5,6,7,8,9,10,16,20,22,32,64,80,128,255,443,1024,8080" + maxLitCount: "3" + allowStrs: "\"\"" issues: max-issues-per-linter: 0 max-same-issues: 0 diff --git a/hack/Dockerfile.dev b/hack/Dockerfile.dev index 5b19a58d..344aa94b 100644 --- a/hack/Dockerfile.dev +++ b/hack/Dockerfile.dev @@ -1,7 +1,7 @@ FROM golang:1.25.7 RUN apt-get update -y -RUN apt-get install docker.io apt-transport-https ca-certificates gnupg python-is-python3 -y +RUN apt-get install docker.io apt-transport-https ca-certificates gnupg python-is-python3 buildah qemu-user-static -y RUN mkdir -p ~/.docker/cli-plugins/ RUN curl -sLo ~/.docker/cli-plugins/docker-buildx https://github.com/docker/buildx/releases/download/v0.8.2/buildx-v0.8.2.linux-amd64 diff --git a/hack/test-all-locally.sh b/hack/test-all-locally.sh index 91328476..b989f461 100755 --- a/hack/test-all-locally.sh +++ b/hack/test-all-locally.sh @@ -27,6 +27,10 @@ image_name=$(build_test_deps) tempConfigFile=$(mktemp) trap "rm -f $tempConfigFile" EXIT +# Ensure the host kernel (Minikube VM) is configured to run cross-platform binaries. +# This is required for Buildah to execute steps (like RUN) for linux/arm64 on an amd64 host. +docker run --privileged --rm tonistiigi/binfmt --install all + minikube docker-env | while read env; do echo $env | grep -E 'export*' | awk '{print $2}' | sed 's/"//g' done > $tempConfigFile diff --git a/pkg/kbld/builder/buildah/buildah.go b/pkg/kbld/builder/buildah/buildah.go new file mode 100644 index 00000000..a4ce4c98 --- /dev/null +++ b/pkg/kbld/builder/buildah/buildah.go @@ -0,0 +1,191 @@ +// Copyright 2026 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +// Package buildah provides a builder implementation using Buildah. +package buildah + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "strings" + + ctlb "carvel.dev/kbld/pkg/kbld/builder" + ctlconf "carvel.dev/kbld/pkg/kbld/config" + ctllog "carvel.dev/kbld/pkg/kbld/logger" +) + +var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`) + +// Buildah struct to define the Builder +type Buildah struct { + logger ctllog.Logger +} + +// New create a new Buildah builder +func New(logger ctllog.Logger) Buildah { + return Buildah{logger} +} + +func ensureDirectory(directory string) error { + stat, err := os.Stat(directory) + if err != nil { + return fmt.Errorf( + "Checking if path '%s' is a directory: %s", directory, err) + } + + // Provide explicit directory check error message because otherwise + // docker CLI outputs confusing msg + // 'error: fork/exec /usr/local/bin/docker: not a directory' + if !stat.IsDir() { + return fmt.Errorf( + "Expected path '%s' to be a directory, but was not", directory) + } + + return nil +} + +// Generate a name to store the image in local +// The local name is always new and random. +// The manifest is new each time and do not accumulate images. +func localImageName(configImageName string, + imgDest *ctlconf.ImageDestination) string { + if imgDest != nil { + configImageName = imgDest.NewImage + } + tb := ctlb.TagBuilder{} + randSuffix, err := tb.RandomStr50() + if err != nil { + return configImageName + ":kbld" + } + return configImageName + ":kbld-" + randSuffix +} + +// BuildAndPushImage builds and pushed the images to the registry +func (b Buildah) BuildAndPushImage(image string, directory string, + imgDst *ctlconf.ImageDestination, + opts ctlconf.SourceBuildahOpts) (string, error) { + if imgDst == nil { + return "", errors.New( + "a destination is required to store the built image") + } + + err := ensureDirectory(directory) + if err != nil { + return "", err + } + + prefixedLogger := b.logger.NewPrefixedWriter(image + " build | ") + prefixedLogger.WriteStr("Start building using buildah") + + localName := localImageName(image, imgDst) + cmdArgs := []string{"build", "--manifest=" + localName} + + if opts.File != nil { + cmdArgs = append(cmdArgs, "--file="+*opts.File) + } + cmdArgs = append(cmdArgs, opts.Args()...) + + // Use current directory as context + // cmdArgs = append(cmdArgs, "./") + + prefixedLogger.WriteStr("=> buildah %s", strings.Join(cmdArgs, " ")) + { + cmd := exec.Command("buildah", cmdArgs...) + cmd.Dir = directory + cmd.Stdout = prefixedLogger + cmd.Stderr = io.MultiWriter(os.Stderr, prefixedLogger) + + err := cmd.Run() + if err != nil { + prefixedLogger.WriteStr("error: %s", err) + return "", err + } + } + + pushLogger := b.logger.NewPrefixedWriter(image + " push | ") + // Push using a temporary, random tag, + // and return a canonical digest reference. + tempTagBytes := make([]byte, 8) + if _, err := rand.Read(tempTagBytes); err != nil { + return "", fmt.Errorf("generating temporary tag: %w", err) + } + tempTag := hex.EncodeToString(tempTagBytes) + tempRemoteName := fmt.Sprintf("%s:%s", imgDst.NewImage, tempTag) + digest, pushErr := Push(localName, tempRemoteName, pushLogger) + + if pushErr != nil { + return "", pushErr + } + remoteName := fmt.Sprintf("%s@%s", imgDst.NewImage, digest) + prefixedLogger.WriteStr("Image build : %s", remoteName) + return remoteName, nil +} + +// Push the buildah manifest and return the digest +func Push(src string, dest string, + log *ctllog.PrefixWriter) (string, error) { + digestFile, digestErr := os.CreateTemp("", "buildah-") + if digestErr != nil { + return "", fmt.Errorf( + "cannot create digest file: %w", digestErr) + } + defer func() { + if err := digestFile.Close(); err != nil { + log.WriteStr("ERROR: Closing temp file %q: %v", + digestFile.Name(), err) + } + if err := os.Remove(digestFile.Name()); err != nil { + log.WriteStr("ERROR: Removing temp file %q: %v", + digestFile.Name(), err) + } + }() + + // !!! with --digestfile, buildah will not return an error + // even if an authentication is required. + log.WriteStr( + "=> buildah manifest push --all --digestfile=%s %s docker://%s", + digestFile.Name(), src, dest) + pushCommand := exec.Command("buildah", "manifest", "push", "--all", + "--digestfile="+digestFile.Name(), src, "docker://"+dest) + pushCommand.Stdout = log + pushCommand.Stderr = log + pushErr := pushCommand.Run() + if pushErr != nil { + return "", fmt.Errorf( + "error pushing to %q (check if you are authenticated) : %w", + dest, pushErr) + } + + digest, err := calculateDigest(digestFile) + if err != nil { + return "", err + } + return digest, nil +} + +func calculateDigest(digestFile *os.File) (string, error) { + digestBytes, readErr := os.ReadFile(digestFile.Name()) + if readErr != nil { + return "", fmt.Errorf( + "cannot read digest from temporary file %q: %w", + digestFile.Name(), readErr) + } + + digest := strings.TrimSpace(string(digestBytes)) + if digest == "" { + return "", fmt.Errorf( + "no digest found in file %q", + digestFile.Name()) + } + if !digestPattern.MatchString(digest) { + return "", fmt.Errorf( + "invalid digest format %q in file %q", digest, digestFile.Name()) + } + return digest, nil +} diff --git a/pkg/kbld/config/config.go b/pkg/kbld/config/config.go index 3e4a4afd..634a5a47 100644 --- a/pkg/kbld/config/config.go +++ b/pkg/kbld/config/config.go @@ -61,6 +61,7 @@ type Source struct { KubectlBuildkit *SourceKubectlBuildkitOpts Ko *SourceKoOpts Bazel *SourceBazelOpts + Buildah *SourceBuildahOpts } type ImageOverride struct { diff --git a/pkg/kbld/config/config_buildah.go b/pkg/kbld/config/config_buildah.go new file mode 100644 index 00000000..6edb08c6 --- /dev/null +++ b/pkg/kbld/config/config_buildah.go @@ -0,0 +1,79 @@ +// Copyright 2026 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "slices" + "strings" +) + +// ContainerFileOpts Options for builds using Containerfiles +// +// see https://github.com/containers/common/blob/main/docs/Containerfile.5.md +type ContainerFileOpts struct { + // Pull Always pull images + Pull *bool + // NoCache Do not use cache when building the image + NoCache *bool + // File containing instructions + // Docker will use "Dockerfile" as default + // Buildah can detect "Containerfile" or "Dockerfile" as default + File *string + // BuildArgs Option "--build-arg=K=V" + BuildArgs map[string]string `json:"buildArgs"` + // Target Set the target build stage to build. + Target *string + // Platforms to build for + Platforms []string +} + +// SourceBuildahOpts specifies options for building images using Buildah. +type SourceBuildahOpts struct { + ContainerFileOpts + // More options + RawOptions *[]string `json:"rawOptions"` +} + +// Args stringifies the options +// +//revive:disable-next-line:cognitive-complexity +func (opts SourceBuildahOpts) Args() []string { + args := []string{} + + if opts.Pull != nil && *opts.Pull { + args = append(args, "--pull") + } + if opts.NoCache != nil && *opts.NoCache { + args = append(args, "--no-cache") + } + + if opts.BuildArgs != nil { + args = opts.buildArgs() + } + + if opts.Target != nil { + args = append(args, "--target="+*opts.Target) + } + if len(opts.Platforms) > 0 { + args = append(args, "--platform="+strings.Join(opts.Platforms, ",")) + } + if opts.RawOptions != nil { + args = append(args, *opts.RawOptions...) + } + return args +} + +func (opts SourceBuildahOpts) buildArgs() []string { + var args []string + keys := make([]string, 0, len(opts.BuildArgs)) + for k := range opts.BuildArgs { + keys = append(keys, k) + } + slices.Sort(keys) + for _, arg := range keys { + value := opts.BuildArgs[arg] + args = append(args, "--build-arg="+arg+"="+value) + } + return args +} diff --git a/pkg/kbld/image/built.go b/pkg/kbld/image/built.go index c4dc3fb8..9c7014d4 100644 --- a/pkg/kbld/image/built.go +++ b/pkg/kbld/image/built.go @@ -7,6 +7,7 @@ import ( "path/filepath" ctlbbz "carvel.dev/kbld/pkg/kbld/builder/bazel" + ctlbah "carvel.dev/kbld/pkg/kbld/builder/buildah" ctlbdk "carvel.dev/kbld/pkg/kbld/builder/docker" ctlbko "carvel.dev/kbld/pkg/kbld/builder/ko" ctlbkb "carvel.dev/kbld/pkg/kbld/builder/kubectlbuildkit" @@ -25,13 +26,15 @@ type BuiltImage struct { kubectlBuildkit ctlbkb.KubectlBuildkit ko ctlbko.Ko bazel ctlbbz.Bazel + buildah ctlbah.Buildah } func NewBuiltImage(url string, buildSource ctlconf.Source, imgDst *ctlconf.ImageDestination, docker ctlbdk.Docker, dockerBuildx ctlbdk.Buildx, pack ctlbpk.Pack, - kubectlBuildkit ctlbkb.KubectlBuildkit, ko ctlbko.Ko, bazel ctlbbz.Bazel) BuiltImage { - - return BuiltImage{url, buildSource, imgDst, docker, dockerBuildx, pack, kubectlBuildkit, ko, bazel} + kubectlBuildkit ctlbkb.KubectlBuildkit, ko ctlbko.Ko, bazel ctlbbz.Bazel, + buildah ctlbah.Buildah) BuiltImage { + return BuiltImage{url, buildSource, imgDst, docker, dockerBuildx, pack, + kubectlBuildkit, ko, bazel, buildah} } func (i BuiltImage) URL() (string, []ctlconf.Origin, error) { @@ -84,6 +87,11 @@ func (i BuiltImage) URL() (string, []ctlconf.Origin, error) { urlRepo, i.buildSource.Path, i.imgDst, *i.buildSource.Docker.Buildx) return url, origins, err + case i.buildSource.Buildah != nil: + url, err := i.buildah.BuildAndPushImage(urlRepo, + i.buildSource.Path, i.imgDst, *i.buildSource.Buildah) + return url, origins, err + // Fall back on Docker by default default: if i.buildSource.Docker == nil { diff --git a/pkg/kbld/image/factory.go b/pkg/kbld/image/factory.go index 8a708948..3d441696 100644 --- a/pkg/kbld/image/factory.go +++ b/pkg/kbld/image/factory.go @@ -7,6 +7,7 @@ import ( "fmt" ctlbbz "carvel.dev/kbld/pkg/kbld/builder/bazel" + ctlbah "carvel.dev/kbld/pkg/kbld/builder/buildah" ctlbdk "carvel.dev/kbld/pkg/kbld/builder/docker" ctlbko "carvel.dev/kbld/pkg/kbld/builder/ko" ctlbkb "carvel.dev/kbld/pkg/kbld/builder/kubectlbuildkit" @@ -72,9 +73,10 @@ func (f Factory) New(url string) Image { kubectlBuildkit := ctlbkb.NewKubectlBuildkit(f.logger) ko := ctlbko.NewKo(f.logger) bazel := ctlbbz.NewBazel(docker, f.logger) + buildah := ctlbah.New(f.logger) var builtImg Image = NewBuiltImage(url, srcConf, imgDstConf, - docker, dockerBuildx, pack, kubectlBuildkit, ko, bazel) + docker, dockerBuildx, pack, kubectlBuildkit, ko, bazel, buildah) if imgDstConf != nil { builtImg = NewTaggedImage(builtImg, *imgDstConf, f.registry) diff --git a/test/e2e/README.md b/test/e2e/README.md index 5531f47e..8e5ffc87 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -8,6 +8,7 @@ To run the end to end tests, you must have the following utilities installed on - [kubectl-buildkit 0.1.0](https://github.com/vmware-tanzu/buildkit-cli-for-kubectl/releases/tag/v0.1.0) - [ko 0.8.0](https://github.com/google/ko/releases/tag/v0.8.0) - [bazel 4.2.0](https://github.com/bazelbuild/bazel/releases/tag/4.2.0) +- [buildah 1.43.0](https://github.com/containers/buildah/blob/main/install.md) ### Run End to End Tests diff --git a/test/e2e/build_buildah_test.go b/test/e2e/build_buildah_test.go new file mode 100644 index 00000000..f4b72ed2 --- /dev/null +++ b/test/e2e/build_buildah_test.go @@ -0,0 +1,71 @@ +//go:build e2e + +// Copyright 2026 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "regexp" + "runtime" + "strings" + "testing" +) + +func TestBuildahBuildAndPush(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Buildah is only available for linux, so we are skipping this test") + } + + env := BuildEnv(t) + kbld := Kbld{t, env.KbldBinaryPath, Logger{}} + + input := env.WithRegistries(` +kind: Object +spec: +- image: docker.io/*username*/kbld-e2e-tests-build +- image: docker.io/*username*/kbld-e2e-tests-build2 +--- +apiVersion: kbld.k14s.io/v1alpha1 +kind: Sources +sources: +- image: docker.io/*username*/kbld-e2e-tests-build + path: assets/simple-app + buildah: + pull: true +- image: docker.io/*username*/kbld-e2e-tests-build2 + path: assets/simple-app + buildah: + # try out multi platform build + platforms: ["linux/amd64","linux/arm64"] + +--- +apiVersion: kbld.k14s.io/v1alpha1 +kind: ImageDestinations +destinations: +- image: docker.io/*username*/kbld-e2e-tests-build +- image: docker.io/*username*/kbld-e2e-tests-build2 + tags: + - test +`) + + out, _ := kbld.RunWithOpts([]string{"-f", "-", "--images-annotation=false"}, RunOpts{ + StdinReader: strings.NewReader(input), + }) + + out = strings.Replace(out, regexp.MustCompile( + "sha256:[a-z0-9]{64}").FindString(out), "SHA256-REPLACED1", -1) + out = strings.Replace(out, regexp.MustCompile( + "sha256:[a-z0-9]{64}").FindString(out), "SHA256-REPLACED2", -1) + + expectedOut := env.WithRegistries(`--- +kind: Object +spec: +- image: index.docker.io/*username*/kbld-e2e-tests-build@SHA256-REPLACED1 +- image: index.docker.io/*username*/kbld-e2e-tests-build2@SHA256-REPLACED2 +`) + + if out != expectedOut { + t.Fatalf("Expected >>>%s<<< to match >>>%s<<<", out, expectedOut) + } +}