From b0418f95bb1bbed27f9d631fb080b2c40c950bc9 Mon Sep 17 00:00:00 2001 From: Luke Kingland Date: Tue, 21 May 2024 13:12:55 +0000 Subject: [PATCH] feat: s2i Go support (#2203) --- .gitignore | 5 + pkg/builders/s2i/assemblers.go | 66 +++++++++++ pkg/builders/s2i/builder.go | 159 ++++++++++++++++++-------- pkg/builders/s2i/builder_test.go | 40 ++++--- pkg/oci/pusher_test.go | 6 +- pkg/pipelines/tekton/validate_test.go | 2 +- 6 files changed, 214 insertions(+), 64 deletions(-) create mode 100644 pkg/builders/s2i/assemblers.go diff --git a/.gitignore b/.gitignore index 773cd7d8b..16b27bc4b 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/pkg/builders/s2i/assemblers.go b/pkg/builders/s2i/assemblers.go new file mode 100644 index 000000000..b7bff919b --- /dev/null +++ b/pkg/builders/s2i/assemblers.go @@ -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) + } +} diff --git a/pkg/builders/s2i/builder.go b/pkg/builders/s2i/builder.go index 1c0e58505..03b2ac5c7 100644 --- a/pkg/builders/s2i/builder.go +++ b/pkg/builders/s2i/builder.go @@ -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 @@ -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 @@ -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 @@ -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") } - // 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 if client == nil { var c dockerClient.CommonAPIClient @@ -186,11 +149,60 @@ func (b *Builder) Build(ctx context.Context, f fn.Function, platforms []fn.Platf 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 } - cfg.ScriptsURL = scriptURL // Excludes // 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.") } - 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 { @@ -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) } } @@ -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 +} diff --git a/pkg/builders/s2i/builder_test.go b/pkg/builders/s2i/builder_test.go index dce00dff1..4a3313130 100644 --- a/pkg/builders/s2i/builder_test.go +++ b/pkg/builders/s2i/builder_test.go @@ -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 @@ -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", @@ -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) @@ -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) } } @@ -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 diff --git a/pkg/oci/pusher_test.go b/pkg/oci/pusher_test.go index de920f317..4f92e8355 100644 --- a/pkg/oci/pusher_test.go +++ b/pkg/oci/pusher_test.go @@ -61,7 +61,7 @@ func TestPusher_Push(t *testing.T) { // Create and push a function client := fn.New( - fn.WithBuilder(NewBuilder("", verbose)), + fn.WithBuilder(NewBuilder("", false)), fn.WithPusher(NewPusher(insecure, anon, verbose))) 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. func TestPusher_BasicAuth(t *testing.T) { var ( @@ -106,7 +106,7 @@ func TestPusher_BasicAuth(t *testing.T) { // no header. ask for auth w.Header().Add("www-authenticate", "Basic realm=\"Registry Realm\"") 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 t.Fatalf("Unauthorized. Expected user %q pass %q, got user %q pass %q", username, password, u, p) http.Error(w, "Unauthorized", http.StatusUnauthorized) diff --git a/pkg/pipelines/tekton/validate_test.go b/pkg/pipelines/tekton/validate_test.go index 77a55faf6..98a7b59be 100644 --- a/pkg/pipelines/tekton/validate_test.go +++ b/pkg/pipelines/tekton/validate_test.go @@ -82,7 +82,7 @@ func Test_validatePipeline(t *testing.T) { { name: "Unsupported runtime - Go - s2i builder", function: fn.Function{Build: fn.BuildSpec{Builder: builders.S2I}, Runtime: "go"}, - wantErr: true, + wantErr: false, }, { name: "Supported runtime - Quarkus - pack builder - without additional Buildpacks",