mirror of https://github.com/knative/func.git
468 lines
14 KiB
Go
468 lines
14 KiB
Go
package s2i
|
|
|
|
import (
|
|
"archive/tar"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/docker/docker/api/types"
|
|
dockerClient "github.com/docker/docker/client"
|
|
"github.com/docker/docker/pkg/jsonmessage"
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
v1 "github.com/google/go-containerregistry/pkg/v1"
|
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
|
"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"
|
|
"golang.org/x/exp/maps"
|
|
"golang.org/x/term"
|
|
|
|
"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/ubi9/nodejs-20-minimal"
|
|
var DefaultQuarkusBuilder = "registry.access.redhat.com/ubi9/openjdk-21"
|
|
var DefaultPythonBuilder = "registry.access.redhat.com/ubi9/python-39"
|
|
var DefaultGoBuilder = "registry.access.redhat.com/ubi9/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,
|
|
}
|
|
|
|
// DockerClient is subset of dockerClient.CommonAPIClient required by this package
|
|
type DockerClient interface {
|
|
ImageBuild(ctx context.Context, context io.Reader, options types.ImageBuildOptions) (types.ImageBuildResponse, error)
|
|
ImageInspectWithRaw(ctx context.Context, image string) (types.ImageInspect, []byte, error)
|
|
}
|
|
|
|
// Builder of functions using the s2i subsystem.
|
|
type Builder struct {
|
|
name string
|
|
verbose bool
|
|
impl build.Builder // S2I builder implementation (aka "Strategy")
|
|
cli DockerClient
|
|
}
|
|
|
|
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 DockerClient) 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 directory
|
|
tmp, err := os.MkdirTemp("", "func-s2i-build")
|
|
if err != nil {
|
|
return fmt.Errorf("cannot create temporary dir for s2i build: %w", err)
|
|
}
|
|
defer os.RemoveAll(tmp)
|
|
|
|
// 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(),
|
|
AsDockerfile: filepath.Join(tmp, "Dockerfile"),
|
|
}
|
|
|
|
// Scaffold
|
|
if cfg, err = scaffold(cfg, f); err != nil {
|
|
return
|
|
}
|
|
|
|
// Extract a an S2I script url from the image if provided and use
|
|
// this in the build config.
|
|
scriptURL, err := s2iScriptURL(ctx, client, cfg.BuilderImage)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get s2i script url: %w", err)
|
|
} else if scriptURL != "image:///usr/libexec/s2i" {
|
|
// Only set if the label found on the image is NOT the default.
|
|
// Otherwise this label, which is essentially a default fallback, will
|
|
// take precidence over any scripts provided in ./.s2i/bin, which are
|
|
// supposed to be the override to that default.
|
|
cfg.ScriptsURL = scriptURL
|
|
}
|
|
|
|
// 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
|
|
}
|
|
for k, v := range buildEnvs {
|
|
cfg.Environment = append(cfg.Environment, api.EnvironmentSpec{Name: k, Value: v})
|
|
}
|
|
|
|
// 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(nil, 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)
|
|
}
|
|
}
|
|
|
|
pr, pw := io.Pipe()
|
|
|
|
// s2i apparently is not excluding the files in --as-dockerfile mode
|
|
exclude := regexp.MustCompile(cfg.ExcludeRegExp)
|
|
|
|
const up = ".." + string(os.PathSeparator)
|
|
go func() {
|
|
tw := tar.NewWriter(pw)
|
|
err := filepath.Walk(tmp, func(path string, fi fs.FileInfo, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
p, err := filepath.Rel(tmp, path)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get relative path: %w", err)
|
|
}
|
|
if p == "." {
|
|
return nil
|
|
}
|
|
|
|
p = filepath.ToSlash(p)
|
|
|
|
if exclude.MatchString(p) {
|
|
return nil
|
|
}
|
|
|
|
lnk := ""
|
|
if fi.Mode()&fs.ModeSymlink != 0 {
|
|
lnk, err = os.Readlink(path)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot read link: %w", err)
|
|
}
|
|
if filepath.IsAbs(lnk) {
|
|
lnk, err = filepath.Rel(tmp, lnk)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot get relative path for symlink: %w", err)
|
|
}
|
|
if strings.HasPrefix(lnk, up) || lnk == ".." {
|
|
return fmt.Errorf("link %q points outside source root", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
hdr, err := tar.FileInfoHeader(fi, lnk)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot create tar header: %w", err)
|
|
}
|
|
hdr.Name = p
|
|
|
|
if runtime.GOOS == "windows" {
|
|
// Windows does not have execute permission, we assume that all files are executable.
|
|
hdr.Mode |= 0111
|
|
}
|
|
|
|
err = tw.WriteHeader(hdr)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot write header to thar stream: %w", err)
|
|
}
|
|
if fi.Mode().IsRegular() {
|
|
var r io.ReadCloser
|
|
r, err = os.Open(path)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot open source file: %w", err)
|
|
}
|
|
defer r.Close()
|
|
|
|
_, err = io.Copy(tw, r)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot copy file to tar stream :%w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
})
|
|
_ = tw.Close()
|
|
_ = pw.CloseWithError(err)
|
|
}()
|
|
|
|
opts := types.ImageBuildOptions{
|
|
Tags: []string{f.Build.Image},
|
|
PullParent: true,
|
|
}
|
|
|
|
resp, err := client.ImageBuild(ctx, pr, opts)
|
|
if err != nil {
|
|
return fmt.Errorf("cannot build the app image: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
var out io.Writer = io.Discard
|
|
if b.verbose {
|
|
out = os.Stderr
|
|
}
|
|
|
|
var isTerminal bool
|
|
var fd uintptr
|
|
if outF, ok := out.(*os.File); ok {
|
|
fd = outF.Fd()
|
|
isTerminal = term.IsTerminal(int(outF.Fd()))
|
|
}
|
|
|
|
return jsonmessage.DisplayJSONMessagesStream(resp.Body, out, fd, isTerminal, nil)
|
|
}
|
|
|
|
func s2iScriptURL(ctx context.Context, cli DockerClient, image string) (string, error) {
|
|
img, _, err := cli.ImageInspectWithRaw(ctx, image)
|
|
if err != nil {
|
|
if dockerClient.IsErrNotFound(err) { // image is not in the daemon, get info directly from registry
|
|
var (
|
|
ref name.Reference
|
|
img v1.Image
|
|
cfg *v1.ConfigFile
|
|
)
|
|
|
|
ref, err = name.ParseReference(image)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot parse image name: %w", err)
|
|
}
|
|
if _, ok := ref.(name.Tag); ok && !slices.Contains(maps.Values(DefaultBuilderImages), image) {
|
|
fmt.Fprintln(os.Stderr, "image referenced by tag which is discouraged: Tags are mutable and can point to a different artifact than the expected one")
|
|
}
|
|
img, err = remote.Image(ref)
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot get image from registry: %w", err)
|
|
}
|
|
cfg, err = img.ConfigFile()
|
|
if err != nil {
|
|
return "", fmt.Errorf("cannot get config for image: %w", err)
|
|
}
|
|
|
|
if cfg.Config.Labels != nil {
|
|
if u, ok := cfg.Config.Labels["io.openshift.s2i.scripts-url"]; ok {
|
|
return u, nil
|
|
}
|
|
}
|
|
}
|
|
return "", err
|
|
}
|
|
|
|
if img.Config != nil && img.Config.Labels != nil {
|
|
if u, ok := img.Config.Labels["io.openshift.s2i.scripts-url"]; ok {
|
|
return u, nil
|
|
}
|
|
}
|
|
|
|
//nolint:staticcheck
|
|
if img.ContainerConfig != nil && img.ContainerConfig.Labels != nil {
|
|
if u, ok := img.ContainerConfig.Labels["io.openshift.s2i.scripts-url"]; ok {
|
|
return u, nil
|
|
}
|
|
}
|
|
|
|
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 runtime
|
|
if f.Runtime != "go" {
|
|
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
|
|
}
|