diff --git a/.golangci.yml b/.golangci.yml index 8fe8b46a..51a976fb 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -15,7 +15,10 @@ linters: enable-all-rules: true rules: - name: dot-imports + disabled: true + - name: add-constant + disabled: true issues: max-issues-per-linter: 0 max-same-issues: 0 - new-from-rev: 0ad1b35d4334a8e10856bf9c6d687266430f71d4 \ No newline at end of file + new-from-rev: 0ad1b35d4334a8e10856bf9c6d687266430f71d4 diff --git a/hack/Dockerfile.dev b/hack/Dockerfile.dev index 5b19a58d..2efe3769 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 -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..daa8377d 100755 --- a/hack/test-all-locally.sh +++ b/hack/test-all-locally.sh @@ -31,6 +31,8 @@ minikube docker-env | while read env; do echo $env | grep -E 'export*' | awk '{print $2}' | sed 's/"//g' done > $tempConfigFile +docker run --rm --privileged multiarch/qemu-user-static --reset -p yes + docker run \ --privileged \ --env-file $tempConfigFile \ diff --git a/pkg/kbld/builder/buildah/buildah.go b/pkg/kbld/builder/buildah/buildah.go new file mode 100644 index 00000000..71edf5a4 --- /dev/null +++ b/pkg/kbld/builder/buildah/buildah.go @@ -0,0 +1,177 @@ +// Copyright 2026 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +// Package buildah use Buildah to build container images +// +// Buildah will consume a directory as context and a Dockerfile as instructions. +// To support multiples architectures at once, buildah create and push +// manifests instead of simple images. +// +// https://github.com/containers/buildah +package buildah + +import ( + "errors" + "fmt" + "os" + "os/exec" + "strings" + + ctlb "carvel.dev/kbld/pkg/kbld/builder" + ctlconf "carvel.dev/kbld/pkg/kbld/config" + ctllog "carvel.dev/kbld/pkg/kbld/logger" +) + +// Buildah is the builder class using the buildah tool +type Buildah struct { + logger ctllog.Logger +} + +// New creates 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) + } + + // Buildah requires a directory as context + if !stat.IsDir() { + return fmt.Errorf("expected path '%s' to be a directory, but was not", + directory) + } + + return nil +} + +// Generate a name to send the image to the server +// This name is not random to avoid cluttering the server with an endless +// stream of persistent tags +func remoteImageName(configImageName string, + imgDst *ctlconf.ImageDestination) string { + if imgDst == nil { + return configImageName + } + if len(imgDst.Tags) == 0 { + return imgDst.NewImage + ":latest" + } + return imgDst.NewImage + ":" + imgDst.Tags[0] +} + +// 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 an image using a directory and some options, +// send the result to a remote server and return the tag with hash. +func (b Buildah) BuildAndPushImage(image string, directory string, + imgDst *ctlconf.ImageDestination, + opts ctlconf.SourceBuildahOpts) (string, error) { + const noName = "" + if imgDst == nil { + return noName, errors.New( + "a destination is required to store the built image") + } + + err := ensureDirectory(directory) + if err != nil { + return noName, err + } + + prefixedLogger := b.logger.NewPrefixedWriter(image + " build | ") + prefixedLogger.Write([]byte("Start building using buildah\n")) + + 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.Write([]byte("=> buildah " + strings.Join(cmdArgs, " "))) + { + cmd := exec.Command("buildah", cmdArgs...) + cmd.Dir = directory + cmd.Stdout = prefixedLogger + cmd.Stderr = os.Stderr + + err := cmd.Run() + if err != nil { + prefixedLogger.Write([]byte(fmt.Sprintf("error: %s\n", err))) + return noName, err + } + } + + pushLogger := b.logger.NewPrefixedWriter(image + " push | ") + remoteName := remoteImageName(image, imgDst) + digest, pushErr := Push(localName, remoteName, pushLogger) + if pushErr != nil { + return noName, pushErr + } + remoteName = remoteName + "@" + digest + prefixedLogger.Write([]byte("Image build : " + remoteName)) + return remoteName, nil +} + +// Push sends the buildah manifest to a remote server 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 { + fmt.Printf( + "ERROR: Closing temporary file %q: %v", digestFile.Name(), err) + } + if err := os.Remove(digestFile.Name()); err != nil { + fmt.Printf( + "ERROR: Deleting temporary file %q: %v", digestFile.Name(), err) + } + }() + + // !!! with --digestfile, buildah will not return an error if an + // authentication is required but the file will be empty. + 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 := make([]byte, 64+7) + digestLen, readErr := digestFile.Read(digest) + if readErr != nil { + return "", fmt.Errorf( + "cannot read digest in file %q (you may not be authenticated) : %w", + digestFile.Name(), readErr) + } + return string(digest[0:digestLen]), nil +} // BuildahPush 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..395fa0f6 --- /dev/null +++ b/pkg/kbld/config/config_buildah.go @@ -0,0 +1,54 @@ +// Copyright 2026 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package config + +import "strings" + +// ContainerFileOpts stores options for all build systems using Containerfiles. +// +// see https://github.com/containers/common/blob/main/docs/Containerfile.5.md +type ContainerFileOpts struct { + // Always pull images + Pull bool + // File containing instructions + // Docker will use "Dockerfile" as default + // Buildah can detect "Containerfile" or "Dockerfile" as default + File *string + // Option "--build-arg=K=V" + BuildArgs map[string]string `json:"buildArgs"` + // + Target *string + // + Platforms []string +} + +// SourceBuildahOpts stores options for buildah only. +type SourceBuildahOpts struct { + ContainerFileOpts + // More options + RawOptions *[]string `json:"rawOptions"` +} + +// Args create the `buildah build` command arguments from options. +func (opts SourceBuildahOpts) Args() []string { + args := []string{} + + if opts.Pull { + args = append(args, "--pull") + } + for arg, value := range opts.BuildArgs { + args = append(args, "--build-arg="+arg+"="+value) + } + 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 +} // SourceBuildahOpts.Args diff --git a/pkg/kbld/image/built.go b/pkg/kbld/image/built.go index c4dc3fb8..799ed100 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: + tag, err := i.buildah.BuildAndPushImage( + urlRepo, i.buildSource.Path, i.imgDst, *i.buildSource.Buildah) + return tag, 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/pkg/kbld/image/tagged.go b/pkg/kbld/image/tagged.go index 560a87b8..ce91b2cd 100644 --- a/pkg/kbld/image/tagged.go +++ b/pkg/kbld/image/tagged.go @@ -4,6 +4,8 @@ package image import ( + "strings" + ctlconf "carvel.dev/kbld/pkg/kbld/config" ctlreg "carvel.dev/kbld/pkg/kbld/registry" regname "github.com/google/go-containerregistry/pkg/name" @@ -26,7 +28,8 @@ func (i TaggedImage) URL() (string, []ctlconf.Origin, error) { return "", nil, err } - if len(i.imgDst.Tags) > 0 { + if len(i.imgDst.Tags) > 0 && strings.Contains(url, "@") { + // Configure new tags only if a digest is known dstRef, err := regname.NewDigest(url, regname.WeakValidation) if err != nil { return "", nil, err diff --git a/test/e2e/assets/simple-app/Dockerfile b/test/e2e/assets/simple-app/Dockerfile index 63637232..caa608e2 100644 --- a/test/e2e/assets/simple-app/Dockerfile +++ b/test/e2e/assets/simple-app/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.10.1 AS build-env +FROM docker.io/library/golang:1.10.1 AS build-env WORKDIR /go/src/github.com/mchmarny/simple-app/ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -v -o app diff --git a/test/e2e/assets/simple-app/dev/Dockerfile.dev b/test/e2e/assets/simple-app/dev/Dockerfile.dev index 1c7c8791..232e20b0 100644 --- a/test/e2e/assets/simple-app/dev/Dockerfile.dev +++ b/test/e2e/assets/simple-app/dev/Dockerfile.dev @@ -1,4 +1,4 @@ -FROM golang:1.10.1 +FROM docker.io/library/golang:1.10.1 WORKDIR /go/src/github.com/mchmarny/simple-app/ COPY . . RUN CGO_ENABLED=0 GOOS=linux go build -v -o app diff --git a/test/e2e/build_buildah_test.go b/test/e2e/build_buildah_test.go new file mode 100644 index 00000000..0ee7a365 --- /dev/null +++ b/test/e2e/build_buildah_test.go @@ -0,0 +1,64 @@ +//go:build e2e + +// Copyright 2026 The Carvel Authors. +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "regexp" + "strings" + "testing" +) + +func TestBuildahBuildAndPush(t *testing.T) { + 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:latest@SHA256-REPLACED1 +- image: index.docker.io/*username*/kbld-e2e-tests-build2:test@SHA256-REPLACED2 +`) + + if out != expectedOut { + t.Fatalf("Expected >>>%s<<< to match >>>%s<<<", out, expectedOut) + } +}