feat: s2i Go support (#2203)

This commit is contained in:
Luke Kingland 2024-05-21 13:12:55 +00:00 committed by GitHub
parent e6fa020f78
commit b0418f95bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 214 additions and 64 deletions

5
.gitignore vendored
View File

@ -12,6 +12,11 @@
/target /target
/hack/bin /hack/bin
/e2e/testdata/default_home/go
/e2e/testdata/default_home/.cache
/pkg/functions/testdata/migrations/*/.gitignore
# Nodejs # Nodejs
node_modules node_modules

View File

@ -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)
}
}

View File

@ -33,6 +33,7 @@ import (
"knative.dev/func/pkg/builders" "knative.dev/func/pkg/builders"
"knative.dev/func/pkg/docker" "knative.dev/func/pkg/docker"
fn "knative.dev/func/pkg/functions" fn "knative.dev/func/pkg/functions"
"knative.dev/func/pkg/scaffolding"
) )
// DefaultName when no WithName option is provided to NewBuilder // DefaultName when no WithName option is provided to NewBuilder
@ -41,14 +42,16 @@ const DefaultName = builders.S2I
var DefaultNodeBuilder = "registry.access.redhat.com/ubi8/nodejs-20-minimal" var DefaultNodeBuilder = "registry.access.redhat.com/ubi8/nodejs-20-minimal"
var DefaultQuarkusBuilder = "registry.access.redhat.com/ubi8/openjdk-21" var DefaultQuarkusBuilder = "registry.access.redhat.com/ubi8/openjdk-21"
var DefaultPythonBuilder = "registry.access.redhat.com/ubi8/python-39" 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 // DefaultBuilderImages for s2i builders indexed by Runtime Language
var DefaultBuilderImages = map[string]string{ var DefaultBuilderImages = map[string]string{
"go": DefaultGoBuilder,
"node": DefaultNodeBuilder, "node": DefaultNodeBuilder,
"nodejs": DefaultNodeBuilder, "nodejs": DefaultNodeBuilder,
"typescript": DefaultNodeBuilder,
"quarkus": DefaultQuarkusBuilder,
"python": DefaultPythonBuilder, "python": DefaultPythonBuilder,
"quarkus": DefaultQuarkusBuilder,
"typescript": DefaultNodeBuilder,
} }
// DockerClient is subset of dockerClient.CommonAPIClient required by this package // DockerClient is subset of dockerClient.CommonAPIClient required by this package
@ -120,7 +123,8 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf
if err != nil { if err != nil {
return return
} }
// If a platform was requestd
// Validate Platforms
if len(platforms) == 1 { if len(platforms) == 1 {
platform := strings.ToLower(platforms[0].OS + "/" + platforms[0].Architecture) platform := strings.ToLower(platforms[0].OS + "/" + platforms[0].Architecture)
// Try to get the platform image from within the builder image // Try to get the platform image from within the builder image
@ -134,47 +138,6 @@ 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") 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)
}
defer os.RemoveAll(tmp)
funcignorePath := filepath.Join(f.Root, ".funcignore")
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)
}
}
// 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")
var client = b.cli var client = b.cli
if client == nil { if client == nil {
var c dockerClient.CommonAPIClient var c dockerClient.CommonAPIClient
@ -186,11 +149,60 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf
client = c 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) scriptURL, err := s2iScriptURL(ctx, client, cfg.BuilderImage)
if err != nil { if err != nil {
return fmt.Errorf("cannot get s2i script url: %w", err) 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 // Excludes
// Do not include .git, .env, .func or any language-specific cache directories // Do not include .git, .env, .func or any language-specific cache directories
@ -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.") return errors.New("Unable to build via the s2i builder.")
} }
var impl = b.impl
// Create the S2I builder instance if not overridden // Create the S2I builder instance if not overridden
var impl = b.impl
if impl == nil { if impl == nil {
impl, _, err = strategies.Strategy(nil, cfg, build.Overrides{}) impl, _, err = strategies.Strategy(nil, cfg, build.Overrides{})
if err != nil { if err != nil {
@ -235,7 +247,7 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf
if b.verbose { if b.verbose {
for _, message := range result.Messages { for _, message := range result.Messages {
fmt.Println(message) fmt.Fprintln(os.Stderr, message)
} }
} }
@ -400,3 +412,56 @@ func BuilderImage(f fn.Function, builderName string) (string, error) {
// delegate as the logic is shared amongst builders // delegate as the logic is shared amongst builders
return builders.Image(f, builderName, DefaultBuilderImages) 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
}

View File

@ -29,6 +29,7 @@ import (
"knative.dev/func/pkg/builders" "knative.dev/func/pkg/builders"
"knative.dev/func/pkg/builders/s2i" "knative.dev/func/pkg/builders/s2i"
fn "knative.dev/func/pkg/functions" fn "knative.dev/func/pkg/functions"
. "knative.dev/func/pkg/testing"
) )
// Test_BuildImages ensures that supported runtimes returns builder image // Test_BuildImages ensures that supported runtimes returns builder image
@ -60,9 +61,9 @@ func Test_BuildImages(t *testing.T) {
wantErr: false, wantErr: false,
}, },
{ {
name: "Without builder - unsupported runtime - go", name: "Without builder - supported runtime - go",
function: fn.Function{Runtime: "go"}, function: fn.Function{Runtime: "go"},
wantErr: true, wantErr: false,
}, },
{ {
name: "Without builder - supported runtime - python", name: "Without builder - supported runtime - python",
@ -91,17 +92,30 @@ func Test_BuildImages(t *testing.T) {
// define a Builder Image will default. // define a Builder Image will default.
func Test_BuilderImageDefault(t *testing.T) { func Test_BuilderImageDefault(t *testing.T) {
var ( var (
i = &mockImpl{} // mock underlying s2i implementation root, done = Mktemp(t)
c = mockDocker{} // mock docker client runtime = "go"
f = fn.Function{Runtime: "node"} // function with no builder image set impl = &mockImpl{} // mock the underlying s2i implementation
b = s2i.NewBuilder( // func S2I Builder logic f = fn.Function{
s2i.WithImpl(i), s2i.WithDockerClient(c)) 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) // the config has arrived as expected (correct functions logic applied)
i.BuildFn = func(cfg *api.Config) (*api.Result, error) { impl.BuildFn = func(cfg *api.Config) (*api.Result, error) {
expected := s2i.DefaultBuilderImages["node"] expected := s2i.DefaultBuilderImages[runtime]
if cfg.BuilderImage != expected { if cfg.BuilderImage != expected {
t.Fatalf("expected s2i config builder image '%v', got '%v'", t.Fatalf("expected s2i config builder image '%v', got '%v'",
expected, cfg.BuilderImage) expected, cfg.BuilderImage)
@ -111,7 +125,7 @@ func Test_BuilderImageDefault(t *testing.T) {
// Invoke Build, which runs function Builder logic before invoking the // Invoke Build, which runs function Builder logic before invoking the
// mock impl above. // 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) t.Fatal(err)
} }
} }
@ -151,8 +165,8 @@ func Test_BuilderImageConfigurable(t *testing.T) {
} }
} }
// Test_BuildImageWithFuncIgnore ensures that ignored files are not added to the func // Test_BuildImageWithFuncIgnore ensures that ignored files are not added to
// image // the func image
func Test_BuildImageWithFuncIgnore(t *testing.T) { func Test_BuildImageWithFuncIgnore(t *testing.T) {
funcIgnoreContent := []byte(`#testing Comments funcIgnoreContent := []byte(`#testing Comments

View File

@ -61,7 +61,7 @@ func TestPusher_Push(t *testing.T) {
// Create and push a function // Create and push a function
client := fn.New( client := fn.New(
fn.WithBuilder(NewBuilder("", verbose)), fn.WithBuilder(NewBuilder("", false)),
fn.WithPusher(NewPusher(insecure, anon, verbose))) fn.WithPusher(NewPusher(insecure, anon, verbose)))
f := fn.Function{Root: root, Runtime: "go", Name: "f", Registry: l.Addr().String() + "/funcs"} f := fn.Function{Root: root, Runtime: "go", Name: "f", Registry: l.Addr().String() + "/funcs"}
@ -85,7 +85,7 @@ func TestPusher_Push(t *testing.T) {
} }
} }
// TestPusher_Auth ensures that the pusher authenticates via basic auth when // TestPusher_BasicAuth ensures that the pusher authenticates via basic auth when
// supplied with a username/password via the context. // supplied with a username/password via the context.
func TestPusher_BasicAuth(t *testing.T) { func TestPusher_BasicAuth(t *testing.T) {
var ( var (
@ -106,7 +106,7 @@ func TestPusher_BasicAuth(t *testing.T) {
// no header. ask for auth // no header. ask for auth
w.Header().Add("www-authenticate", "Basic realm=\"Registry Realm\"") w.Header().Add("www-authenticate", "Basic realm=\"Registry Realm\"")
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)
} else if u != "username" || p != "password" { } else if u != username || p != password {
// header exists, but creds are either missing or incorrect // header exists, but creds are either missing or incorrect
t.Fatalf("Unauthorized. Expected user %q pass %q, got user %q pass %q", username, password, u, p) t.Fatalf("Unauthorized. Expected user %q pass %q, got user %q pass %q", username, password, u, p)
http.Error(w, "Unauthorized", http.StatusUnauthorized) http.Error(w, "Unauthorized", http.StatusUnauthorized)

View File

@ -82,7 +82,7 @@ func Test_validatePipeline(t *testing.T) {
{ {
name: "Unsupported runtime - Go - s2i builder", name: "Unsupported runtime - Go - s2i builder",
function: fn.Function{Build: fn.BuildSpec{Builder: builders.S2I}, Runtime: "go"}, function: fn.Function{Build: fn.BuildSpec{Builder: builders.S2I}, Runtime: "go"},
wantErr: true, wantErr: false,
}, },
{ {
name: "Supported runtime - Quarkus - pack builder - without additional Buildpacks", name: "Supported runtime - Quarkus - pack builder - without additional Buildpacks",