func/pkg/builders/s2i/builder.go

284 lines
8.7 KiB
Go

package s2i
import (
"context"
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"strings"
dockerClient "github.com/docker/docker/client"
"github.com/openshift/source-to-image/pkg/api"
"github.com/openshift/source-to-image/pkg/api/validation"
"github.com/openshift/source-to-image/pkg/build"
"github.com/openshift/source-to-image/pkg/build/strategies"
s2idocker "github.com/openshift/source-to-image/pkg/docker"
"github.com/openshift/source-to-image/pkg/scm/git"
"knative.dev/func/pkg/builders"
"knative.dev/func/pkg/docker"
fn "knative.dev/func/pkg/functions"
"knative.dev/func/pkg/scaffolding"
)
// DefaultName when no WithName option is provided to NewBuilder
const DefaultName = builders.S2I
var DefaultNodeBuilder = "registry.access.redhat.com/ubi8/nodejs-20-minimal"
var DefaultQuarkusBuilder = "registry.access.redhat.com/ubi8/openjdk-21"
var DefaultPythonBuilder = "registry.access.redhat.com/ubi8/python-39"
var DefaultGoBuilder = "registry.access.redhat.com/ubi8/go-toolset"
// DefaultBuilderImages for s2i builders indexed by Runtime Language
var DefaultBuilderImages = map[string]string{
"go": DefaultGoBuilder,
"node": DefaultNodeBuilder,
"nodejs": DefaultNodeBuilder,
"python": DefaultPythonBuilder,
"quarkus": DefaultQuarkusBuilder,
"typescript": DefaultNodeBuilder,
}
// Builder of functions using the s2i subsystem.
type Builder struct {
name string
verbose bool
impl build.Builder // S2I builder implementation (aka "Strategy")
cli s2idocker.Client
}
type Option func(*Builder)
func WithName(n string) Option {
return func(b *Builder) {
b.name = n
}
}
// WithVerbose toggles verbose logging.
func WithVerbose(v bool) Option {
return func(b *Builder) {
b.verbose = v
}
}
// WithImpl sets an optional S2I Builder implementation override to use in
// place of what will be generated by the S2I build "strategy" system based
// on the config. Used for mocking the implementation during tests.
func WithImpl(s build.Builder) Option {
return func(b *Builder) {
b.impl = s
}
}
func WithDockerClient(cli s2idocker.Client) Option {
return func(b *Builder) {
b.cli = cli
}
}
// NewBuilder creates a new instance of a Builder with static defaults.
func NewBuilder(options ...Option) *Builder {
b := &Builder{name: DefaultName}
for _, o := range options {
o(b)
}
return b
}
// Build the function using the S2I builder.
//
// Platforms:
// The S2I builder supports at most a single platform to target, and the
// platform specified must be available in the provided builder image.
// If the provided builder image is not a multi-architecture image index
// container, specifying a target platform is redundant, so if provided it
// must match that of the single-architecture container or the request is
// invalid.
func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platform) (err error) {
// Builder image from the function if defined, default otherwise.
builderImage, err := BuilderImage(f, b.name)
if err != nil {
return
}
// Validate Platforms
if len(platforms) == 1 {
platform := strings.ToLower(platforms[0].OS + "/" + platforms[0].Architecture)
// Try to get the platform image from within the builder image
// Will also succeed if the builder image is a single-architecture image
// and the requested platform matches.
if builderImage, err = docker.GetPlatformImage(builderImage, platform); err != nil {
return fmt.Errorf("cannot get platform image reference for %q: %w", platform, err)
}
} else if len(platforms) > 1 {
// Only a single requestd platform supported.
return errors.New("the S2I builder currently only supports specifying a single target platform")
}
var client = b.cli
if client == nil {
var c dockerClient.CommonAPIClient
c, _, err = docker.NewClient(dockerClient.DefaultDockerHost)
if err != nil {
return fmt.Errorf("cannot create docker client: %w", err)
}
defer c.Close()
client = c
}
// Link .s2iignore -> .funcignore
funcignorePath := filepath.Join(f.Root, ".funcignore")
s2iignorePath := filepath.Join(f.Root, ".s2iignore")
if _, err := os.Stat(funcignorePath); err == nil {
if _, err := os.Stat(s2iignorePath); err == nil {
fmt.Fprintln(os.Stderr, "Warning: an existing .s2iignore was detected. Using this with preference over .funcignore")
} else {
if err = os.Symlink("./.funcignore", s2iignorePath); err != nil {
return err
}
defer os.Remove(s2iignorePath)
}
}
// Build Config
cfg := &api.Config{
Source: &git.URL{
Type: git.URLTypeLocal,
URL: url.URL{Path: f.Root},
},
Quiet: !b.verbose,
Tag: f.Build.Image,
BuilderImage: builderImage,
BuilderPullPolicy: api.DefaultBuilderPullPolicy,
PreviousImagePullPolicy: api.DefaultPreviousImagePullPolicy,
RuntimeImagePullPolicy: api.DefaultRuntimeImagePullPolicy,
DockerConfig: s2idocker.GetDefaultDockerConfig(),
}
// Scaffold
if cfg, err = scaffold(cfg, f); err != nil {
return
}
// Excludes
// Do not include .git, .env, .func or any language-specific cache directories
// (node_modules, etc) in the tar file sent to the builder, as this both
// bloats the build process and can cause unexpected errors in the resultant
// function.
cfg.ExcludeRegExp = "(^|/)\\.git|\\.env|\\.func|node_modules(/|$)"
// Environment variables
// Build Envs have local env var references interpolated then added to the
// config as an S2I EnvironmentList struct
buildEnvs, err := fn.Interpolate(f.Build.BuildEnvs)
if err != nil {
return err
}
buildEnvs["LISTEN_ADDRESS"] = "0.0.0.0:8080"
for k, v := range buildEnvs {
cfg.Environment = append(cfg.Environment, api.EnvironmentSpec{Name: k, Value: v})
}
for _, m := range f.Build.Mounts {
cfg.BuildVolumes = append(cfg.BuildVolumes, fmt.Sprintf("%s:%s:ro,Z", m.Source, m.Destination))
}
if runtime.GOOS == "linux" {
cfg.DockerNetworkMode = "host"
}
// Validate the config
if errs := validation.ValidateConfig(cfg); len(errs) > 0 {
for _, e := range errs {
fmt.Fprintf(os.Stderr, "ERROR: %s\n", e)
}
return errors.New("Unable to build via the s2i builder.")
}
// Create the S2I builder instance if not overridden
var impl = b.impl
if impl == nil {
impl, _, err = strategies.Strategy(client, cfg, build.Overrides{})
if err != nil {
return fmt.Errorf("cannot create s2i builder: %w", err)
}
}
// Perform the build
result, err := impl.Build(cfg)
if err != nil {
return
}
if b.verbose {
for _, message := range result.Messages {
fmt.Fprintln(os.Stderr, message)
}
}
return nil
}
// Builder Image chooses the correct builder image or defaults.
func BuilderImage(f fn.Function, builderName string) (string, error) {
// delegate as the logic is shared amongst builders
return builders.Image(f, builderName, DefaultBuilderImages)
}
// scaffold the project
// Returns a config with settings suitable for building runtimes which
// support scaffolding.
func scaffold(cfg *api.Config, f fn.Function) (*api.Config, error) {
// Scafffolding is currently only supported by the Go and Python runtimes
if f.Runtime != "go" && f.Runtime != "python" {
return cfg, nil
}
contextDir := filepath.Join(".s2i", "builds", "last")
appRoot := filepath.Join(f.Root, contextDir)
_ = os.RemoveAll(appRoot)
// The enbedded repository contains the scaffolding code itself which glues
// together the middleware and a function via main
embeddedRepo, err := fn.NewRepository("", "") // default is the embedded fs
if err != nil {
return cfg, fmt.Errorf("unable to load the embedded scaffolding. %w", err)
}
// Write scaffolding to .s2i/builds/last
err = scaffolding.Write(appRoot, f.Root, f.Runtime, f.Invoke, embeddedRepo.FS())
if err != nil {
return cfg, fmt.Errorf("unable to build due to a scaffold error. %w", err)
}
// Write out an S2I assembler script if the runtime needs to override the
// one provided in the S2I image.
assemble, err := assembler(f)
if err != nil {
return cfg, err
}
if assemble != "" {
if err := os.MkdirAll(filepath.Join(f.Root, ".s2i", "bin"), 0755); err != nil {
return nil, fmt.Errorf("unable to create .s2i bin dir. %w", err)
}
if err := os.WriteFile(filepath.Join(f.Root, ".s2i", "bin", "assemble"), []byte(assemble), 0700); err != nil {
return nil, fmt.Errorf("unable to write go assembler. %w", err)
}
}
cfg.KeepSymlinks = true // Don't infinite loop on the symlink to root.
// We want to force that the system use the (copy via filesystem)
// method rather than a "git clone" method because (other than being
// faster) appears to have a bug where the assemble script is ignored.
// Maybe this issue is related:
// https://github.com/openshift/source-to-image/issues/1141
cfg.ForceCopy = true
return cfg, nil
}