package s2i import ( "context" "errors" "fmt" "net/url" "os" "path/filepath" "runtime" "strings" dockerClient "github.com/docker/docker/client" "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" "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/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, "python": DefaultPythonBuilder, "quarkus": DefaultQuarkusBuilder, "typescript": DefaultNodeBuilder, } // Builder of functions using the s2i subsystem. type Builder struct { name string verbose bool impl build.Builder // S2I builder implementation (aka "Strategy") cli s2idocker.Client } 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 s2idocker.Client) 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 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(), } // Scaffold if cfg, err = scaffold(cfg, f); err != nil { return } // 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 } buildEnvs["LISTEN_ADDRESS"] = "0.0.0.0:8080" for k, v := range buildEnvs { cfg.Environment = append(cfg.Environment, api.EnvironmentSpec{Name: k, Value: v}) } for _, m := range f.Build.Mounts { cfg.BuildVolumes = append(cfg.BuildVolumes, fmt.Sprintf("%s:%s:ro,Z", m.Source, m.Destination)) } if runtime.GOOS == "linux" { cfg.DockerNetworkMode = "host" } // 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(client, 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) } } 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 and Python runtimes if f.Runtime != "go" && f.Runtime != "python" { 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 }