dev-tools/mage/dockerbuilder.go (258 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 (
"bytes"
"compress/gzip"
"errors"
"fmt"
"io"
"io/fs"
"maps"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/elastic/elastic-agent/internal/pkg/agent/install"
"github.com/elastic/elastic-agent/pkg/component"
"github.com/magefile/mage/sh"
)
type dockerBuilder struct {
PackageSpec
imageName string
buildDir string
beatDir string
}
func newDockerBuilder(spec PackageSpec) (*dockerBuilder, error) {
buildDir := filepath.Join(spec.packageDir, "docker-build")
beatDir := filepath.Join(buildDir, "beat")
return &dockerBuilder{
PackageSpec: spec,
imageName: spec.ImageName(),
buildDir: buildDir,
beatDir: beatDir,
}, nil
}
func (b *dockerBuilder) Build() error {
if err := os.RemoveAll(b.buildDir); err != nil {
return fmt.Errorf("failed to clean existing build directory %s: %w", b.buildDir, err)
}
if err := b.copyFiles(); err != nil {
return fmt.Errorf("error copying files for docker variant %q: %w", b.DockerVariant, err)
}
if err := b.prepareBuild(); err != nil {
return fmt.Errorf("failed to prepare build: %w", err)
}
tag, additionalTags, err := b.dockerBuild()
tries := 3
for err != nil && tries != 0 {
fmt.Println(">> Building docker images again (after 10 s)")
// This sleep is to avoid hitting the docker build issues when resources are not available.
time.Sleep(time.Second * 10)
tag, additionalTags, err = b.dockerBuild()
tries--
}
if err != nil {
return fmt.Errorf("failed to build docker: %w", err)
}
if err := b.dockerSave(tag); err != nil {
return fmt.Errorf("failed to save docker as artifact: %w", err)
}
// additional tags should not be created with
for _, tag := range additionalTags {
if err := b.dockerSave(tag, map[string]interface{}{
// effectively override the name used from b.ImageName() to the tag
"Name": strings.ReplaceAll(tag, ":", "-"),
}); err != nil {
return fmt.Errorf("failed to save docker with tag %s as artifact: %w", tag, err)
}
}
return nil
}
func (b *dockerBuilder) modulesDirs() []string {
var modulesd []string
for _, f := range b.Files {
if f.Modules {
modulesd = append(modulesd, f.Target)
}
}
return modulesd
}
func (b *dockerBuilder) exposePorts() []string {
if ports := b.ExtraVars["expose_ports"]; ports != "" {
return strings.Split(ports, ",")
}
return nil
}
func (b *dockerBuilder) copyFiles() error {
for _, f := range b.Files {
source := f.Source
var checkFn func(string) bool
target := filepath.Join(b.beatDir, f.Target)
if f.ExpandSpec {
specFilename := filepath.Base(source)
specContent, err := os.ReadFile(source)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("failed reading spec file for component %q: %w", specFilename, err)
}
// create filter
allowedPaths, err := component.ParseComponentFiles(specContent, specFilename, true)
if err != nil {
return fmt.Errorf("failed computing component files %q: %w", specFilename, err)
}
checkFn, err = install.SkipComponentsPathWithSubpathsFn(allowedPaths)
if err != nil {
return fmt.Errorf("failed compiling skip fn %q: %w", specFilename, err)
}
source = filepath.Dir(source) // change source to components directory
target = filepath.Dir(target) // target pointing to spec file
}
if err := CopyWithCheck(source, target, checkFn); err != nil {
if f.SkipOnMissing && errors.Is(err, os.ErrNotExist) {
continue
}
return fmt.Errorf("failed to copy from %s to %s: %w", f.Source, target, err)
}
}
return nil
}
func (b *dockerBuilder) prepareBuild() error {
elasticBeatsDir, err := ElasticBeatsDir()
if err != nil {
return err
}
templatesDir := filepath.Join(elasticBeatsDir, "dev-tools/packaging/templates/docker")
data := map[string]interface{}{
"ExposePorts": b.exposePorts(),
"ModulesDirs": b.modulesDirs(),
"Variant": b.DockerVariant.String(),
}
err = filepath.WalkDir(templatesDir, func(path string, d fs.DirEntry, _ error) error {
if !d.Type().IsDir() && !isDockerFile(path) {
target := strings.TrimSuffix(
filepath.Join(b.buildDir, filepath.Base(path)),
".tmpl",
)
err = b.ExpandFile(path, target, data)
if err != nil {
return fmt.Errorf("expanding template '%s' to '%s': %w", path, target, err)
}
}
return nil
})
if err != nil {
return err
}
return b.expandDockerfile(templatesDir, data)
}
func isDockerFile(path string) bool {
path = filepath.Base(path)
return strings.HasPrefix(path, "Dockerfile") || strings.HasPrefix(path, "docker-entrypoint")
}
func (b *dockerBuilder) expandDockerfile(templatesDir string, data map[string]interface{}) error {
dockerfile := "Dockerfile.tmpl"
if f, found := b.ExtraVars["dockerfile"]; found {
dockerfile = f
}
entrypoint := "docker-entrypoint.tmpl"
if e, found := b.ExtraVars["docker_entrypoint"]; found {
entrypoint = e
}
type fileExpansion struct {
source string
target string
}
for _, file := range []fileExpansion{{dockerfile, "Dockerfile.tmpl"}, {entrypoint, "docker-entrypoint.tmpl"}} {
target := strings.TrimSuffix(
filepath.Join(b.buildDir, file.target),
".tmpl",
)
path := filepath.Join(templatesDir, file.source)
err := b.ExpandFile(path, target, data)
if err != nil {
return fmt.Errorf("expanding template '%s' to '%s': %w", path, target, err)
}
}
return nil
}
// dockerBuild runs "docker build -t t1 -t t2 ... buildDir"
// returns the main tag additional tags if specified as part of extra_tags property
// the extra tags are not push to the registry from b.ExtraVars["repository"]
// returns an error if the command fails
func (b *dockerBuilder) dockerBuild() (string, []string, error) {
mainTag := fmt.Sprintf("%s:%s", b.imageName, b.Version)
// For Independent Agent releases, replace the "+" with a "." since the "+" character
// currently isn't allowed in a tag in Docker
// E.g., 8.13.0+build202402191057 -> 8.13.0.build202402191057
mainTag = strings.Replace(mainTag, "+", ".", 1)
if b.Snapshot {
mainTag = mainTag + "-SNAPSHOT"
}
if repository := b.ExtraVars["repository"]; repository != "" {
mainTag = fmt.Sprintf("%s/%s", repository, mainTag)
}
args := []string{
"build",
"-t", mainTag,
}
extraTags := []string{}
for _, tag := range b.ExtraTags {
extraTags = append(extraTags, fmt.Sprintf("%s:%s", b.imageName, tag))
}
for _, t := range extraTags {
args = append(args, "-t", t)
}
args = append(args, b.buildDir)
return mainTag, extraTags, sh.Run("docker", args...)
}
func (b *dockerBuilder) dockerSave(tag string, templateExtraArgs ...map[string]interface{}) error {
if _, err := os.Stat(distributionsDir); os.IsNotExist(err) {
err := os.MkdirAll(distributionsDir, 0750)
if err != nil {
return fmt.Errorf("cannot create folder for docker artifacts: %w", err)
}
}
// Save the container as artifact
outputFile := b.OutputFile
if outputFile == "" {
args := map[string]interface{}{
"Name": b.imageName,
}
for _, extraArgs := range templateExtraArgs {
maps.Copy(args, extraArgs)
}
outputTar, err := b.Expand(defaultBinaryName+".docker.tar.gz", args)
if err != nil {
return err
}
outputFile = filepath.Join(distributionsDir, outputTar)
}
var stderr bytes.Buffer
cmd := exec.Command("docker", "save", tag)
cmd.Stderr = &stderr
stdout, err := cmd.StdoutPipe()
if err != nil {
return err
}
if err = cmd.Start(); err != nil {
return err
}
err = func() error {
f, err := os.Create(outputFile)
if err != nil {
return err
}
defer f.Close()
w := gzip.NewWriter(f)
defer w.Close()
_, err = io.Copy(w, stdout)
if err != nil {
return err
}
return nil
}()
if err != nil {
return err
}
if err = cmd.Wait(); err != nil {
if errmsg := strings.TrimSpace(stderr.String()); errmsg != "" {
err = fmt.Errorf("%w: %s", errors.New(errmsg), err.Error())
}
return err
}
err = CreateSHA512File(outputFile)
if err != nil {
return fmt.Errorf("failed to create .sha512 file: %w", err)
}
return nil
}