Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: s2i builder go support #2203

Merged
merged 1 commit into from
May 21, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
/target
/hack/bin

/e2e/testdata/default_home/go
/e2e/testdata/default_home/.cache

/pkg/functions/testdata/migrations/*/.gitignore

# Nodejs
node_modules

Expand Down
66 changes: 66 additions & 0 deletions pkg/builders/s2i/assemblers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package s2i

import (
"fmt"

fn "knative.dev/func/pkg/functions"
)

// GoAssembler
//
// Adapted from /usr/libexec/s2i/assemble within the UBI-8 go-toolchain
// such that the "go build" command builds subdirectory .s2i/builds/last
// (where main resides) rather than the root.
// TODO: many apps use the pattern of having main in a subdirectory, for
// example the idiomatic "./cmd/myapp/main.go". It would therefore be
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we raise a GH issue for this?

Copy link
Member Author

@lkingland lkingland Mar 13, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I think we should. Any idea where that repository is hosted? A cursory search found nothing for me

// beneficial to submit a patch to the go-toolchain source allowing this
// path to be customized with an environment variable instead
const GoAssembler = `
#!/bin/bash
set -e
pushd /tmp/src
if [[ $(go list -f {{.Incomplete}}) == "true" ]]; then
INSTALL_URL=${INSTALL_URL:-$IMPORT_URL}
lkingland marked this conversation as resolved.
Show resolved Hide resolved
if [[ ! -z "$IMPORT_URL" ]]; then
popd
echo "Assembling GOPATH"
export GOPATH=$(realpath $HOME/go)
mkdir -p $GOPATH/src/$IMPORT_URL
mv /tmp/src/* $GOPATH/src/$IMPORT_URL
if [[ -d /tmp/artifacts/pkg ]]; then
echo "Restoring previous build artifacts"
mv /tmp/artifacts/pkg $GOPATH
fi
# Resolve dependencies, ignore if vendor present
if [[ ! -d $GOPATH/src/$INSTALL_URL/vendor ]]; then
echo "Resolving dependencies"
pushd $GOPATH/src/$INSTALL_URL
go get
popd
fi
# lets build
pushd $GOPATH/src/$INSTALL_URL
echo "Building"
go install -i $INSTALL_URL
mv $GOPATH/bin/* /opt/app-root/gobinary
popd
exit
fi
exec /$STI_SCRIPTS_PATH/usage
else
pushd .s2i/builds/last
go get f
go build -o /opt/app-root/gobinary
popd
popd
fi
`

func assembler(f fn.Function) (string, error) {
switch f.Runtime {
case "go":
return GoAssembler, nil
default:
return "", fmt.Errorf("no assembler defined for runtime %q", f.Runtime)
}
}
157 changes: 111 additions & 46 deletions pkg/builders/s2i/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import (
"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
Expand All @@ -41,14 +42,16 @@ 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,
"typescript": DefaultNodeBuilder,
"quarkus": DefaultQuarkusBuilder,
"python": DefaultPythonBuilder,
"quarkus": DefaultQuarkusBuilder,
"typescript": DefaultNodeBuilder,
}

// DockerClient is subset of dockerClient.CommonAPIClient required by this package
Expand Down Expand Up @@ -120,7 +123,8 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf
if err != nil {
return
}
// If a platform was requestd

// 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
Expand All @@ -134,63 +138,71 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf
return errors.New("the S2I builder currently only supports specifying a single target platform")
}

// TODO this function currently doesn't support private s2i builder images since credentials are not set

// Build Config
cfg := &api.Config{}
cfg.Quiet = !b.verbose
cfg.Tag = f.Build.Image
cfg.Source = &git.URL{URL: url.URL{Path: f.Root}, Type: git.URLTypeLocal}
cfg.BuilderImage = builderImage
cfg.BuilderPullPolicy = api.DefaultBuilderPullPolicy
cfg.PreviousImagePullPolicy = api.DefaultPreviousImagePullPolicy
cfg.RuntimeImagePullPolicy = api.DefaultRuntimeImagePullPolicy
cfg.DockerConfig = s2idocker.GetDefaultDockerConfig()

tmp, err := os.MkdirTemp("", "s2i-build")
if err != nil {
return fmt.Errorf("cannot create temporary dir for s2i build: %w", err)
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
}
defer os.RemoveAll(tmp)

// Link .s2iignore -> .funcignore
funcignorePath := filepath.Join(f.Root, ".funcignore")
s2iignorePath := filepath.Join(f.Root, ".s2iignore")
if _, err := os.Stat(funcignorePath); err == nil {
s2iignorePath := filepath.Join(f.Root, ".s2iignore")

// If the .s2iignore file exists, remove it
if _, err := os.Stat(s2iignorePath); err == nil {
err := os.Remove(s2iignorePath)
if err != nil {
return fmt.Errorf("error removing existing s2iignore file: %w", err)
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)
}
// Create the symbolic link
err = os.Symlink(funcignorePath, s2iignorePath)
if err != nil {
return fmt.Errorf("error creating symlink: %w", err)
}
// Removing the symbolic link at the end of the function
defer os.Remove(s2iignorePath)
}

cfg.AsDockerfile = filepath.Join(tmp, "Dockerfile")
// 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)

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
// 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
}
cfg.ScriptsURL = scriptURL

// Excludes
// Do not include .git, .env, .func or any language-specific cache directories
Expand Down Expand Up @@ -218,8 +230,8 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf
return errors.New("Unable to build via the s2i builder.")
}

var impl = b.impl
// 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 {
Expand All @@ -235,7 +247,7 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf

if b.verbose {
for _, message := range result.Messages {
fmt.Println(message)
fmt.Fprintln(os.Stderr, message)
}
}

Expand Down Expand Up @@ -400,3 +412,56 @@ 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
}
40 changes: 27 additions & 13 deletions pkg/builders/s2i/builder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"knative.dev/func/pkg/builders"
"knative.dev/func/pkg/builders/s2i"
fn "knative.dev/func/pkg/functions"
. "knative.dev/func/pkg/testing"
)

// Test_BuildImages ensures that supported runtimes returns builder image
Expand Down Expand Up @@ -60,9 +61,9 @@ func Test_BuildImages(t *testing.T) {
wantErr: false,
},
{
name: "Without builder - unsupported runtime - go",
name: "Without builder - supported runtime - go",
function: fn.Function{Runtime: "go"},
wantErr: true,
wantErr: false,
},
{
name: "Without builder - supported runtime - python",
Expand Down Expand Up @@ -91,17 +92,30 @@ func Test_BuildImages(t *testing.T) {
// define a Builder Image will default.
func Test_BuilderImageDefault(t *testing.T) {
var (
i = &mockImpl{} // mock underlying s2i implementation
c = mockDocker{} // mock docker client
f = fn.Function{Runtime: "node"} // function with no builder image set
b = s2i.NewBuilder( // func S2I Builder logic
s2i.WithImpl(i), s2i.WithDockerClient(c))
root, done = Mktemp(t)
runtime = "go"
impl = &mockImpl{} // mock the underlying s2i implementation
f = fn.Function{
Name: "test",
Root: root,
Runtime: runtime,
Registry: "example.com/alice"} // function with no builder image set
builder = s2i.NewBuilder( // func S2I Builder logic
s2i.WithImpl(impl),
s2i.WithDockerClient(mockDocker{}))
err error
)
defer done()

// An implementation of the underlying S2I implementation which verifies
// Initialize the test function
if f, err = fn.New().Init(f); err != nil {
t.Fatal(err)
}

// An implementation of the underlying S2I builder which verifies
// the config has arrived as expected (correct functions logic applied)
i.BuildFn = func(cfg *api.Config) (*api.Result, error) {
expected := s2i.DefaultBuilderImages["node"]
impl.BuildFn = func(cfg *api.Config) (*api.Result, error) {
expected := s2i.DefaultBuilderImages[runtime]
if cfg.BuilderImage != expected {
t.Fatalf("expected s2i config builder image '%v', got '%v'",
expected, cfg.BuilderImage)
Expand All @@ -111,7 +125,7 @@ func Test_BuilderImageDefault(t *testing.T) {

// Invoke Build, which runs function Builder logic before invoking the
// mock impl above.
if err := b.Build(context.Background(), f, nil); err != nil {
if err := builder.Build(context.Background(), f, nil); err != nil {
t.Fatal(err)
}
}
Expand Down Expand Up @@ -151,8 +165,8 @@ func Test_BuilderImageConfigurable(t *testing.T) {
}
}

// Test_BuildImageWithFuncIgnore ensures that ignored files are not added to the func
// image
// Test_BuildImageWithFuncIgnore ensures that ignored files are not added to
// the func image
func Test_BuildImageWithFuncIgnore(t *testing.T) {

funcIgnoreContent := []byte(`#testing Comments
Expand Down