Skip to content

Commit

Permalink
feat: s2i Go support (#2203)
Browse files Browse the repository at this point in the history
  • Loading branch information
lkingland authored May 21, 2024
1 parent e6fa020 commit b0418f9
Show file tree
Hide file tree
Showing 6 changed files with 213 additions and 63 deletions.
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
// 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}
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
Loading

0 comments on commit b0418f9

Please sign in to comment.