feat: pack builder images individually configurable (#1028)

This commit is contained in:
Luke Kingland 2022-06-04 05:31:52 +09:00 committed by GitHub
parent ab12aa7029
commit 17dc507c25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 319 additions and 122 deletions

View File

@ -19,125 +19,197 @@ import (
"github.com/buildpacks/pack/pkg/logging"
)
// DefaultBuilderImages for Pack builders indexed by Runtime Language
var DefaultBuilderImages = map[string]string{
"node": "gcr.io/paketo-buildpacks/builder:base",
"typescript": "gcr.io/paketo-buildpacks/builder:base",
"go": "gcr.io/paketo-buildpacks/builder:base",
"python": "gcr.io/paketo-buildpacks/builder:base",
"quarkus": "gcr.io/paketo-buildpacks/builder:base",
"rust": "gcr.io/paketo-buildpacks/builder:base",
"springboot": "gcr.io/paketo-buildpacks/builder:base",
}
var (
DefaultBuilderImages = map[string]string{
"node": "gcr.io/paketo-buildpacks/builder:base",
"typescript": "gcr.io/paketo-buildpacks/builder:base",
"go": "gcr.io/paketo-buildpacks/builder:base",
"python": "gcr.io/paketo-buildpacks/builder:base",
"quarkus": "gcr.io/paketo-buildpacks/builder:base",
"rust": "gcr.io/paketo-buildpacks/builder:base",
"springboot": "gcr.io/paketo-buildpacks/builder:base",
}
//Builder holds the configuration that will be passed to
//Buildpack builder
trustedBuilderImagePrefixes = []string{
"quay.io/boson",
"gcr.io/paketo-buildpacks",
"docker.io/paketobuildpacks",
}
v330 = semver.MustParse("v3.3.0") // for checking podman version
)
// Builder will build Function using Pack.
type Builder struct {
verbose bool
logger io.Writer
impl Impl
}
//NewBuilder builds the new Builder configuration
func NewBuilder(verbose bool) *Builder {
return &Builder{verbose: verbose}
// Impl allows for the underlying implementation to be mocked for tests.
type Impl interface {
Build(context.Context, pack.BuildOptions) error
}
var v330 = semver.MustParse("v3.3.0")
// NewBuilder instantiates a Buildpack-based Builder
func NewBuilder(options ...Option) *Builder {
b := &Builder{}
for _, o := range options {
o(b)
}
// Stream logs to stdout or buffer only for display on error.
if b.verbose {
b.logger = stdoutWrapper{os.Stdout}
} else {
b.logger = &bytes.Buffer{}
}
return b
}
type Option func(*Builder)
func WithVerbose(v bool) Option {
return func(b *Builder) {
b.verbose = v
}
}
func WithImpl(i Impl) Option {
return func(b *Builder) {
b.impl = i
}
}
// Build the Function at path.
func (builder *Builder) Build(ctx context.Context, f fn.Function) (err error) {
// Use the builder found in the Function configuration file if it exists,
// or a default for the language if not provided
packBuilder := BuilderImage(f)
if packBuilder == "" {
return fmt.Errorf("builder image not found for function of language '%v'", f.Runtime)
}
// Build options for the pack client.
var network string
if runtime.GOOS == "linux" {
network = "host"
}
// log output is either STDOUt or kept in a buffer to be printed on error.
var logWriter io.Writer
if builder.verbose {
// pass stdout as non-closeable writer
// otherwise pack client would close it which is bad
logWriter = stdoutWrapper{os.Stdout}
} else {
logWriter = &bytes.Buffer{}
}
cli, dockerHost, err := docker.NewClient(client.DefaultDockerHost)
if err != nil {
return err
}
defer cli.Close()
version, err := cli.ServerVersion(ctx)
if err != nil {
return err
}
var daemonIsPodmanBeforeV330 bool
for _, component := range version.Components {
if component.Name == "Podman Engine" {
v := semver.MustParse(version.Version)
if v.Compare(v330) < 0 {
daemonIsPodmanBeforeV330 = true
}
break
}
}
buildEnvs, err := fn.Interpolate(f.BuildEnvs)
if err != nil {
return err
}
var isTrustedBuilderFunc = func(b string) bool {
return !daemonIsPodmanBeforeV330 &&
(strings.HasPrefix(packBuilder, "quay.io/boson") ||
strings.HasPrefix(packBuilder, "gcr.io/paketo-buildpacks") ||
strings.HasPrefix(packBuilder, "docker.io/paketobuildpacks"))
}
packOpts := pack.BuildOptions{
AppPath: f.Root,
Image: f.Image,
LifecycleImage: "quay.io/boson/lifecycle:0.13.2",
Builder: packBuilder,
Env: buildEnvs,
Buildpacks: f.Buildpacks,
TrustBuilder: isTrustedBuilderFunc,
DockerHost: dockerHost,
ContainerConfig: struct {
Network string
Volumes []string
}{Network: network, Volumes: nil},
}
// Client with a logger which is enabled if in Verbose mode and a dockerClient that supports SSH docker daemon connection.
packClient, err := pack.NewClient(pack.WithLogger(logging.NewSimpleLogger(logWriter)), pack.WithDockerClient(cli))
func (b *Builder) Build(ctx context.Context, f fn.Function) (err error) {
// Builder image defined on the Function if set, or from the default map.
image, err := BuilderImage(f)
if err != nil {
return
}
// Build based using the given builder.
if err = packClient.Build(ctx, packOpts); err != nil {
if ctx.Err() != nil {
// received SIGINT
// Pack build options
opts := pack.BuildOptions{
AppPath: f.Root,
Image: f.Image,
LifecycleImage: "quay.io/boson/lifecycle:0.13.2",
Builder: image,
Buildpacks: f.Buildpacks,
ContainerConfig: struct {
Network string
Volumes []string
}{Network: "", Volumes: nil},
}
if opts.Env, err = fn.Interpolate(f.BuildEnvs); err != nil {
return err
}
if runtime.GOOS == "linux" {
opts.ContainerConfig.Network = "host"
}
// Instantate the pack build client implementation
// (and update build opts as necessary)
if b.impl == nil {
if b.impl, err = newImpl(ctx, &opts, b.logger); err != nil {
return
} else if !builder.verbose {
// If the builder was not showing logs, embed the full logs in the error.
err = fmt.Errorf("failed to build the function (output: %q): %w", logWriter.(*bytes.Buffer).String(), err)
}
}
// Perform the build
if err = b.impl.Build(ctx, opts); err != nil {
if ctx.Err() != nil {
return // SIGINT
} else if !b.verbose {
err = fmt.Errorf("failed to build the function (output: %q): %w", b.logger.(*bytes.Buffer).String(), err)
}
}
return
}
// hack this makes stdout non-closeable
// newImpl returns an instance of the builder implementatoin. Note that this
// also mutates the provided options' DockerHost and TrustBuilder.
func newImpl(ctx context.Context, opts *pack.BuildOptions, logger io.Writer) (impl Impl, err error) {
cli, dockerHost, err := docker.NewClient(client.DefaultDockerHost)
if err != nil {
return
}
defer cli.Close()
opts.DockerHost = dockerHost
daemonIsPodmanPreV330, err := podmanPreV330(ctx, cli)
if err != nil {
return
}
opts.TrustBuilder = func(_ string) bool {
if daemonIsPodmanPreV330 {
return false
}
for _, v := range trustedBuilderImagePrefixes {
if strings.HasPrefix(opts.Builder, v) {
return true
}
}
return false
}
// Client with a logger which is enabled if in Verbose mode and a dockerClient that supports SSH docker daemon connection.
return pack.NewClient(pack.WithLogger(logging.NewSimpleLogger(logger)), pack.WithDockerClient(cli))
}
// Builder Image
//
// A value defined on the Function itself takes precidence. If not defined,
// the default builder image for the Function's language runtime is used.
// An inability to determine a builder image (such as an unknown language),
// will return empty string. Errors are returned if either the runtime is not
// populated or an inability to locate a default.
//
// Exported for use by Tekton in-cluster builds which do not have access to this
// library at this time, and can therefore not instantiate and invoke this
// package's buildpacks.Builder.Build. Instead, they must transmit information
// to the cluster using a Pipeline definition.
func BuilderImage(f fn.Function) (string, error) {
if f.Runtime == "" {
return "", ErrRuntimeRequired{}
}
v, ok := f.BuilderImages["pack"]
if ok {
return v, nil
}
v, ok = DefaultBuilderImages[f.Runtime]
if ok {
return v, nil
}
return "", ErrRuntimeNotSupported{f.Runtime}
}
// podmanPreV330 returns if the daemon is podman pre v330 or errors trying.
func podmanPreV330(ctx context.Context, cli client.CommonAPIClient) (b bool, err error) {
version, err := cli.ServerVersion(ctx)
if err != nil {
return
}
for _, component := range version.Components {
if component.Name == "Podman Engine" {
v := semver.MustParse(version.Version)
if v.Compare(v330) < 0 {
return true, nil
}
break
}
}
return
}
// stdoutWrapper is a hack that makes stdout non-closeable
type stdoutWrapper struct {
impl io.Writer
}
@ -146,23 +218,18 @@ func (s stdoutWrapper) Write(p []byte) (n int, err error) {
return s.impl.Write(p)
}
// Builder Image for a Function being built using Buildpack.
//
// A value defined on the Function itself takes precidence. If not defined,
// the default builder image for the Function's language runtime is used.
// An inability to determine a builder image (such as an unknown language),
// will return empty string.
//
// Exported for use by Tekton in-cluster builds which do not have access to this
// library at this time, and can therefore not instantiate and invoke this
// package's buildpacks.Builder.Build. Instead, they must transmit information
// to the cluster using a Pipeline definition.
func BuilderImage(f fn.Function) (builder string) {
// NOTE this will be updated when func.yaml is expanded to support
// differing builder images for different build strategies (buildpac vs s2i)
if f.Builder != "" {
return f.Builder
}
builder = DefaultBuilderImages[f.Runtime]
return
// Errors
type ErrRuntimeRequired struct{}
func (e ErrRuntimeRequired) Error() string {
return "Pack requires the Function define a language runtime"
}
type ErrRuntimeNotSupported struct {
Runtime string
}
func (e ErrRuntimeNotSupported) Error() string {
return fmt.Sprintf("Pack builder has no default builder image for the '%v' language runtime. Please provide one.", e.Runtime)
}

121
buildpacks/builder_test.go Normal file
View File

@ -0,0 +1,121 @@
package buildpacks
import (
"context"
"errors"
"testing"
pack "github.com/buildpacks/pack/pkg/client"
fn "knative.dev/kn-plugin-func"
. "knative.dev/kn-plugin-func/testing"
)
// Test_ErrRuntimeRequired ensures that a request to build without a runtime
// defined for the Function yields an ErrRuntimeRequired
func Test_ErrRuntimeRequired(t *testing.T) {
b := NewBuilder()
err := b.Build(context.Background(), fn.Function{})
if !errors.As(err, &ErrRuntimeRequired{}) {
t.Fatalf("expected ErrRuntimeRequired not received. Got %v", err)
}
}
// Test_ErrRuntimeNotSupported ensures that a request to build a function whose
// runtime is not yet supported yields an ErrRuntimeNotSupported
func Test_ErrRuntimeNotSupported(t *testing.T) {
b := NewBuilder()
err := b.Build(context.Background(), fn.Function{Runtime: "unsupported"})
if !errors.As(err, &ErrRuntimeNotSupported{}) {
t.Fatalf("expected ErrRuntimeNotSupported not received. got %v", err)
}
}
// Test_ImageDefault ensures that a Function bing built which does not
// define a Builder Image will get the internally-defined default.
func Test_ImageDefault(t *testing.T) {
var (
i = &mockImpl{}
b = NewBuilder(WithImpl(i))
f = fn.Function{Runtime: "node"}
)
i.BuildFn = func(ctx context.Context, opts pack.BuildOptions) error {
expected := DefaultBuilderImages["node"]
if opts.Builder != expected {
t.Fatalf("expected pack builder image '%v', got '%v'", expected, opts.Builder)
}
return nil
}
if err := b.Build(context.Background(), f); err != nil {
t.Fatal(err)
}
}
// Test_BuilderImageConfigurable ensures that the builder will use the builder
// image defined on the given Function if provided.
func Test_BuilderImageConfigurable(t *testing.T) {
var (
i = &mockImpl{} // mock underlying implementation
b = NewBuilder(WithImpl(i)) // Func Builder logic
f = fn.Function{ // Function with a builder image set
Runtime: "node",
BuilderImages: map[string]string{
"pack": "example.com/user/builder-image",
},
}
)
i.BuildFn = func(ctx context.Context, opts pack.BuildOptions) error {
expected := f.BuilderImages["pack"]
if opts.Builder != expected {
t.Fatalf("expected builder image for node to be '%v', got '%v'", expected, opts.Builder)
}
return nil
}
if err := b.Build(context.Background(), f); err != nil {
t.Fatal(err)
}
}
// Test_BuildEnvs ensures that build environment variables are interpolated and
// provided in Build Options
func Test_BuildEnvs(t *testing.T) {
defer WithEnvVar(t, "INTERPOLATE_ME", "interpolated")()
var (
envName = "NAME"
envValue = "{{ env:INTERPOLATE_ME }}"
f = fn.Function{
Runtime: "node",
BuildEnvs: []fn.Env{{Name: &envName, Value: &envValue}},
}
i = &mockImpl{}
b = NewBuilder(WithImpl(i))
)
i.BuildFn = func(ctx context.Context, opts pack.BuildOptions) error {
for k, v := range opts.Env {
if k == envName && v == "interpolated" {
return nil // success!
} else if k == envName && v == envValue {
t.Fatal("build env was not interpolated")
}
}
t.Fatal("build envs not added to builder options")
return nil
}
if err := b.Build(context.Background(), f); err != nil {
t.Fatal(err)
}
}
type mockImpl struct {
BuildFn func(context.Context, pack.BuildOptions) error
}
func (i mockImpl) Build(ctx context.Context, opts pack.BuildOptions) error {
return i.BuildFn(ctx, opts)
}

View File

@ -203,7 +203,7 @@ func TestRemoteRepositories(t *testing.T) {
// newClient creates an instance of the func client whose concrete impls
// match those created by the kn func plugin CLI.
func newClient(verbose bool) *fn.Client {
builder := buildpacks.NewBuilder(verbose)
builder := buildpacks.NewBuilder(buildpacks.WithVerbose(verbose))
pusher := docker.NewPusher(docker.WithVerbose(verbose))
deployer := knative.NewDeployer(DefaultNamespace, verbose)
remover := knative.NewRemover(DefaultNamespace, verbose)

View File

@ -158,7 +158,7 @@ func runBuild(cmd *cobra.Command, _ []string, newClient ClientFactory) (err erro
// Choose a builder based on the value of the --builder flag
var builder fn.Builder
if config.Builder == "pack" {
builder = buildpacks.NewBuilder(config.Verbose)
builder = buildpacks.NewBuilder(buildpacks.WithVerbose(config.Verbose))
} else if config.Builder == "s2i" {
builder = s2i.NewBuilder(s2i.WithVerbose(config.Verbose))
} else {

View File

@ -68,7 +68,7 @@ func NewClient(cfg ClientConfig, options ...fn.Option) (*fn.Client, func()) {
fn.WithVerbose(cfg.Verbose),
fn.WithProgressListener(p),
fn.WithTransport(t),
fn.WithBuilder(buildpacks.NewBuilder(cfg.Verbose)),
fn.WithBuilder(buildpacks.NewBuilder(buildpacks.WithVerbose(cfg.Verbose))),
fn.WithRemover(knative.NewRemover(cfg.Namespace, cfg.Verbose)),
fn.WithDescriber(knative.NewDescriber(cfg.Namespace, cfg.Verbose)),
fn.WithLister(knative.NewLister(cfg.Namespace, cfg.Verbose)),

View File

@ -125,7 +125,7 @@ func generatePipelineRun(f fn.Function, labels map[string]string) *pplnv1beta1.P
},
{
Name: "builderImage",
Value: *pplnv1beta1.NewArrayOrString(buildpacks.BuilderImage(f)),
Value: *pplnv1beta1.NewArrayOrString(getBuilderImage(f)),
},
},
@ -155,6 +155,15 @@ func generatePipelineRun(f fn.Function, labels map[string]string) *pplnv1beta1.P
}
}
// guilderImage returns the builder image to use when building the Function
// with the Pack strategy if it can be calculated (the Function has a defined
// language runtime. Errors are checked elsewhere, so at this level they
// manifest as an inability to get a builder image = empty string.
func getBuilderImage(f fn.Function) (name string) {
name, _ = buildpacks.BuilderImage(f)
return
}
func getPipelineName(f fn.Function) string {
return fmt.Sprintf("%s-%s-pipeline", f.Name, f.BuildType)
}