dev-tools/mage/crossbuild.go (291 lines of code) (raw):
// Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
// or more contributor license agreements. Licensed under the Elastic License 2.0;
// you may not use this file except in compliance with the Elastic License 2.0.
package mage
import (
"fmt"
"go/build"
"log"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
"github.com/magefile/mage/mg"
"github.com/magefile/mage/sh"
"github.com/elastic/elastic-agent-libs/file"
"github.com/elastic/elastic-agent/dev-tools/mage/gotool"
)
const defaultCrossBuildTarget = "golangCrossBuild"
// Platforms contains the set of target platforms for cross-builds. It can be
// modified at runtime by setting the PLATFORMS environment variable.
// See NewPlatformList for details about platform filtering expressions.
var Platforms = BuildPlatforms.Defaults()
// SelectedPackageTypes is the list of package types. If empty, all packages types
// are considered to be selected (see IsPackageTypeSelected).
var SelectedPackageTypes []PackageType
// SelectedDockerVariants is the list of docker variants. If empty, all docker variants
// are considered to be selected (see IsDockerVariantSelected).
var SelectedDockerVariants []DockerVariant
func init() {
// Allow overriding via PLATFORMS.
if expression := os.Getenv("PLATFORMS"); len(expression) > 0 {
Platforms = NewPlatformList(expression)
}
// Allow overriding via PACKAGES.
if packageTypes := os.Getenv("PACKAGES"); len(packageTypes) > 0 {
for _, pkgtype := range strings.Split(packageTypes, ",") {
var p PackageType
err := p.UnmarshalText([]byte(pkgtype))
if err != nil {
continue
}
SelectedPackageTypes = append(SelectedPackageTypes, p)
}
}
// Allow overriding via DOCKER_VARIANTS.
if dockerVariants := os.Getenv("DOCKER_VARIANTS"); len(dockerVariants) > 0 {
for _, docvariant := range strings.Split(dockerVariants, ",") {
var v DockerVariant
err := v.UnmarshalText([]byte(docvariant))
if err != nil {
continue
}
SelectedDockerVariants = append(SelectedDockerVariants, v)
}
}
}
// CrossBuildOption defines an option to the CrossBuild target.
type CrossBuildOption func(params *crossBuildParams)
// ImageSelectorFunc returns the name of the builder image.
type ImageSelectorFunc func(platform string) (string, error)
// ForPlatforms filters the platforms based on the given expression.
func ForPlatforms(expr string) func(params *crossBuildParams) {
return func(params *crossBuildParams) {
params.Platforms = params.Platforms.Filter(expr)
}
}
// WithTarget specifies the mage target to execute inside the golang-crossbuild
// container.
func WithTarget(target string) func(params *crossBuildParams) {
return func(params *crossBuildParams) {
params.Target = target
}
}
// InDir specifies the base directory to use when cross-building.
func InDir(path ...string) func(params *crossBuildParams) {
return func(params *crossBuildParams) {
params.InDir = filepath.Join(path...)
}
}
// Serially causes each cross-build target to be executed serially instead of
// in parallel.
func Serially() func(params *crossBuildParams) {
return func(params *crossBuildParams) {
params.Serial = true
}
}
// ImageSelector returns the name of the selected builder image.
func ImageSelector(f ImageSelectorFunc) func(params *crossBuildParams) {
return func(params *crossBuildParams) {
params.ImageSelector = f
}
}
// AddPlatforms sets dependencies on others platforms.
func AddPlatforms(expressions ...string) func(params *crossBuildParams) {
return func(params *crossBuildParams) {
var list BuildPlatformList
for _, expr := range expressions {
list = NewPlatformList(expr)
params.Platforms = params.Platforms.Merge(list)
}
}
}
type crossBuildParams struct {
Platforms BuildPlatformList
Target string
Serial bool
InDir string
ImageSelector ImageSelectorFunc
}
// CrossBuild executes a given build target once for each target platform.
func CrossBuild(options ...CrossBuildOption) error {
fmt.Println("--- CrossBuild Elastic-Agent")
params := crossBuildParams{Platforms: Platforms, Target: defaultCrossBuildTarget, ImageSelector: CrossBuildImage}
for _, opt := range options {
opt(¶ms)
}
if len(params.Platforms) == 0 {
log.Printf("Skipping cross-build of target=%v because platforms list is empty.", params.Target)
return nil
}
// AIX can't really be crossbuilt, due to cgo and various compiler shortcomings.
// If we have a singular AIX platform set, revert to a native build toolchain
if runtime.GOOS == "aix" {
for _, platform := range params.Platforms {
if platform.GOOS() == "aix" {
if len(params.Platforms) != 1 {
return fmt.Errorf("platform AIX cannot be crossbuilt with other platforms. Set PLATFORMS='aix/ppc64'")
}
// This is basically a short-out so we can attempt to build on AIX in a relatively generic way
log.Printf("Target is building for AIX, skipping normal crossbuild process")
args := DefaultBuildArgs()
args.OutputDir = filepath.Join("build", "golang-crossbuild")
args.Name += "-" + Platform.GOOS + "-" + Platform.Arch
return Build(args)
}
}
// If we're here, something isn't set.
return fmt.Errorf("cannot crossbuild on AIX. Either run `mage build` or set PLATFORMS='aix/ppc64'")
}
// Docker is required for this target.
if err := HaveDocker(); err != nil {
return err
}
if CrossBuildMountModcache {
// Make sure the module dependencies are downloaded on the host,
// as they will be mounted into the container read-only.
mg.Deps(func() error { return gotool.Mod.Download() })
}
// Build the magefile for Linux, so we can run it inside the container.
mg.Deps(buildMage)
log.Println("crossBuild: Platform list =", params.Platforms)
var deps []interface{}
for _, buildPlatform := range params.Platforms {
if !buildPlatform.Flags.CanCrossBuild() {
return fmt.Errorf("unsupported cross build platform %v", buildPlatform.Name)
}
builder := GolangCrossBuilder{buildPlatform.Name, params.Target, params.InDir, params.ImageSelector}
if params.Serial {
if err := builder.Build(); err != nil {
return fmt.Errorf("failed cross-building target=%s for platform=%s: %w",
params.Target, buildPlatform.Name, err)
}
} else {
deps = append(deps, builder.Build)
}
}
// Each build runs in parallel.
Parallel(deps...)
return nil
}
// CrossBuildXPack executes the 'golangCrossBuild' target in the Beat's
// associated x-pack directory to produce a version of the Beat that contains
// Elastic licensed content.
func CrossBuildXPack(options ...CrossBuildOption) error {
o := []CrossBuildOption{InDir("x-pack", BeatName)}
o = append(o, options...)
return CrossBuild(o...)
}
// buildMage pre-compiles the magefile to a binary using the GOARCH parameter.
// It has the benefit of speeding up the build because the
// mage -compile is done only once rather than in each Docker container.
func buildMage() error {
arch := runtime.GOARCH
return sh.RunWith(map[string]string{"CGO_ENABLED": "0"}, "mage", "-f", "-goos=linux", "-goarch="+arch,
"-compile", CreateDir(filepath.Join("build", "mage-linux-"+arch)))
}
// CrossBuildImage build the docker image.
func CrossBuildImage(platform string) (string, error) {
tagSuffix := "main"
switch {
case platform == "darwin/amd64":
tagSuffix = "darwin-debian11"
case platform == "darwin/arm64" || platform == "darwin/universal":
tagSuffix = "darwin-arm64-debian11"
case platform == "linux/arm64":
tagSuffix = "base-arm-debian9"
case platform == "linux/armv5" || platform == "linux/armv6":
tagSuffix = "armel"
case platform == "linux/armv7":
tagSuffix = "armhf"
case strings.HasPrefix(platform, "linux/mips"):
tagSuffix = "mips-debian11"
case strings.HasPrefix(platform, "linux/ppc"):
tagSuffix = "ppc-debian11"
case platform == "linux/s390x":
tagSuffix = "s390x-debian11"
case strings.HasPrefix(platform, "linux"):
tagSuffix = "main-debian11"
}
goVersion, err := GoVersion()
if err != nil {
return "", err
}
if FIPSBuild {
tagSuffix += "-fips"
}
return BeatsCrossBuildImage + ":" + goVersion + "-" + tagSuffix, nil
}
// GolangCrossBuilder executes the specified mage target inside of the
// associated golang-crossbuild container image for the platform.
type GolangCrossBuilder struct {
Platform string
Target string
InDir string
ImageSelector ImageSelectorFunc
}
// Build executes the build inside of Docker.
func (b GolangCrossBuilder) Build() error {
fmt.Printf(">> %v: Building for %v\n", b.Target, b.Platform)
repoInfo, err := GetProjectRepoInfo()
if err != nil {
return fmt.Errorf("failed to determine repo root and package sub dir: %w", err)
}
mountPoint := filepath.ToSlash(filepath.Join("/go", "src", repoInfo.CanonicalRootImportPath))
// use custom dir for build if given, subdir if not:
cwd := repoInfo.SubDir
if b.InDir != "" {
cwd = b.InDir
}
workDir := filepath.ToSlash(filepath.Join(mountPoint, cwd))
builderArch := runtime.GOARCH
buildCmd, err := filepath.Rel(workDir,
filepath.Join(mountPoint, repoInfo.SubDir, "build/mage-linux-"+builderArch))
if err != nil {
return fmt.Errorf("failed to determine mage-linux-%s relative path: %w", builderArch, err)
}
// If we're running this mage target on Windows, the path separator used in
// buildCmd will be `\`. But the buildCmd is executed inside a Linux Docker
// container. So we ensure that all path separators are `/`.
buildCmd = filepath.ToSlash(buildCmd)
dockerRun := sh.RunCmd("docker", "run")
image, err := b.ImageSelector(b.Platform)
if err != nil {
return fmt.Errorf("failed to determine golang-crossbuild image tag: %w", err)
}
verbose := ""
if mg.Verbose() {
verbose = "true"
}
var args []string
// There's a bug on certain debian versions:
// https://discuss.linuxcontainers.org/t/debian-jessie-containers-have-extremely-low-performance/1272
// basically, apt-get has a bug where will try to iterate through every possible FD as set by the NOFILE ulimit.
// On certain docker installs, docker will set the ulimit to a value > 10^9, which means apt-get will take >1 hour.
// This runs across all possible debian platforms, since there's no real harm in it.
if strings.Contains(image, "debian") {
args = append(args, "--ulimit", "nofile=262144:262144")
}
if runtime.GOOS != "windows" {
args = append(args,
"--env", "EXEC_UID="+strconv.Itoa(os.Getuid()),
"--env", "EXEC_GID="+strconv.Itoa(os.Getgid()),
)
}
if versionQualified {
args = append(args, "--env", "VERSION_QUALIFIER="+versionQualifier)
}
if CrossBuildMountModcache {
// Mount $GOPATH/pkg/mod into the container, read-only.
hostDir := filepath.Join(build.Default.GOPATH, "pkg", "mod")
args = append(args, "-v", hostDir+":/go/pkg/mod:ro")
}
if !ExternalBuild {
beatsPath, err := filepath.Abs(filepath.Join("../beats"))
if err != nil {
return fmt.Errorf("error while reading local beats: %w", err)
}
args = append(args, "-v", fmt.Sprintf("%s:%s", beatsPath, beatsPath))
}
args = append(args,
"--rm",
"--env", "GOFLAGS=-mod=readonly",
"--env", "MAGEFILE_VERBOSE="+verbose,
"--env", "MAGEFILE_TIMEOUT="+EnvOr("MAGEFILE_TIMEOUT", ""),
"--env", fmt.Sprintf("SNAPSHOT=%v", Snapshot),
"--env", fmt.Sprintf("DEV=%v", DevBuild),
"--env", fmt.Sprintf("EXTERNAL=%v", ExternalBuild),
"--env", fmt.Sprintf("FIPS=%v", FIPSBuild),
"-v", repoInfo.RootDir+":"+mountPoint,
"-w", workDir,
image,
// Arguments for docker crossbuild entrypoint. For details see
// https://github.com/elastic/golang-crossbuild/blob/main/go1.17/base/rootfs/entrypoint.go.
"--build-cmd", buildCmd+" "+b.Target,
"--platforms", b.Platform,
)
return dockerRun(args...)
}
// DockerChown chowns files generated during build. EXEC_UID and EXEC_GID must
// be set in the containers environment otherwise this is a noop.
func DockerChown(path string) {
// Chown files generated during build that are root owned.
uid, _ := strconv.Atoi(EnvOr("EXEC_UID", "-1"))
gid, _ := strconv.Atoi(EnvOr("EXEC_GID", "-1"))
if uid > 0 && gid > 0 {
log.Printf(">>> Fixing file ownership issues from Docker at path=%v", path)
if err := chownPaths(uid, gid, path); err != nil {
log.Println(err)
}
}
}
// chownPaths will chown the file and all of the dirs specified in the path.
func chownPaths(uid, gid int, path string) error {
start := time.Now()
numFixed := 0
defer func() {
log.Printf("chown took: %v, changed %d files", time.Since(start), numFixed)
}()
return filepath.Walk(path, func(name string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Get the file's UID and GID.
stat, err := file.Wrap(info)
if err != nil {
return err
}
fileUID, _ := stat.UID()
fileGID, _ := stat.GID()
if uid == fileUID && gid == fileGID {
// Skip if UID/GID are already a match.
return nil
}
if err := os.Chown(name, uid, gid); err != nil {
return fmt.Errorf("failed to chown path=%v : %w", name, err)
}
numFixed++
return nil
})
}