diff --git a/cmd/build.go b/cmd/build.go index 6fec468e98..5791eca8ff 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -181,7 +181,6 @@ func runBuild(cmd *cobra.Command, _ []string, newClient ClientFactory) (err erro } else if f.Build.Builder == builders.S2I { o = append(o, fn.WithBuilder(s2i.NewBuilder( s2i.WithName(builders.S2I), - s2i.WithPlatform(cfg.Platform), s2i.WithVerbose(cfg.Verbose)))) } @@ -189,7 +188,11 @@ func runBuild(cmd *cobra.Command, _ []string, newClient ClientFactory) (err erro defer done() // Build and (optionally) push - if f, err = client.Build(cmd.Context(), f); err != nil { + buildOptions, err := cfg.buildOptions() + if err != nil { + return + } + if f, err = client.Build(cmd.Context(), f, buildOptions...); err != nil { return } if cfg.Push { @@ -233,6 +236,26 @@ type buildConfig struct { WithTimestamp bool } +func (c buildConfig) buildOptions() (oo []fn.BuildOption, err error) { + oo = []fn.BuildOption{} + + // Platforms + // + // TODO: upgrade --platform to a multi-value field. The individual builder + // implementations are responsible for bubbling an error if they do + // not support this. Pack supports none, S2I supports one, host builder + // supports multi. + if c.Platform != "" { + parts := strings.Split(c.Platform, "/") + if len(parts) != 2 { + return oo, fmt.Errorf("the value for --patform must be in the form [OS]/[Architecture]. eg \"linux/amd64\"") + } + oo = append(oo, fn.BuildWithPlatforms([]fn.Platform{{OS: parts[0], Architecture: parts[1]}})) + } + + return +} + // newBuildConfig gathers options into a single build request. func newBuildConfig() buildConfig { return buildConfig{ diff --git a/cmd/deploy.go b/cmd/deploy.go index 4540c873a1..81dae726a0 100644 --- a/cmd/deploy.go +++ b/cmd/deploy.go @@ -263,7 +263,6 @@ func runDeploy(cmd *cobra.Command, newClient ClientFactory) (err error) { } else if f.Build.Builder == builders.S2I { builder = s2i.NewBuilder( s2i.WithName(builders.S2I), - s2i.WithPlatform(cfg.Platform), s2i.WithVerbose(cfg.Verbose)) } else { return builders.ErrUnknownBuilder{Name: f.Build.Builder, Known: KnownBuilders()} @@ -283,8 +282,13 @@ func runDeploy(cmd *cobra.Command, newClient ClientFactory) (err error) { } } else { if shouldBuild(cfg.Build, f, client) { // --build or "auto" with FS changes - if f, err = client.Build(cmd.Context(), f); err != nil { - return + buildOptions, err := cfg.buildOptions() + if err != nil { + return err + } + + if f, err = client.Build(cmd.Context(), f, buildOptions...); err != nil { + return err } } if cfg.Push { diff --git a/cmd/run.go b/cmd/run.go index 9734c9b414..8ff6fbdca2 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -179,7 +179,6 @@ func runRun(cmd *cobra.Command, args []string, newClient ClientFactory) (err err } else if f.Build.Builder == builders.S2I { o = append(o, fn.WithBuilder(s2i.NewBuilder( s2i.WithName(builders.S2I), - s2i.WithPlatform(cfg.Platform), s2i.WithVerbose(cfg.Verbose)))) } if cfg.Container { @@ -194,8 +193,12 @@ func runRun(cmd *cobra.Command, args []string, newClient ClientFactory) (err err // If requesting to run via the container, build the container if it is // either out-of-date or a build was explicitly requested. if cfg.Container && shouldBuild(cfg.Build, f, client) { - if f, err = client.Build(cmd.Context(), f); err != nil { - return + buildOptions, err := cfg.buildOptions() + if err != nil { + return err + } + if f, err = client.Build(cmd.Context(), f, buildOptions...); err != nil { + return err } } diff --git a/pkg/builders/buildpacks/builder.go b/pkg/builders/buildpacks/builder.go index 5d54f7fd75..11117c69f9 100644 --- a/pkg/builders/buildpacks/builder.go +++ b/pkg/builders/buildpacks/builder.go @@ -3,6 +3,7 @@ package buildpacks import ( "bytes" "context" + "errors" "fmt" "io" "runtime" @@ -108,7 +109,12 @@ func WithTimestamp(v bool) Option { var DefaultLifecycleImage = "quay.io/boson/lifecycle@sha256:f53fea9ec9188b92cab0b8a298ff852d76a6c2aaf56f968a08637e13de0e0c59" // Build the Function at path. -func (b *Builder) Build(ctx context.Context, f fn.Function) (err error) { +func (b *Builder) Build(ctx context.Context, f fn.Function, oo ...fn.BuildOption) (err error) { + options := fn.NewBuildOptions(oo...) + if len(options.Platforms) != 0 { + return errors.New("the pack builder does not support specifying target platforms directly.") + } + // Builder image from the function if defined, default otherwise. image, err := BuilderImage(f, b.name) if err != nil { diff --git a/pkg/builders/s2i/builder.go b/pkg/builders/s2i/builder.go index 5f983b6b66..21f817df99 100644 --- a/pkg/builders/s2i/builder.go +++ b/pkg/builders/s2i/builder.go @@ -53,11 +53,10 @@ type DockerClient interface { // Builder of functions using the s2i subsystem. type Builder struct { - name string - verbose bool - impl build.Builder // S2I builder implementation (aka "Strategy") - cli DockerClient - platform string + name string + verbose bool + impl build.Builder // S2I builder implementation (aka "Strategy") + cli DockerClient } type Option func(*Builder) @@ -90,12 +89,6 @@ func WithDockerClient(cli DockerClient) Option { } } -func WithPlatform(platform string) Option { - return func(b *Builder) { - b.platform = platform - } -} - // NewBuilder creates a new instance of a Builder with static defaults. func NewBuilder(options ...Option) *Builder { b := &Builder{name: DefaultName} @@ -105,8 +98,11 @@ func NewBuilder(options ...Option) *Builder { return b } -func (b *Builder) Build(ctx context.Context, f fn.Function) (err error) { - // TODO this function currently doesn't support private s2i builder images since credentials are not set +func (b *Builder) Build(ctx context.Context, f fn.Function, oo ...fn.BuildOption) (err error) { + options := fn.NewBuildOptions(oo...) + if len(options.Platforms) != 0 { + return errors.New("the pack builder does not support specifying target platforms directly.") + } // Builder image from the function if defined, default otherwise. builderImage, err := BuilderImage(f, b.name) @@ -114,13 +110,23 @@ func (b *Builder) Build(ctx context.Context, f fn.Function) (err error) { return } - if b.platform != "" { - builderImage, err = docker.GetPlatformImage(builderImage, b.platform) + // Allow for specifying a single target platform + if len(options.Platforms) > 1 { + return errors.New("the S2I builder currently only supports specifying a single target platform") + } + if len(options.Platforms) == 1 { + platform := strings.ToLower( + options.Platforms[0].OS + "/" + + options.Platforms[0].Architecture) + + builderImage, err = docker.GetPlatformImage(builderImage, platform) if err != nil { - return fmt.Errorf("cannot get platform specific image reference: %w", err) + return fmt.Errorf("cannot get platform image reference for %q: %w", platform, err) } } + // 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 diff --git a/pkg/docker/runner.go b/pkg/docker/runner.go index 448db9e9d6..98a57e2f6c 100644 --- a/pkg/docker/runner.go +++ b/pkg/docker/runner.go @@ -50,7 +50,7 @@ func NewRunner(verbose bool, out, errOut io.Writer) *Runner { } // Run the function. -func (n *Runner) Run(ctx context.Context, f fn.Function) (job *fn.Job, err error) { +func (n *Runner) Run(ctx context.Context, f fn.Function, _ ...fn.RunOption) (job *fn.Job, err error) { var ( port = choosePort(DefaultHost, DefaultPort, DefaultDialTimeout) @@ -102,6 +102,8 @@ func (n *Runner) Run(ctx context.Context, f fn.Function) (job *fn.Job, err error }() // Start + // TODO: Could we use the RunOption Timeout to exit if not started within + // that time? if err = c.ContainerStart(ctx, id, types.ContainerStartOptions{}); err != nil { return job, errors.Wrap(err, "runner unable to start container") } diff --git a/pkg/functions/client.go b/pkg/functions/client.go index 86ed85c7ae..5441ca31a3 100644 --- a/pkg/functions/client.go +++ b/pkg/functions/client.go @@ -31,6 +31,25 @@ const ( // one implementation of each supported function signature. Currently that // includes an HTTP Handler ("http") and Cloud Events handler ("events") DefaultTemplate = "http" + + // DefaultRunTimeout when running a function which does not define its own + // timeout. + DefaultRunTimeout = 60 * time.Second +) + +var ( + // DefaultPlatforms is a suggestion to builder implementations which + // platforms should be the default. Due to spotty implementation support + // use of this set is left up to the discretion of the builders + // themselves. In the event the builder receives build options which + // specify a set of platforms to use in leau of the default (see the + // BuildWithPlatforms functionl option), the builder should return + // an error if the request can not proceed. + DefaultPlatforms = []Platform{ + {OS: "linux", Architecture: "amd64"}, + {OS: "linux", Architecture: "arm64"}, + {OS: "linux", Architecture: "arm", Variant: "v7"}, // eg. RPiv4 + } ) // Client for managing function instances. @@ -53,21 +72,20 @@ type Client struct { instances *InstanceRefs // Function Instances management transport http.RoundTripper // Customizable internal transport pipelinesProvider PipelinesProvider // CI/CD pipelines management + runTimeout time.Duration // defaut timeout when running } -// ErrNotBuilt indicates the function has not yet been built. -var ErrNotBuilt = errors.New("not built") - -// ErrNameRequired indicates the operation requires a name to complete. -var ErrNameRequired = errors.New("name required") - -// ErrRegistryRequired indicates the operation requires a registry to complete. -var ErrRegistryRequired = errors.New("registry required to build function, please set with `--registry` or the FUNC_REGISTRY environment variable") - // Builder of function source to runnable image. type Builder interface { // Build a function project with source located at path. - Build(context.Context, Function) error + Build(context.Context, Function, ...BuildOption) error +} + +// Platform upon which a function may run +type Platform struct { + OS string + Architecture string + Variant string } // Pusher of function image to a registry. @@ -103,7 +121,7 @@ type Runner interface { // Run the function, returning a Job with metadata, error channels, and // a stop function.The process can be stopped by running the returned stop // function, either on context cancellation or in a defer. - Run(context.Context, Function) (*Job, error) + Run(context.Context, Function, ...RunOption) (*Job, error) } // Remover of deployed services. @@ -206,6 +224,7 @@ func New(options ...Option) *Client { progressListener: &NoopProgressListener{}, pipelinesProvider: &noopPipelinesProvider{}, transport: http.DefaultTransport, + runTimeout: DefaultRunTimeout, } c.runner = newDefaultRunner(c, os.Stdout, os.Stderr) for _, o := range options { @@ -357,6 +376,19 @@ func WithPipelinesProvider(pp PipelinesProvider) Option { } } +// WithRunTimeout sets a custom default timeout for functions which do not +// define their own. This is useful in situations where the client is +// operating in a restricted environment and all functions tend to take longer +// to start up than usual, or when the client is running functions which +// in general take longer to start. If a timeout is specified on the +// function itself, that will take precidence. Use the RunWithTimeout option +// on the Run method to specify a timeout with precidence. +func WithRunTimeout(t time.Duration) Option { + return func(c *Client) { + c.runTimeout = t + } +} + // ACCESSORS // --------- @@ -433,7 +465,7 @@ func (c *Client) Apply(ctx context.Context, f Function) (string, Function, error // Returns final primary route to the Function and any errors. func (c *Client) Update(ctx context.Context, f Function) (string, Function, error) { if !f.Initialized() { - return "", f, ErrNotInitialized + return "", f, ErrNotInitialized{f.Root} } var err error if f, err = c.Build(ctx, f); err != nil { @@ -580,9 +612,34 @@ func (c *Client) Init(cfg Function) (Function, error) { return NewFunction(oldRoot) } +type BuildOptions struct { + Platforms []Platform +} + +type BuildOption func(c *BuildOptions) + +func NewBuildOptions(options ...BuildOption) BuildOptions { + oo := BuildOptions{} + for _, o := range options { + o(&oo) + } + // Note this returns the exact build options requested. It is up to the + // builder implementations to choose how to use this information. + // For example, some may error stating that building for speific platforms + // is not supported (eg pack builder). Others may use them if provided and use + // DefaultPlatforms if not (eg host builder). + return oo +} + +func BuildWithPlatforms(pp []Platform) BuildOption { + return func(c *BuildOptions) { + c.Platforms = pp + } +} + // Build the function at path. Errors if the function is either unloadable or does // not contain a populated Image. -func (c *Client) Build(ctx context.Context, f Function) (Function, error) { +func (c *Client) Build(ctx context.Context, f Function, options ...BuildOption) (Function, error) { c.progressListener.Increment("Building function image") ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -609,7 +666,7 @@ func (c *Client) Build(ctx context.Context, f Function) (Function, error) { } } - if err = c.builder.Build(ctx, f); err != nil { + if err = c.builder.Build(ctx, f, options...); err != nil { return f, err } @@ -837,9 +894,21 @@ func (c *Client) Route(ctx context.Context, f Function) (string, Function, error return instance.Route, f, nil } +type RunOptions struct { + Timeout time.Duration +} + +type RunOption func(c *RunOptions) + +func RunWithStartTimeout(t time.Duration) RunOption { + return func(c *RunOptions) { + c.Timeout = t + } +} + // Run the function whose code resides at root. // On start, the chosen port is sent to the provided started channel -func (c *Client) Run(ctx context.Context, f Function) (job *Job, err error) { +func (c *Client) Run(ctx context.Context, f Function, options ...RunOption) (job *Job, err error) { go func() { <-ctx.Done() c.progressListener.Stopping() @@ -851,7 +920,7 @@ func (c *Client) Run(ctx context.Context, f Function) (job *Job, err error) { // Run the function, which returns a Job for use interacting (at arms length) // with that running task (which is likely inside a container process). - if job, err = c.runner.Run(ctx, f); err != nil { + if job, err = c.runner.Run(ctx, f, options...); err != nil { return } @@ -1169,7 +1238,7 @@ func hasInitializedFunction(path string) (bool, error) { // Builder type noopBuilder struct{ output io.Writer } -func (n *noopBuilder) Build(ctx context.Context, _ Function) error { return nil } +func (n *noopBuilder) Build(ctx context.Context, _ Function, _ ...BuildOption) error { return nil } // Pusher type noopPusher struct{ output io.Writer } diff --git a/pkg/functions/client_test.go b/pkg/functions/client_test.go index d7b707e856..89e497611c 100644 --- a/pkg/functions/client_test.go +++ b/pkg/functions/client_test.go @@ -14,6 +14,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "sync/atomic" "testing" @@ -23,6 +24,7 @@ import ( "knative.dev/func/pkg/builders" fn "knative.dev/func/pkg/functions" "knative.dev/func/pkg/mock" + "knative.dev/func/pkg/oci" . "knative.dev/func/pkg/testing" ) @@ -37,6 +39,12 @@ const ( TestRuntime = "go" ) +var ( + // TestPlatforms to use when a multi-architecture build is not necessary + // for testing. + TestPlatforms = []fn.Platform{{OS: runtime.GOOS, Architecture: runtime.GOARCH}} +) + // TestClient_New function completes without error using defaults and zero values. // New is the superset of creating a new fully deployed function, and // thus implicitly tests Create, Build and Deploy, which are exposed @@ -596,7 +604,8 @@ func TestClient_New_Delegation(t *testing.T) { // TestClient_Run ensures that the runner is invoked with the path requested. // Implicitly checks that the stop fn returned also is respected. -// See TestRunner for the unit test for the default runner implementation. +// See TestClient_DefaultRunner for the unit test for the default runner +// implementation. func TestClient_Run(t *testing.T) { // Create the root function directory root := "testdata/example.com/testRun" @@ -1709,3 +1718,176 @@ func TestClient_CreateMigration(t *testing.T) { t.Fatal("freshly created function should have the latest migration") } } + +// TestClient_RunnerDefault ensures that the default internal runner correctly +// executes a scaffolded function. see TestClient_Run* tests for more general +// opaque box tests which confirm the client is inoking the runner. +func TestClient_RunnerDefault(t *testing.T) { + // This integration test explicitly requires the "host" builder due to its + // lack of a dependency on a container runtime, and the other builders not + // taking advantage of Scaffolding (expected by this runner). + // See E2E tests for testing of running functions built using Pack or S2I and + // which are dependent on Podman or Docker. + // Currently only a Go function is tested because other runtimes do not yet + // have scaffolding. + + root, cleanup := Mktemp(t) + defer cleanup() + ctx, cancel := context.WithCancel(context.Background()) + client := fn.New(fn.WithBuilder(oci.NewBuilder("", true)), fn.WithVerbose(true)) + + // Initialize + f, err := client.Init(fn.Function{Root: root, Runtime: "go", Registry: TestRegistry}) + if err != nil { + t.Fatal(err) + } + + // Build + if f, err = client.Build(ctx, f, fn.BuildWithPlatforms(TestPlatforms)); err != nil { + t.Fatal(err) + } + + // Run + job, err := client.Run(ctx, f) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := job.Stop(); err != nil { + t.Fatalf("error on job stop: %v", err) + } + }() + + // Invoke + resp, err := http.Get(fmt.Sprintf("http://%s:%s", job.Host, job.Port)) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + t.Fatalf("unexpected response code: %v", resp.StatusCode) + } + + cancel() +} + +// TestClient_RunTimeout ensures that the run task bubbles a timeout +// error if the function does not report ready within the allotted timeout. +func TestClient_RunTimeout(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + root, cleanup := Mktemp(t) + defer cleanup() + + // A client with a shorter global timeout. + client := fn.New( + fn.WithBuilder(oci.NewBuilder("", true)), + fn.WithVerbose(true), + fn.WithRunTimeout(500*time.Millisecond)) + + // Initialize + f, err := client.Init(fn.Function{Root: root, Runtime: "go", Registry: TestRegistry}) + if err != nil { + t.Fatal(err) + } + + // Replace the implementation with the test implementation which will + // return a non-200 response for the first 10 seconds. This confirms + // the client is waiting and retrying. + // TODO: we need an init option which skips writing example source-code. + _ = os.Remove(filepath.Join(root, "function.go")) + _ = os.Remove(filepath.Join(root, "function_test.go")) + _ = os.Remove(filepath.Join(root, "handle.go")) + _ = os.Remove(filepath.Join(root, "handle_test.go")) + src, err := os.Open(filepath.Join(cwd, "testdata", "testClientRunTimeout", "f.go")) + if err != nil { + t.Fatal(err) + } + dst, err := os.Create(filepath.Join(root, "f.go")) + + if _, err = io.Copy(dst, src); err != nil { + t.Fatal(err) + } + src.Close() + dst.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Build + if f, err = client.Build(ctx, f, fn.BuildWithPlatforms(TestPlatforms)); err != nil { + t.Fatal(err) + } + + // Run + // with a very short timeout + _, err = client.Run(ctx, f, fn.RunWithStartTimeout(100*time.Millisecond)) + if !errors.Is(err, fn.ErrRunTimeout) { + t.Fatalf("did not receive ErrRunTimeout. Got %v", err) + } +} + +// TestClient_RunWaits ensures that the run task awaits a ready response +// from the job before returning. +func TestClient_RunReadiness(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + root, cleanup := Mktemp(t) + defer cleanup() + + client := fn.New(fn.WithBuilder(oci.NewBuilder("", true)), fn.WithVerbose(true)) + + // Initialize + f, err := client.Init(fn.Function{Root: root, Runtime: "go", Registry: TestRegistry}) + if err != nil { + t.Fatal(err) + } + + // Replace the implementation with the test implementation which will + // return a non-200 response for the first 10 seconds. This confirms + // the client is waiting and retrying. + // TODO: we need an init option which skips writing example source-code. + _ = os.Remove(filepath.Join(root, "function.go")) + _ = os.Remove(filepath.Join(root, "function_test.go")) + _ = os.Remove(filepath.Join(root, "handle.go")) + _ = os.Remove(filepath.Join(root, "handle_test.go")) + src, err := os.Open(filepath.Join(cwd, "testdata", "testClientRunReadiness", "f.go")) + if err != nil { + t.Fatal(err) + } + dst, err := os.Create(filepath.Join(root, "f.go")) + + if _, err = io.Copy(dst, src); err != nil { + t.Fatal(err) + } + src.Close() + dst.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Build + if f, err = client.Build(ctx, f, fn.BuildWithPlatforms(TestPlatforms)); err != nil { + t.Fatal(err) + } + + // Run + // The function returns a non-200 from its readiness handler at first. + // Since we already confirmed in another test that a timeout awaiting a + // 200 response from this endpoint does indeed fail the run task, this + // delayed 200 confirms there is a retry in place. + job, err := client.Run(ctx, f) + if err != nil { + t.Fatal(err) + } + defer func() { + if err := job.Stop(); err != nil { + t.Fatalf("error on job stop: %v", err) + } + }() +} diff --git a/pkg/functions/errors.go b/pkg/functions/errors.go new file mode 100644 index 0000000000..01670828fa --- /dev/null +++ b/pkg/functions/errors.go @@ -0,0 +1,64 @@ +package functions + +import ( + "errors" + "fmt" +) + +var ( + ErrEnvironmentNotFound = errors.New("environment not found") + ErrMismatchedName = errors.New("name passed the function source") + ErrNameRequired = errors.New("name required") + ErrNotBuilt = errors.New("not built") + ErrNotRunning = errors.New("function not running") + ErrRepositoriesNotDefined = errors.New("custom template repositories location not specified") + ErrRepositoryNotFound = errors.New("repository not found") + ErrRootRequired = errors.New("function root path is required") + ErrRunTimeout = errors.New("timeout waiting for function to report ready") + ErrRuntimeNotFound = errors.New("language runtime not found") + ErrRuntimeRequired = errors.New("language runtime required") + ErrTemplateMissingRepository = errors.New("template name missing repository prefix") + ErrTemplateNotFound = errors.New("template not found") + ErrTemplatesNotFound = errors.New("templates path (runtimes) not found") + + // TODO: change the wording of this error to not be CLI-specific; + // eg "registry required". Then catch the error in the CLI and add the + // cli-specific usage hints there + ErrRegistryRequired = errors.New("registry required to build function, please set with `--registry` or the FUNC_REGISTRY environment variable") +) + +// ErrNotInitialized indicates that a function is uninitialized +type ErrNotInitialized struct { + Path string +} + +func NewUninitializedError(path string) error { + return &ErrNotInitialized{Path: path} +} + +func (e ErrNotInitialized) Error() string { + if e.Path == "" { + return "function is not initialized" + } + return fmt.Sprintf("'%s' does not contain an initialized function", e.Path) +} + +// ErrRuntimeNotRecognized indicates a runtime which is not in the set of +// those known. +type ErrRuntimeNotRecognized struct { + Runtime string +} + +func (e ErrRuntimeNotRecognized) Error() string { + return fmt.Sprintf("the %q runtime is not recognized", e.Runtime) +} + +// ErrRunnerNotImplemented indicates the feature is not available for the +// requested runtime. +type ErrRunnerNotImplemented struct { + Runtime string +} + +func (e ErrRunnerNotImplemented) Error() string { + return fmt.Sprintf("the %q runtime may only be run containerized.", e.Runtime) +} diff --git a/pkg/functions/function.go b/pkg/functions/function.go index d0d2d83e18..8db4c4ff8f 100644 --- a/pkg/functions/function.go +++ b/pkg/functions/function.go @@ -126,6 +126,13 @@ type RunSpec struct { // Env variables to be set Envs Envs `yaml:"envs,omitempty"` + + // StartTimeout specifies that this function should have a custom timeout + // set for starting. This is useful for functons which have a long + // startup time, such as loading large models into system memory. + // Note this is currently only respected by the func client + // runner. Ksvc integration is in development. + StartTimeout time.Duration } // DeploySpec @@ -163,18 +170,6 @@ type BuildConfig struct { BuilderImages map[string]string `yaml:"builderImages,omitempty"` } -type UninitializedError struct { - Path string -} - -func (e *UninitializedError) Error() string { - return fmt.Sprintf("'%s' does not contain an initialized function", e.Path) -} - -func NewUninitializedError(path string) error { - return &UninitializedError{Path: path} -} - // NewFunctionWith defaults as provided. func NewFunctionWith(defaults Function) Function { if defaults.SpecVersion == "" { diff --git a/pkg/functions/instances.go b/pkg/functions/instances.go index 48e8f93746..529bb9602d 100644 --- a/pkg/functions/instances.go +++ b/pkg/functions/instances.go @@ -11,14 +11,6 @@ const ( EnvironmentRemote = "remote" ) -var ( - ErrNotInitialized = errors.New("function is not initialized") - ErrNotRunning = errors.New("function not running") - ErrRootRequired = errors.New("function root path is required") - ErrEnvironmentNotFound = errors.New("environment not found") - ErrMismatchedName = errors.New("name passed does not match name of the function at root") -) - // InstanceRefs manager // // InstanceRefs are point-in-time snapshots of a function's runtime state in @@ -61,7 +53,7 @@ func (s *InstanceRefs) Local(ctx context.Context, f Function) (Instance, error) return i, ErrRootRequired } if !f.Initialized() { - return i, ErrNotInitialized + return i, ErrNotInitialized{f.Root} } ports := jobPorts(f) if len(ports) == 0 { diff --git a/pkg/functions/instances_test.go b/pkg/functions/instances_test.go index 2de3da41e7..dca282f4a6 100644 --- a/pkg/functions/instances_test.go +++ b/pkg/functions/instances_test.go @@ -5,6 +5,7 @@ package functions import ( "context" + "errors" "fmt" "runtime" "strings" @@ -26,31 +27,34 @@ func TestInstances_LocalErrors(t *testing.T) { } tests := []struct { - name string - f Function - want error + name string + f Function + wantIs error + wantAs any }{ { - name: "Not running", // Function exists but is not running - f: f, - want: ErrNotRunning, + name: "Not running", // Function exists but is not running + f: f, + wantIs: ErrNotRunning, }, { - name: "Not initialized", // A function directory is provided, but no function exists - f: Function{Root: "testdata/not-initialized"}, - want: ErrNotInitialized, + name: "Not initialized", // A function directory is provided, but no function exists + f: Function{Root: "testdata/not-initialized"}, + wantAs: &ErrNotInitialized{}, }, { - name: "Root required", // No root directory is provided - f: Function{}, - want: ErrRootRequired, + name: "Root required", // No root directory is provided + f: Function{}, + wantIs: ErrRootRequired, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { i := InstanceRefs{} - if _, err := i.Local(context.TODO(), tt.f); err != tt.want { - t.Errorf("Local() error = %v, wantErr %v", err, tt.want) + _, err := i.Local(context.TODO(), tt.f) + if (tt.wantIs != nil && !errors.Is(err, tt.wantIs)) || + (tt.wantAs != nil && !errors.As(err, tt.wantAs)) { + t.Errorf("Local() error = %v, wantErr %v", err, tt.wantAs) } }) } diff --git a/pkg/functions/runner.go b/pkg/functions/runner.go index 6b26f78666..4e97c843ae 100644 --- a/pkg/functions/runner.go +++ b/pkg/functions/runner.go @@ -2,7 +2,6 @@ package functions import ( "context" - "errors" "fmt" "io" "net" @@ -20,10 +19,6 @@ const ( defaultRunDialTimeout = 2 * time.Second defaultRunStopTimeout = 10 * time.Second readinessEndpoint = "/health/readiness" - - // defaultRunTimeout is long to allow for slow-starting functions by default - // TODO: allow to be shortened as-needed using a runOption. - defaultRunTimeout = 5 * time.Minute ) type defaultRunner struct { @@ -40,7 +35,11 @@ func newDefaultRunner(client *Client, out, err io.Writer) *defaultRunner { } } -func (r *defaultRunner) Run(ctx context.Context, f Function) (job *Job, err error) { +func (r *defaultRunner) Run(ctx context.Context, f Function, options ...RunOption) (job *Job, err error) { + c := RunOptions{} + for _, o := range options { + o(&c) + } var ( port = choosePort(defaultRunHost, defaultRunPort, defaultRunDialTimeout) runFn func() error @@ -59,7 +58,7 @@ func (r *defaultRunner) Run(ctx context.Context, f Function) (job *Job, err erro } // Runner for the Function's runtime. - if runFn, err = runFunc(ctx, job); err != nil { + if runFn, err = getRunFunc(ctx, job); err != nil { return } @@ -69,43 +68,50 @@ func (r *defaultRunner) Run(ctx context.Context, f Function) (job *Job, err erro } // Wait for it to become available before returning the metadata. - err = waitFor(job, defaultRunTimeout) + err = waitFor(job, r.timeout(f, c)) return } -// runFunc returns a function which will run the user's Function based on +// timeout for this run task. The timeout passed as a runOption takes highest +// precidence. The function's timeout, if defined, is next. Finally the +// client's global timout is last, which is by default DefaultRunTimeout +// unless set using the WithRunTimeout option on client construction. +func (r *defaultRunner) timeout(f Function, c RunOptions) time.Duration { + if c.Timeout != 0 { + return c.Timeout + } else if f.Run.StartTimeout != 0 { + return f.Run.StartTimeout + } + return r.client.runTimeout +} + +// getRunFunc returns a function which will run the user's Function based on // the jobs runtime. -func runFunc(ctx context.Context, job *Job) (runFn func() error, err error) { +func getRunFunc(ctx context.Context, job *Job) (runFn func() error, err error) { runtime := job.Function.Runtime switch runtime { + case "": + err = ErrRuntimeRequired case "go": runFn = func() error { return runGo(ctx, job) } case "python": - err = runnerNotImplemented{runtime} + err = ErrRunnerNotImplemented{runtime} case "java": - err = runnerNotImplemented{runtime} + err = ErrRunnerNotImplemented{runtime} case "node": - err = runnerNotImplemented{runtime} + err = ErrRunnerNotImplemented{runtime} case "typescript": - err = runnerNotImplemented{runtime} + err = ErrRunnerNotImplemented{runtime} case "rust": - err = runnerNotImplemented{runtime} - case "": - err = fmt.Errorf("runner requires the function have runtime set") + err = ErrRunnerNotImplemented{runtime} + case "quarkus": + err = ErrRunnerNotImplemented{runtime} default: - err = fmt.Errorf("the %q runtime is not supported", runtime) + err = ErrRuntimeNotRecognized{runtime} } return } -type runnerNotImplemented struct { - Runtime string -} - -func (e runnerNotImplemented) Error() string { - return fmt.Sprintf("the %q runtime may only be run containerized.", e.Runtime) -} - func runGo(ctx context.Context, job *Job) (err error) { // TODO: extract the build command code from the OCI Container Builder // and have both the runner and OCI Container Builder use the same. @@ -138,10 +144,11 @@ func runGo(ctx context.Context, job *Job) (err error) { cmd.Stderr = os.Stderr // cmd.Cancel = stop // TODO: use when we upgrade to go 1.20 + + // TODO: Update the functions go runtime to accept LISTEN_ADDRESS rather + // than just port in able to allow listening on other interfaces + // (keeping the default localhost only) if job.Host != "127.0.0.1" { - // TODO: Update the functions go runtime to accept LISTEN_ADDRESS rather - // than just port in able to allow listening on other interfaces - // (keeping the default localhost only) fmt.Fprintf(os.Stderr, "Warning: the Go functions runtime currently only supports localhost '127.0.0.1'. Requested listen interface '%v' will be ignored.", job.Host) } // See the 1.19 [release notes](https://tip.golang.org/doc/go1.19) which state: @@ -165,13 +172,13 @@ func waitFor(job *Job, timeout time.Duration) error { ) defer tick.Stop() if job.verbose { - fmt.Printf("Waiting for %v\n", url) + fmt.Printf("Waiting %q for %q\n", timeout, url) } for { select { case <-time.After(timeout): - return errors.New("timed out waiting for function to be ready") - case <-tick.C: + return ErrRunTimeout + case <-time.After(200 * time.Millisecond): resp, err := http.Get(url) defer resp.Body.Close() if err != nil { @@ -183,7 +190,7 @@ func waitFor(job *Job, timeout time.Duration) error { if job.verbose { fmt.Printf("Endpoint returned HTTP %v.\n", resp.StatusCode) dump, _ := httputil.DumpResponse(resp, true) - fmt.Println(dump) + fmt.Println(string(dump)) } continue } diff --git a/pkg/functions/runner_test.go b/pkg/functions/runner_test.go index d27d66795b..938054f907 100644 --- a/pkg/functions/runner_test.go +++ b/pkg/functions/runner_test.go @@ -1,67 +1,44 @@ //go:build !integration // +build !integration -package functions_test +package functions import ( "context" - "fmt" - "net/http" + "errors" "testing" - - fn "knative.dev/func/pkg/functions" - "knative.dev/func/pkg/oci" - . "knative.dev/func/pkg/testing" ) -// TestRunner ensures that the default internal runner correctly executes -// a scaffolded function. -func TestRunner(t *testing.T) { - // This integration test explicitly requires the "host" builder due to its - // lack of a dependency on a container runtime, and the other builders not - // taking advantage of Scaffolding (expected by this runner). - // See E2E tests for testing of running functions built using Pack or S2I and - // which are dependent on Podman or Docker. - // Currently only a Go function is tested because other runtimes do not yet - // have scaffolding. - - root, cleanup := Mktemp(t) - defer cleanup() - ctx, cancel := context.WithCancel(context.Background()) - client := fn.New(fn.WithBuilder(oci.NewBuilder("", true)), fn.WithVerbose(true)) - - // Initialize - f, err := client.Init(fn.Function{Root: root, Runtime: "go", Registry: TestRegistry}) - if err != nil { - t.Fatal(err) +// TestGetRunFuncErrors ensures that known runtimes which do not yet +// have their runner implemented return a "not yet available" message, as +// distinct from unrecognized runtimes which state as much. +func TestGetRunFuncErrors(t *testing.T) { + tests := []struct { + Runtime string + ExpectedIs error + ExpectedAs any + }{ + {"", ErrRuntimeRequired, nil}, + {"go", nil, nil}, + {"python", nil, &ErrRunnerNotImplemented{}}, + {"rust", nil, &ErrRunnerNotImplemented{}}, + {"node", nil, &ErrRunnerNotImplemented{}}, + {"typescript", nil, &ErrRunnerNotImplemented{}}, + {"quarkus", nil, &ErrRunnerNotImplemented{}}, + {"java", nil, &ErrRunnerNotImplemented{}}, + {"other", nil, &ErrRuntimeNotRecognized{}}, } - - // Build - if f, err = client.Build(ctx, f); err != nil { - t.Fatal(err) - } - - // Run - job, err := client.Run(ctx, f) - if err != nil { - t.Fatal(err) + for _, test := range tests { + t.Run(test.Runtime, func(t *testing.T) { + + ctx := context.Background() + job := Job{Function: Function{Runtime: test.Runtime}} + _, err := getRunFunc(ctx, &job) + + if test.ExpectedAs != nil && !errors.As(err, test.ExpectedAs) { + t.Fatalf("did not receive expected error type for %v runtime.", test.Runtime) + } + t.Logf("ok: %v", err) + }) } - defer func() { - if err := job.Stop(); err != nil { - t.Fatalf("error on job stop: %v", err) - } - }() - - // Invoke - resp, err := http.Get(fmt.Sprintf("http://%s:%s", job.Host, job.Port)) - if err != nil { - t.Fatal(err) - } - defer resp.Body.Close() - - if resp.StatusCode != 200 { - t.Fatalf("unexpected response code: %v", resp.StatusCode) - } - - cancel() } diff --git a/pkg/functions/templates.go b/pkg/functions/templates.go index e0035fd59a..ace93a83c3 100644 --- a/pkg/functions/templates.go +++ b/pkg/functions/templates.go @@ -2,22 +2,11 @@ package functions import ( "context" - "errors" "strings" "knative.dev/func/pkg/utils" ) -var ( - ErrRepositoryNotFound = errors.New("repository not found") - ErrRepositoriesNotDefined = errors.New("custom template repositories location not specified") - ErrTemplatesNotFound = errors.New("templates path (runtimes) not found") - ErrRuntimeNotFound = errors.New("language runtime not found") - ErrRuntimeRequired = errors.New("language runtime required") - ErrTemplateNotFound = errors.New("template not found") - ErrTemplateMissingRepository = errors.New("template name missing repository prefix") -) - // Templates Manager type Templates struct { client *Client diff --git a/pkg/functions/testdata/testClientRunReadiness/f.go b/pkg/functions/testdata/testClientRunReadiness/f.go new file mode 100644 index 0000000000..42786cc1cd --- /dev/null +++ b/pkg/functions/testdata/testClientRunReadiness/f.go @@ -0,0 +1,30 @@ +package f + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" +) + +type F struct { + Created time.Time +} + +func New() *F { + return &F{time.Now()} +} + +func (f *F) Handle(_ context.Context, w http.ResponseWriter, r *http.Request) { + fmt.Println("Request received") + fmt.Fprintf(w, "Request received\n") +} + +func (f *F) Ready(ctx context.Context) (bool, error) { + // Emulate a function which does not start immediately + if time.Now().After(f.Created.Add(600 * time.Millisecond)) { + return true, nil + } + return false, errors.New("still starting up") +} diff --git a/pkg/functions/testdata/testClientRunReadiness/go.mod b/pkg/functions/testdata/testClientRunReadiness/go.mod new file mode 100644 index 0000000000..8bf168fb8f --- /dev/null +++ b/pkg/functions/testdata/testClientRunReadiness/go.mod @@ -0,0 +1,3 @@ +module function + +go 1.17 diff --git a/pkg/functions/testdata/testClientRunTimeout/f.go b/pkg/functions/testdata/testClientRunTimeout/f.go new file mode 100644 index 0000000000..7fed8a2a2c --- /dev/null +++ b/pkg/functions/testdata/testClientRunTimeout/f.go @@ -0,0 +1,30 @@ +package f + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" +) + +type F struct { + Created time.Time +} + +func New() *F { + return &F{time.Now()} +} + +func (f *F) Handle(_ context.Context, w http.ResponseWriter, r *http.Request) { + fmt.Println("Request received") + fmt.Fprintf(w, "Request received\n") +} + +func (f *F) Ready(ctx context.Context) (bool, error) { + // Emulate a function which takes a few seconds to start up. + if time.Now().After(f.Created.Add(2 * time.Second)) { + return true, nil + } + return false, errors.New("still starting up") +} diff --git a/pkg/functions/testdata/testClientRunTimeout/go.mod b/pkg/functions/testdata/testClientRunTimeout/go.mod new file mode 100644 index 0000000000..8bf168fb8f --- /dev/null +++ b/pkg/functions/testdata/testClientRunTimeout/go.mod @@ -0,0 +1,3 @@ +module function + +go 1.17 diff --git a/pkg/mock/builder.go b/pkg/mock/builder.go index 22baa9d562..ab3599651f 100644 --- a/pkg/mock/builder.go +++ b/pkg/mock/builder.go @@ -17,7 +17,7 @@ func NewBuilder() *Builder { } } -func (i *Builder) Build(ctx context.Context, f fn.Function) error { +func (i *Builder) Build(ctx context.Context, f fn.Function, _ ...fn.BuildOption) error { i.BuildInvoked = true return i.BuildFn(f) } diff --git a/pkg/mock/runner.go b/pkg/mock/runner.go index 2311265555..3e2b7bfc35 100644 --- a/pkg/mock/runner.go +++ b/pkg/mock/runner.go @@ -26,7 +26,7 @@ func NewRunner() *Runner { } } -func (r *Runner) Run(ctx context.Context, f fn.Function) (*fn.Job, error) { +func (r *Runner) Run(ctx context.Context, f fn.Function, _ ...fn.RunOption) (*fn.Job, error) { r.Lock() defer r.Unlock() r.RunInvoked = true diff --git a/pkg/oci/builder.go b/pkg/oci/builder.go index e7e43a2c07..737ff84df5 100644 --- a/pkg/oci/builder.go +++ b/pkg/oci/builder.go @@ -17,16 +17,6 @@ import ( var path = filepath.Join -// TODO: This may no longer be necessary, delete if e2e and acceptance tests -// succeed: -// const DefaultName = builders.Host - -var defaultPlatforms = []v1.Platform{ - {OS: "linux", Architecture: "amd64"}, - {OS: "linux", Architecture: "arm64"}, - {OS: "linux", Architecture: "arm", Variant: "v7"}, -} - var defaultIgnored = []string{ // TODO: implement and use .funcignore ".git", ".func", @@ -34,25 +24,41 @@ var defaultIgnored = []string{ // TODO: implement and use .funcignore ".gitignore", } -// BuildErr indicates a build error occurred. -type BuildErr struct { - Err error -} - -func (e BuildErr) Error() string { - return fmt.Sprintf("error performing host build. %v", e.Err) -} - // Builder which creates an OCI-compliant multi-arch (index) container from // the function at path. type Builder struct { name string verbose bool + + tester *testHelper // used by tests to validate concurrency without races. } // NewBuilder creates a builder instance. func NewBuilder(name string, verbose bool) *Builder { - return &Builder{name, verbose} + return &Builder{name, verbose, nil} +} + +func newBuildConfig(ctx context.Context, b *Builder, f fn.Function, oo ...fn.BuildOption) *buildConfig { + c := &buildConfig{ + ctx, + b.name, + f, + time.Now(), + b.verbose, + "", + b.tester, + []v1.Platform{}, + fn.NewBuildOptions(oo...), + } + + // If the client did not specifically request a certain set of platforms, + // use the func core defined set of suggested defaults. + if len(c.options.Platforms) == 0 { + c.platforms = toPlatforms(fn.DefaultPlatforms) + } else { + c.platforms = toPlatforms(c.options.Platforms) + } + return c } // Build an OCI-compliant Mult-arch (v1.ImageIndex) container on disk @@ -63,8 +69,8 @@ func NewBuilder(name string, verbose bool) *Builder { // Updates a symlink to this directory at: // // .func/builds/last -func (b *Builder) Build(ctx context.Context, f fn.Function) (err error) { - cfg := &buildConfig{ctx, f, time.Now(), b.verbose, ""} +func (b *Builder) Build(ctx context.Context, f fn.Function, options ...fn.BuildOption) (err error) { + cfg := newBuildConfig(ctx, b, f, options...) if err = setup(cfg); err != nil { // create directories and links return @@ -87,22 +93,40 @@ func (b *Builder) Build(ctx context.Context, f fn.Function) (err error) { if err = containerize(cfg); err != nil { return } - return updateLastLink(cfg) + if err = updateLastLink(cfg); err != nil { + return + } // TODO: communicating build completeness throgh returning without error // relies on the implicit availability of the OIC image in this process' // build directory. Would be better to have a formal build result object // which includes a general struct which can be used by all builders to // communicate to the pusher where the image can be found. + // Tests, however, can use a simple channel: + if cfg.tester != nil && cfg.tester.notifyDone { + if cfg.verbose { + fmt.Println("tester configured to notify on done. Sending to unbuffered doneCh") + } + cfg.tester.doneCh <- true + fmt.Println("send to doneCh complete") + } + return } // buildConfig contains various settings for a single build type buildConfig struct { ctx context.Context // build context + name string // builder name f fn.Function // Function being built t time.Time // Timestamp for this build verbose bool // verbose logging h string // hash cache (use .hash() accessor) + tester *testHelper + // platforms is the final set of platforms to build for (note that the + // platforms on fn.BuildOptions denotes those specifically requested, and + // thus does not include defaulting) + platforms []v1.Platform + options fn.BuildOptions } func (c *buildConfig) hash() string { @@ -145,7 +169,7 @@ func (c *buildConfig) blobsDir() string { func setup(cfg *buildConfig) (err error) { // error if already in progress if isActive(cfg, cfg.buildDir()) { - return BuildErr{fmt.Errorf("Build directory already exists for this version hash and is associated with an active PID. Is a build already in progress? %v", cfg.buildDir())} + return ErrBuildInProgress{cfg.buildDir()} } // create build files directory @@ -175,10 +199,6 @@ func setup(cfg *buildConfig) (err error) { } func teardown(cfg *buildConfig) { - // remove the pid link for the current process indicating the build is - // no longer in progress. - _ = os.RemoveAll(cfg.pidLink()) - // remove pid links for processes which no longer exist. dd, _ := os.ReadDir(cfg.pidsDir()) for _, d := range dd { @@ -263,3 +283,40 @@ func updateLastLink(cfg *buildConfig) error { _ = os.RemoveAll(cfg.lastLink()) return os.Symlink(cfg.buildDir(), cfg.lastLink()) } + +type testHelper struct { + emulateSlowBuild bool + continueCh chan any + + notifyDone bool + doneCh chan any + + notifyPaused bool + pausedCh chan any +} + +func newTestHelper() *testHelper { + return &testHelper{ + continueCh: make(chan any), + doneCh: make(chan any), + pausedCh: make(chan any), + } +} + +// toPlatforms converts func's implementation-agnoztic Platform struct +// into to the OCI builder's implementation-specific go-containerregistry v1 +// palatform. +// Examples: +// {OS: "linux", Architecture: "amd64"}, +// {OS: "linux", Architecture: "arm64"}, +// {OS: "linux", Architecture: "arm", Variant: "v6"}, +// {OS: "linux", Architecture: "arm", Variant: "v7"}, +// {OS: "darwin", Architecture: "amd64"}, +// {OS: "darwin", Architecture: "arm64"}, +func toPlatforms(pp []fn.Platform) []v1.Platform { + platforms := make([]v1.Platform, len(pp)) + for i, p := range pp { + platforms[i] = v1.Platform{OS: p.OS, Architecture: p.Architecture, Variant: p.Variant} + } + return platforms +} diff --git a/pkg/oci/builder_test.go b/pkg/oci/builder_test.go index 806b3d83c4..8f49e453b8 100644 --- a/pkg/oci/builder_test.go +++ b/pkg/oci/builder_test.go @@ -3,6 +3,7 @@ package oci import ( "context" "encoding/json" + "errors" "os" "path/filepath" "testing" @@ -17,7 +18,7 @@ func TestBuilder(t *testing.T) { root, done := Mktemp(t) defer done() - client := fn.New() + client := fn.New(fn.WithVerbose(true)) f, err := client.Init(fn.Function{Root: root, Runtime: "go"}) if err != nil { @@ -35,6 +36,44 @@ func TestBuilder(t *testing.T) { validateOCI(last, t) } +// TestBuilder_Concurrency +func TestBuilder_Concurrency(t *testing.T) { + root, done := Mktemp(t) + defer done() + + client := fn.New() + + f, err := client.Init(fn.Function{Root: root, Runtime: "go"}) + if err != nil { + t.Fatal(err) + } + + // Start a build which pauses such that we can start a second. + builder1 := NewBuilder("builder1", true) + builder1.tester = newTestHelper() + builder1.tester.emulateSlowBuild = true + builder1.tester.notifyPaused = true + builder1.tester.notifyDone = true + go func() { + if err := builder1.Build(context.Background(), f, + fn.BuildWithPlatforms([]fn.Platform{{OS: "linux", Architecture: "arm64"}})); err != nil { + t.Fatal(err) + } + }() + <-builder1.tester.pausedCh // wait until it is paused + + builder2 := NewBuilder("builder2", true) + go func() { + err = builder2.Build(context.Background(), f, fn.BuildWithPlatforms([]fn.Platform{{OS: "linux", Architecture: "arm64"}})) + if !errors.As(err, &ErrBuildInProgress{}) { + t.Fatalf("Did not receive expected error. got %v", err) + + } + }() + builder1.tester.continueCh <- true // release the paused first builder + <-builder1.tester.doneCh // wait for it to be done +} + // ImageIndex represents the structure of an OCI Image Index. type ImageIndex struct { SchemaVersion int `json:"schemaVersion"` diff --git a/pkg/oci/containerize.go b/pkg/oci/containerize.go index 0306c82f85..1c04f7473b 100644 --- a/pkg/oci/containerize.go +++ b/pkg/oci/containerize.go @@ -78,7 +78,7 @@ func containerize(cfg *buildConfig) (err error) { // Create an image for each platform consisting of the shared data layer // and an os/platform specific layer. imageDescs := []v1.Descriptor{} - for _, p := range defaultPlatforms { // TODO: Configurable additions. + for _, p := range cfg.platforms { imageDesc, err := newImage(cfg, dataDesc, dataLayer, p, cfg.verbose) if err != nil { return err diff --git a/pkg/oci/containerize_go.go b/pkg/oci/containerize_go.go index 57dd345c42..2c2011afe7 100644 --- a/pkg/oci/containerize_go.go +++ b/pkg/oci/containerize_go.go @@ -21,7 +21,6 @@ type goLayerBuilder struct{} // the statically linked binary in a tarred layer and return the Descriptor // and Layer metadata. func (c goLayerBuilder) Build(cfg *buildConfig, p v1.Platform) (desc v1.Descriptor, layer v1.Layer, err error) { - // Executable exe, err := goBuild(cfg, p) // Compile binary returning its path if err != nil { @@ -55,6 +54,10 @@ func (c goLayerBuilder) Build(cfg *buildConfig, p v1.Platform) (desc v1.Descript } func goBuild(cfg *buildConfig, p v1.Platform) (binPath string, err error) { + if cfg.tester != nil && cfg.tester.emulateSlowBuild { + pauseBuildUntilReleased(cfg, p) + } + gobin, args, outpath, err := goBuildCmd(p, cfg) if err != nil { return @@ -74,6 +77,35 @@ func goBuild(cfg *buildConfig, p v1.Platform) (binPath string, err error) { return outpath, cmd.Run() } +func isFirstBuild(cfg *buildConfig, current v1.Platform) bool { + first := cfg.platforms[0] + return current.OS == first.OS && + current.Architecture == first.Architecture && + current.Variant == first.Variant + +} + +func pauseBuildUntilReleased(cfg *buildConfig, p v1.Platform) { + if cfg.verbose { + fmt.Println("test set to emulate slow build. checking if this build should be paused") + } + if !isFirstBuild(cfg, p) { + if cfg.verbose { + fmt.Println("not first build. will not pause") + } + return + } + if cfg.verbose { + fmt.Println("this is the first build: pausing awaiting release via cfg.tester.continueCh") + } + fmt.Printf("testing slow builds. %v paused\n", cfg.name) + if cfg.tester.notifyPaused { + cfg.tester.pausedCh <- true + } + <-cfg.tester.continueCh + fmt.Printf("continuing build\n") +} + func goBuildCmd(p v1.Platform, cfg *buildConfig) (gobin string, args []string, outpath string, err error) { /* TODO: Use Build Command override from the function if provided * A future PR will include the ability to specify a diff --git a/pkg/oci/errors.go b/pkg/oci/errors.go new file mode 100644 index 0000000000..9e6afd95b5 --- /dev/null +++ b/pkg/oci/errors.go @@ -0,0 +1,20 @@ +package oci + +import "fmt" + +// BuildErr indicates a general build error occurred. +type BuildErr struct { + Err error +} + +func (e BuildErr) Error() string { + return fmt.Sprintf("error performing host build. %v", e.Err) +} + +type ErrBuildInProgress struct { + Dir string +} + +func (e ErrBuildInProgress) Error() string { + return fmt.Sprintf("Build directory already exists for this version hash and is associated with an active PID. Is a build already in progress? %v", e.Dir) +} diff --git a/pkg/scaffolding/detectors.go b/pkg/scaffolding/detectors.go index b90d7739b3..9d0429c5c4 100644 --- a/pkg/scaffolding/detectors.go +++ b/pkg/scaffolding/detectors.go @@ -24,13 +24,17 @@ func newDetector(runtime string) (detector, error) { case "python": return &pythonDetector{}, nil case "rust": - return nil, errors.New("the Rust signature detector is not yet available") + return nil, ErrDetectorNotImplemented{runtime} case "node": - return nil, errors.New("the Node.js signature detector is not yet available") + return nil, ErrDetectorNotImplemented{runtime} + case "typescript": + return nil, ErrDetectorNotImplemented{runtime} case "quarkus": - return nil, errors.New("the TypeScript signature detector is not yet available") + return nil, ErrDetectorNotImplemented{runtime} + case "java": + return nil, ErrDetectorNotImplemented{runtime} default: - return nil, fmt.Errorf("unable to detect the signature of the unrecognized runtime language %q", runtime) + return nil, ErrRuntimeNotRecognized{runtime} } } @@ -41,8 +45,7 @@ type goDetector struct{} func (d goDetector) Detect(dir string) (static, instanced bool, err error) { files, err := os.ReadDir(dir) if err != nil { - err = fmt.Errorf("signature detector encountered an error when scanning the function's source code. %w", err) - return + return static, instanced, fmt.Errorf("signature detector encountered an error when scanning the function's source code. %w", err) } for _, file := range files { filename := filepath.Join(dir, file.Name()) diff --git a/pkg/scaffolding/errors.go b/pkg/scaffolding/errors.go new file mode 100644 index 0000000000..2062107ecb --- /dev/null +++ b/pkg/scaffolding/errors.go @@ -0,0 +1,38 @@ +package scaffolding + +import "fmt" + +type ScaffoldingError struct { + Msg string + Err error +} + +func (e ScaffoldingError) Error() string { + if e.Msg != "" { + return fmt.Sprintf("scaffolding error. %v. %v", e.Msg, e.Err) + } + return fmt.Sprintf("scaffolding error %v", e.Err) +} + +func (e ScaffoldingError) Unwrap() error { + return e.Err +} + +var ErrScaffoldingNotFound = ScaffoldingError{"scaffolding not found", nil} +var ErrSignatureNotFound = ScaffoldingError{"supported signature not found", nil} + +type ErrDetectorNotImplemented struct { + Runtime string +} + +func (e ErrDetectorNotImplemented) Error() string { + return fmt.Sprintf("the %v signature detector is not yet available", e.Runtime) +} + +type ErrRuntimeNotRecognized struct { + Runtime string +} + +func (e ErrRuntimeNotRecognized) Error() string { + return fmt.Sprintf("signature not found. The runtime %v is not recognized", e.Runtime) +} diff --git a/pkg/scaffolding/scaffold.go b/pkg/scaffolding/scaffold.go index c7c52f9202..0dee66da26 100644 --- a/pkg/scaffolding/scaffold.go +++ b/pkg/scaffolding/scaffold.go @@ -40,19 +40,19 @@ func Write(out, src, runtime, invoke string, fs filesystem.Filesystem) (err erro // Path in the filesystem at which scaffolding is expected to exist d := fmt.Sprintf("%v/scaffolding/%v", runtime, s.String()) // fs uses / on all OSs if _, err := fs.Stat(d); err != nil { - return fmt.Errorf("no scaffolding found for %s signature %s. %w", runtime, s, err) + return ErrScaffoldingNotFound } // Copy from d -> out from the filesystem if err := filesystem.CopyFromFS(d, out, fs); err != nil { - return err + return ScaffoldingError{"filesystem copy failed", err} } // Replace the 'f' link of the scaffolding (which is now incorrect) to // link to the function's root. rel, err := filepath.Rel(out, src) if err != nil { - return fmt.Errorf("error determining relative path to function source %w", err) + return ScaffoldingError{"error determining relative path to function source", err} } link := filepath.Join(out, "f") _ = os.Remove(link) diff --git a/pkg/scaffolding/scaffold_test.go b/pkg/scaffolding/scaffold_test.go index af1c3ad757..4dc0c1b980 100644 --- a/pkg/scaffolding/scaffold_test.go +++ b/pkg/scaffolding/scaffold_test.go @@ -4,6 +4,7 @@ package scaffolding import ( + "errors" "os" "path/filepath" "testing" @@ -13,6 +14,42 @@ import ( . "knative.dev/func/pkg/testing" ) +// TestWrite_RuntimeErrors ensures that known runtimes which are not +// yet implemented return a "not yet available" message, and unrecognized +// runtimes state as much. +func TestWrite_RuntimeErrors(t *testing.T) { + tests := []struct { + Runtime string + Expected any + }{ + {"go", nil}, + {"python", nil}, + {"rust", &ErrDetectorNotImplemented{}}, + {"node", &ErrDetectorNotImplemented{}}, + {"typescript", &ErrDetectorNotImplemented{}}, + {"quarkus", &ErrDetectorNotImplemented{}}, + {"java", &ErrDetectorNotImplemented{}}, + {"other", &ErrRuntimeNotRecognized{}}, + } + for _, test := range tests { + t.Run(test.Runtime, func(t *testing.T) { + // Since runtime validation during signature detection is the very first + // thing that occurs, we can elide most of the setup and pass zero + // values for source directory, output directory and invocation. + // This may need to be expanded in the event the Write function is + // expanded to have more preconditions. + err := Write("", "", test.Runtime, "", nil) + if test.Expected != nil && err == nil { + t.Fatalf("expected runtime %v to yield a detection error", test.Runtime) + } + if test.Expected != nil && !errors.As(err, test.Expected) { + t.Fatalf("did not receive expected error type for %v runtime.", test.Runtime) + } + t.Logf("ok: %v", err) + }) + } +} + // TestWrite ensures that the Write method writes Scaffolding to the given // destination. This is a failry shallow test. See the Scaffolding and // Detector tests for more depth. @@ -26,7 +63,7 @@ func TestWrite(t *testing.T) { if err != nil { t.Fatal(err) } - fs := filesystem.NewOsFilesystem(filepath.Join(cwd, "testdata")) + fs := filesystem.NewOsFilesystem(filepath.Join(cwd, "testdata", "testwrite")) root, done := Mktemp(t) defer done() @@ -78,3 +115,55 @@ func New() *F { return nil } } } + +// TestWrite_ScaffoldingNotFound ensures that a typed error is returned +// when scaffolding is not found. +func TestWrite_ScaffoldingNotFound(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + fs := filesystem.NewOsFilesystem(filepath.Join(cwd, "testdata", "testnotfound")) + + root, done := Mktemp(t) + defer done() + + impl := ` +package f + +type F struct{} + +func New() *F { return nil } +` + err = os.WriteFile(filepath.Join(root, "f.go"), []byte(impl), os.ModePerm) + if err != nil { + t.Fatal(err) + } + + out := filepath.Join(root, "out") + + err = Write(out, root, "go", "", fs) + if err == nil { + t.Fatal("did not receive expected error") + } + if !errors.Is(err, ErrScaffoldingNotFound) { + t.Fatalf("error received was not ErrScaffoldingNotFound. %v", err) + } +} + +// TestNewScaffoldingError ensures that a scaffolding error wraps its +// underlying error such that callers can use errors.Is/As. +func TestNewScaffoldingError(t *testing.T) { + + // exampleError that would come from something scaffolding employs to + // accomplish a task + var ExampleError = errors.New("example error") + + err := ScaffoldingError{"some ExampleError", ExampleError} + + if !errors.Is(err, ExampleError) { + t.Fatalf("type ScaffoldingError does not wrap errors.") + } + t.Logf("ok: %v", err) + +} diff --git a/pkg/scaffolding/testdata/testnotfound/go/README.md b/pkg/scaffolding/testdata/testnotfound/go/README.md new file mode 100644 index 0000000000..1d2c2dbf08 --- /dev/null +++ b/pkg/scaffolding/testdata/testnotfound/go/README.md @@ -0,0 +1,3 @@ +# TestNotFound + +An example of a template runtime directory which lacks scaffolding. diff --git a/pkg/scaffolding/testdata/go/scaffolding/instanced-http/main.go b/pkg/scaffolding/testdata/testwrite/go/scaffolding/instanced-http/main.go similarity index 100% rename from pkg/scaffolding/testdata/go/scaffolding/instanced-http/main.go rename to pkg/scaffolding/testdata/testwrite/go/scaffolding/instanced-http/main.go diff --git a/schema/func_yaml-schema.json b/schema/func_yaml-schema.json index 81d1157643..3d994dbfd2 100644 --- a/schema/func_yaml-schema.json +++ b/schema/func_yaml-schema.json @@ -306,6 +306,9 @@ "type": "object" }, "RunSpec": { + "required": [ + "StartTimeout" + ], "properties": { "volumes": { "items": { @@ -321,6 +324,10 @@ }, "type": "array", "description": "Env variables to be set" + }, + "StartTimeout": { + "type": "integer", + "description": "StartTimeout specifies that this function should have a custom timeout\nset for starting. This is useful for functons which have a long\nstartup time, such as loading large models into system memory.\nNote this is currently only respected by the func client\nrunner. Ksvc integration is in development." } }, "additionalProperties": false,