feat!: invoke (#705)

* feat!: rename 'emit' to 'invoke' and default to local

This commit renames 'func emit' command to 'func invoke' and makes the
default behavior to send an event to localhost. The special '--sink'
value 'local' is changed to 'cluster' to indicate that the function
should be invoked on the cluster instead of locally. All other behavior
has remained the same.

BREAKING CHANGE

Signed-off-by: Lance Ball <lball@redhat.com>

* fixup: update commands.md doc

Signed-off-by: Lance Ball <lball@redhat.com>

* squash: change Emitter interface to Invoker

Changes Emit() to Send() in the (now named) Invoker interface, and changes
Emit() to Invoke() in the client.

BREAKING CHANGE

Signed-off-by: Lance Ball <lball@redhat.com>

* squash: use a common Invoker interface for HTTP and events

Signed-off-by: Lance Ball <lball@redhat.com>

* checkpoint

Signed-off-by: Lance Ball <lball@redhat.com>

* fixup: change Emitter to EventInvoker

Signed-off-by: Lance Ball <lball@redhat.com>

* Invoke v2 Draft

* feat: client invoke function

* static invoke defaults and methods

* remove assimilated invoker package

* includes an ignored .func directory on create

* Instances manager with local and remote defaults

Funciton Info is now Instance, representing a Function in a given
environment.

Describing a Function instance is now Instances().Get(f, environment)

Moves Runner to be async with a Stop method to enable returning runtime pid
and port for persisting.

Instances now have a place for primary Route in addition to all routes slice

Running Functions write PID and Port to .func

* cascading targets: local vs remote vs ad-hoc endpoint

* runner start signals and cancel cleanup

* return run on context done or err on channel

* async runner

Refactors the image runner to start the container asynchronously,
reporting back the port on which it started.  Errors are communicated
back via a provided channel and stop is signaled using context
cancelation.

* pid neither required nor available

* add withTransport option

Incorporates addition of custom transport of the emitter into the
renamed version invoker. Flag and help text cleanup.  Re-additionof the
Info accessor.

* schema now includes invocation data

* loop build msg

* run jobs

Externally exposed port is now chosen based on availability, with 8080
preferred and falling back to an os-chosen open port.
The Client Run method is now async, returning the port assigned to the
running Function, a stop/cleanup function and a runtime errors channel.
The Runner is internally divided into the runner and its started Jobs.

* job metadata

Extracts job metadata tracking to a Job object in the core,
Handles multiple instances of the same Function by creating a single
file for each instances in .func/instances/<port>

* remove superfluous error types and flag bindings

* feat: enable invoke target remote

* feat: preferentially invoke local, remote if running

* feat: read --file for invoke

* feat: invoke confirm prompts

* fixup cli tests

- Updates to handle asynchronous Runner
- Standardize on the naming convention for selective running

* docker runner tests and lint errors

* test refactor

* feat: invoke format override

* comments, spelling and other cleanup

* invoke command doc

* feat: invoke format interactive option

* rename runjob.go to job.go

* e2e test flag update

* test naming homoginization

* silence build activity messages when verbose

* test debugging

* code review updates

- return Job from Client.Run rather than constituent members
- Treat .gitignore as contentious, punting on feature to mutate if
  extant.
- docs wording changes
- add invocation format to pertinent manifest.yaml files

* help text spelling etc.

Co-authored-by: Lance Ball <lball@redhat.com>
This commit is contained in:
Luke Kingland 2022-01-22 05:04:05 +09:00 committed by GitHub
parent 8ceb325142
commit e918f74b9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
47 changed files with 2162 additions and 1048 deletions

227
client.go
View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
@ -52,10 +53,11 @@ type Client struct {
dnsProvider DNSProvider // Provider of DNS services
registry string // default registry for OCI image tags
progressListener ProgressListener // progress listener
emitter Emitter // Emits CloudEvents to functions
pipelinesProvider PipelinesProvider // Manages lifecyle of CI/CD pipelines used by a Function
repositories *Repositories // Repositories management
templates *Templates // Templates management
instances *Instances // Function Instances management
transport http.RoundTripper // Customizable internal transport
pipelinesProvider PipelinesProvider // CI/CD pipelines management
}
// ErrNotBuilt indicates the Function has not yet been built.
@ -96,8 +98,10 @@ const (
// Runner runs the Function locally.
type Runner interface {
// Run the Function locally.
Run(context.Context, Function) error
// 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)
}
// Remover of deployed services.
@ -138,18 +142,30 @@ type ProgressListener interface {
Done()
}
// Describer of Functions' remote deployed aspect.
// Describer of Function instances
type Describer interface {
// Describe the running state of the service as reported by the underlyng platform.
Describe(ctx context.Context, name string) (description Info, err error)
// Describe the named Function in the remote environment.
Describe(ctx context.Context, name string) (Instance, error)
}
// Info about a given Function
type Info struct {
// Instance data about the runtime state of a Function in a given environment.
//
// A Function instance is a logical running Function space, which share
// a unique route (or set of routes). Due to autoscaling and load balancing,
// there is a one to many relationship between a given route and processes.
// By default the system creates the 'local' and 'remote' named instances
// when a Function is run (locally) and deployed, respectively.
// See the .Instances(f) accessor for the map of named environments to these
// Function Information structures.
type Instance struct {
// Route is the primary route of a Function instance.
Route string
// Routes is the primary route plus any other route at which the Function
// can be contacted.
Routes []string `json:"routes" yaml:"routes"`
Name string `json:"name" yaml:"name"`
Image string `json:"image" yaml:"image"`
Namespace string `json:"namespace" yaml:"namespace"`
Routes []string `json:"routes" yaml:"routes"`
Subscriptions []Subscription `json:"subscriptions" yaml:"subscriptions"`
}
@ -166,11 +182,6 @@ type DNSProvider interface {
Provide(Function) error
}
// Emitter emits CloudEvents to functions
type Emitter interface {
Emit(ctx context.Context, endpoint string) error
}
// PipelinesProvider manages lifecyle of CI/CD pipelines used by a Function
type PipelinesProvider interface {
Run(context.Context, Function) error
@ -186,18 +197,21 @@ func New(options ...Option) *Client {
runner: &noopRunner{output: os.Stdout},
remover: &noopRemover{output: os.Stdout},
lister: &noopLister{output: os.Stdout},
describer: &noopDescriber{output: os.Stdout},
dnsProvider: &noopDNSProvider{output: os.Stdout},
progressListener: &NoopProgressListener{},
emitter: &noopEmitter{},
pipelinesProvider: &noopPipelinesProvider{},
repositoriesPath: filepath.Join(ConfigPath(), "repositories"),
transport: http.DefaultTransport,
}
for _, o := range options {
o(c)
}
// Initialize sub-managers using now-fully-initialized client.
c.repositories = newRepositories(c)
c.templates = newTemplates(c)
c.instances = newInstances(c)
// Trigger the creation of the config and repository paths
_ = ConfigPath() // Config is package-global scoped
@ -355,11 +369,10 @@ func WithRegistry(registry string) Option {
}
}
// WithEmitter sets a CloudEvent emitter on the client which is capable of sending
// a CloudEvent to an arbitrary function endpoint
func WithEmitter(e Emitter) Option {
// WithTransport sets a custom transport to use internally.
func WithTransport(t http.RoundTripper) Option {
return func(c *Client) {
c.emitter = e
c.transport = t
}
}
@ -383,6 +396,11 @@ func (c *Client) Templates() *Templates {
return c.templates
}
// Instances accessor
func (c *Client) Instances() *Instances {
return c.instances
}
// Runtimes available in totality.
// Not all repository/template combinations necessarily exist,
// and further validation is performed when a template+runtime is chosen.
@ -407,8 +425,8 @@ func (c *Client) Runtimes() ([]string, error) {
return runtimes.Items(), nil
}
// METHODS
// ---------
// LIFECYCLE METHODS
// -----------------
// New Function.
// Use Create, Build and Deploy independently for lower level control.
@ -497,12 +515,6 @@ func (c *Client) Create(cfg Function) (err error) {
return fmt.Errorf("Function at '%v' already initialized", cfg.Root)
}
// The path for the new Function should not have any contentious files
// (hidden files OK, unless it's one used by Func)
if err := assertEmptyRoot(cfg.Root); err != nil {
return err
}
// Path is defaulted to the current working directory
if cfg.Root == "" {
if cfg.Root, err = os.Getwd(); err != nil {
@ -515,9 +527,20 @@ func (c *Client) Create(cfg Function) (err error) {
cfg.Name = nameFromPath(cfg.Root)
}
// Create a new Function
// The path for the new Function should not have any contentious files
// (hidden files OK, unless it's one used by Func)
if err := assertEmptyRoot(cfg.Root); err != nil {
return err
}
// Create a new Function (in memory)
f := NewFunctionWith(cfg)
// Create a .func diretory which is also added to a .gitignore
if err = createRuntimeDir(f); err != nil {
return
}
// Write out the new Function's Template files.
// Templates contain values which may result in the Function being mutated
// (default builders, etc), so a new (potentially mutated) Function is
@ -548,33 +571,36 @@ func (c *Client) Create(cfg Function) (err error) {
return
}
// createRuntimeDir creates a .func directory in the root of the given
// Function which is also registered as ignored in .gitignore
// TODO: Mutate extant .gitignore file if it exists rather than failing
// if present (see contentious files in function.go), such that a user
// can `git init` a directory prior to `func init` in the same directory).
func createRuntimeDir(f Function) error {
if err := os.MkdirAll(filepath.Join(f.Root, RunDataDir), os.ModePerm); err != nil {
return err
}
gitignore := `
# Functions use the .func directory for local runtime data which should
# generally not be tracked in source control:
/.func
`
return os.WriteFile(filepath.Join(f.Root, ".gitignore"), []byte(gitignore), os.ModePerm)
}
// 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, path string) (err error) {
c.progressListener.Increment("Building function image")
m := []string{
"Still building",
"Don't give up on me",
"This is taking a while",
"Still building"}
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
go func() {
for {
<-ticker.C
if len(m) == 0 {
break
}
c.progressListener.Increment(m[0])
m = m[1:] // remove 0th element
}
}()
go func() {
<-ctx.Done()
c.progressListener.Stopping()
}()
// If not logging verbosely, the ongoing progress of the build will not
// be streaming to stdout, and the lack of activity has been seen to cause
// users to prematurely exit due to the sluggishness of pulling large images
if !c.verbose {
c.printBuildActivity(ctx) // print friendly messages until context is canceled
}
f, err := NewFunction(path)
if err != nil {
@ -606,6 +632,33 @@ func (c *Client) Build(ctx context.Context, path string) (err error) {
return
}
func (c *Client) printBuildActivity(ctx context.Context) {
m := []string{
"Still building",
"Still building",
"Yes, still building",
"Don't give up on me",
"Still building",
"This is taking a while",
}
i := 0
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
go func() {
for {
select {
case <-ticker.C:
c.progressListener.Increment(m[i])
i++
i = i % len(m)
case <-ctx.Done():
c.progressListener.Stopping()
return
}
}
}()
}
// Deploy the Function at path. Errors if the Function has not been
// initialized with an image tag.
func (c *Client) Deploy(ctx context.Context, path string) (err error) {
@ -674,36 +727,39 @@ func (c *Client) Route(path string) (err error) {
}
// Run the Function whose code resides at root.
func (c *Client) Run(ctx context.Context, root string) error {
// On start, the chosen port is sent to the provided started channel
func (c *Client) Run(ctx context.Context, root string) (job *Job, err error) {
go func() {
<-ctx.Done()
c.progressListener.Stopping()
}()
// Create an instance of a Function representation at the given root.
// Load the Function
f, err := NewFunction(root)
if err != nil {
return err
return
}
if !f.Initialized() {
// TODO: this needs a test.
return fmt.Errorf("the given path '%v' does not contain an initialized Function. Please create one at this path in order to run", root)
err = fmt.Errorf("the given path '%v' does not contain an initialized "+
"Function. Please create one at this path in order to run", root)
return
}
// delegate to concrete implementation of runner entirely.
return c.runner.Run(ctx, f)
}
// 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 {
return
}
// List currently deployed Functions.
func (c *Client) List(ctx context.Context) ([]ListItem, error) {
// delegate to concrete implementation of lister entirely.
return c.lister.List(ctx)
// Return to the caller the effective port, a function to call to trigger
// stop, and a channel on which can be received runtime errors.
return job, nil
}
// Info for a Function. Name takes precidence. If no name is provided,
// the Function defined at root is used.
func (c *Client) Info(ctx context.Context, name, root string) (d Info, err error) {
func (c *Client) Info(ctx context.Context, name, root string) (d Instance, err error) {
go func() {
<-ctx.Done()
c.progressListener.Stopping()
@ -724,6 +780,12 @@ func (c *Client) Info(ctx context.Context, name, root string) (d Info, err error
return c.describer.Describe(ctx, f.Name)
}
// List currently deployed Functions.
func (c *Client) List(ctx context.Context) ([]ListItem, error) {
// delegate to concrete implementation of lister entirely.
return c.lister.List(ctx)
}
// Remove a Function. Name takes precidence. If no name is provided,
// the Function defined at root is used if it exists.
func (c *Client) Remove(ctx context.Context, cfg Function) error {
@ -747,13 +809,32 @@ func (c *Client) Remove(ctx context.Context, cfg Function) error {
return c.remover.Remove(ctx, f.Name)
}
// Emit a CloudEvent to a function endpoint
func (c *Client) Emit(ctx context.Context, endpoint string) error {
// Invoke is a convenience method for triggering the execution of a Function
// for testing and development.
// The target argument is optional, naming the running instance of the Function
// which should be invoked. This can be the literal names "local" or "remote",
// or can be a URL to an arbitrary endpoint. If not provided, a running local
// instance is preferred, with the remote Function triggered if there is no
// locally running instance.
// Example:
// myClient.Invoke(myContext, myFunction, "local", NewInvokeMessage())
// The message sent to the Function is defined by the invoke message.
// See NewInvokeMessage for its defaults.
// Functions are invoked in a manner consistent with the settings defined in
// their metadata. For example HTTP vs CloudEvent
func (c *Client) Invoke(ctx context.Context, root string, target string, m InvokeMessage) (err error) {
go func() {
<-ctx.Done()
c.progressListener.Stopping()
}()
return c.emitter.Emit(ctx, endpoint)
f, err := NewFunction(root)
if err != nil {
return
}
// See invoke.go for implementation details
return invoke(ctx, c, f, target, m)
}
// Push the image for the named service to the configured registry
@ -812,7 +893,9 @@ func (n *noopDeployer) Deploy(ctx context.Context, _ Function) (DeploymentResult
// Runner
type noopRunner struct{ output io.Writer }
func (n *noopRunner) Run(_ context.Context, _ Function) error { return nil }
func (n *noopRunner) Run(context.Context, Function) (job *Job, err error) {
return
}
// Remover
type noopRemover struct{ output io.Writer }
@ -824,10 +907,12 @@ type noopLister struct{ output io.Writer }
func (n *noopLister) List(context.Context) ([]ListItem, error) { return []ListItem{}, nil }
// Emitter
type noopEmitter struct{}
// Describer
type noopDescriber struct{ output io.Writer }
func (n *noopEmitter) Emit(ctx context.Context, endpoint string) error { return nil }
func (n *noopDescriber) Describe(context.Context, string) (Instance, error) {
return Instance{}, errors.New("no describer provided")
}
// PipelinesProvider
type noopPipelinesProvider struct{}

View File

@ -4,16 +4,20 @@
package function_test
import (
"bufio"
"context"
"errors"
"fmt"
"io/ioutil"
"net"
"net/http"
"os"
"path/filepath"
"reflect"
"testing"
"time"
cloudevents "github.com/cloudevents/sdk-go/v2"
fn "knative.dev/kn-plugin-func"
"knative.dev/kn-plugin-func/mock"
. "knative.dev/kn-plugin-func/testing"
@ -30,11 +34,11 @@ const (
TestRuntime = "go"
)
// TestNew Function completes without error using defaults and zero values.
// 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
// by the client API for those who prefer manual transmissions.
func TestNew(t *testing.T) {
func TestClient_New(t *testing.T) {
root := "testdata/example.com/testNew"
defer Using(t, root)()
@ -45,11 +49,11 @@ func TestNew(t *testing.T) {
}
}
// TestInstantiationCreatesRepositoriesPath ensures that instantiating the
// TestClient_InstantiationCreatesRepositoriesPath ensures that instantiating the
// client has the side-effect of ensuring that the repositories path exists
// on-disk, and also confirms that the XDG_CONFIG_HOME environment variable is
// respected when calculating this home path.
func TestInstantiationCreatesRepositoriesPath(t *testing.T) {
func TestClient_InstantiationCreatesRepositoriesPath(t *testing.T) {
root := "testdata/example.com/testNewCreatesRepositoriesPath"
defer Using(t, root)()
@ -74,8 +78,8 @@ func TestInstantiationCreatesRepositoriesPath(t *testing.T) {
}
}
// TestRuntimeRequired ensures that the the runtime is an expected value.
func TestRuntimeRequired(t *testing.T) {
// TestClient_New_RuntimeRequired ensures that the the runtime is an expected value.
func TestClient_New_RuntimeRequired(t *testing.T) {
// Create a root for the new Function
root := "testdata/example.com/testRuntimeRequired"
defer Using(t, root)()
@ -89,9 +93,9 @@ func TestRuntimeRequired(t *testing.T) {
}
}
// TestNameDefaults ensures that a newly created Function has its name defaulted
// TestClient_New_NameDefaults ensures that a newly created Function has its name defaulted
// to a name which can be dervied from the last part of the given root path.
func TestNameDefaults(t *testing.T) {
func TestClient_New_NameDefaults(t *testing.T) {
root := "testdata/example.com/testNameDefaults"
defer Using(t, root)()
@ -118,9 +122,9 @@ func TestNameDefaults(t *testing.T) {
}
}
// TestWritesTemplate ensures the config file and files from the template
// TestClient_New_WritesTemplate ensures the config file and files from the template
// are written on new.
func TestWritesTemplate(t *testing.T) {
func TestClient_New_WritesTemplate(t *testing.T) {
root := "testdata/example.com/testWritesTemplate"
defer Using(t, root)()
@ -141,9 +145,9 @@ func TestWritesTemplate(t *testing.T) {
}
}
// TestExtantAborts ensures that a directory which contains an extant
// TestClient_New_ExtantAborts ensures that a directory which contains an extant
// Function does not reinitialize.
func TestExtantAborts(t *testing.T) {
func TestClient_New_ExtantAborts(t *testing.T) {
root := "testdata/example.com/testExtantAborts"
defer Using(t, root)()
@ -160,9 +164,9 @@ func TestExtantAborts(t *testing.T) {
}
}
// TestNonemptyAborts ensures that a directory which contains any
// TestClient_New_NonemptyAborts ensures that a directory which contains any
// (visible) files aborts.
func TestNonemptyAborts(t *testing.T) {
func TestClient_New_NonemptyAborts(t *testing.T) {
root := "testdata/example.com/testNonemptyAborts"
defer Using(t, root)()
@ -180,12 +184,12 @@ func TestNonemptyAborts(t *testing.T) {
}
}
// TestHiddenFilesIgnored ensures that initializing in a directory that
// TestClient_New_HiddenFilesIgnored ensures that initializing in a directory that
// only contains hidden files does not error, protecting against the naieve
// implementation of aborting initialization if any files exist, which would
// break functions tracked in source control (.git), or when used in
// conjunction with other tools (.envrc, etc)
func TestHiddenFilesIgnored(t *testing.T) {
func TestClient_New_HiddenFilesIgnored(t *testing.T) {
// Create a directory for the Function
root := "testdata/example.com/testHiddenFilesIgnored"
defer Using(t, root)()
@ -204,7 +208,7 @@ func TestHiddenFilesIgnored(t *testing.T) {
}
}
// TestRepositoriesExtensible ensures that templates are extensible
// TestClient_New_RepositoriesExtensible ensures that templates are extensible
// using a custom path to template repositories on disk. The custom repositories
// location is not defined herein but expected to be provided because, for
// example, a CLI may want to use XDG_CONFIG_HOME. Assuming a repository path
@ -213,7 +217,7 @@ func TestHiddenFilesIgnored(t *testing.T) {
// $FUNC_REPOSITORIES/boson/go/json
// See the CLI for full details, but a standard default location is
// $HOME/.config/func/repositories/boson/go/json
func TestRepositoriesExtensible(t *testing.T) {
func TestClient_New_RepositoriesExtensible(t *testing.T) {
root := "testdata/example.com/testRepositoriesExtensible"
defer Using(t, root)()
@ -234,8 +238,9 @@ func TestRepositoriesExtensible(t *testing.T) {
}
}
// TestRuntimeNotFound generates an error (embedded default repository).
func TestRuntimeNotFound(t *testing.T) {
// TestRuntime_New_RuntimeNotFoundError generates an error when the provided
// runtime is not fo0und (embedded default repository).
func TestClient_New_RuntimeNotFoundError(t *testing.T) {
root := "testdata/example.com/testRuntimeNotFound"
defer Using(t, root)()
@ -249,9 +254,9 @@ func TestRuntimeNotFound(t *testing.T) {
}
}
// TestRuntimeNotFoundCustom ensures that the correct error is returned
// TestClient_New_RuntimeNotFoundCustom ensures that the correct error is returned
// when the requested runtime is not found in a given custom repository
func TestRuntimeNotFoundCustom(t *testing.T) {
func TestClient_New_RuntimeNotFoundCustom(t *testing.T) {
root := "testdata/example.com/testRuntimeNotFoundCustom"
defer Using(t, root)()
@ -271,8 +276,8 @@ func TestRuntimeNotFoundCustom(t *testing.T) {
}
}
// TestTemplateNotFound generates an error (embedded default repository).
func TestTemplateNotFound(t *testing.T) {
// TestClient_New_TemplateNotFoundError generates an error (embedded default repository).
func TestClient_New_TemplateNotFoundError(t *testing.T) {
root := "testdata/example.com/testTemplateNotFound"
defer Using(t, root)()
@ -287,9 +292,9 @@ func TestTemplateNotFound(t *testing.T) {
}
}
// TestTemplateNotFoundCustom ensures that the correct error is returned
// TestClient_New_TemplateNotFoundCustom ensures that the correct error is returned
// when the requested template is not found in the given custom repository.
func TestTemplateNotFoundCustom(t *testing.T) {
func TestClient_New_TemplateNotFoundCustom(t *testing.T) {
root := "testdata/example.com/testTemplateNotFoundCustom"
defer Using(t, root)()
@ -308,9 +313,9 @@ func TestTemplateNotFoundCustom(t *testing.T) {
}
}
// TestNamed ensures that an explicitly passed name is used in leau of the
// TestClient_New_Named ensures that an explicitly passed name is used in leau of the
// path derived name when provided, and persists through instantiations.
func TestNamed(t *testing.T) {
func TestClient_New_Named(t *testing.T) {
// Explicit name to use
name := "service.example.com"
@ -335,7 +340,7 @@ func TestNamed(t *testing.T) {
}
}
// TestRegistryRequired ensures that a registry is required, and is
// TestClient_New_RegistryRequired ensures that a registry is required, and is
// prepended with the DefaultRegistry if a single token.
// Registry is the namespace at the container image registry.
// If not prepended with the registry, it will be defaulted:
@ -345,7 +350,7 @@ func TestNamed(t *testing.T) {
// At this time a registry namespace is required as we rely on a third-party
// registry in all cases. When we support in-cluster container registries,
// this configuration parameter will become optional.
func TestRegistryRequired(t *testing.T) {
func TestClient_New_RegistryRequired(t *testing.T) {
// Create a root for the Function
root := "testdata/example.com/testRegistryRequired"
defer Using(t, root)()
@ -358,10 +363,10 @@ func TestRegistryRequired(t *testing.T) {
fmt.Println(err)
}
// TestDeriveImage ensures that the full image (tag) of the resultant OCI
// TestClient_New_ImageNameDerived ensures that the full image (tag) of the resultant OCI
// container is populated based of a derivation using configured registry
// plus the service name.
func TestDeriveImage(t *testing.T) {
func TestClient_New_ImageNameDerived(t *testing.T) {
// Create the root Function directory
root := "testdata/example.com/testDeriveImage"
defer Using(t, root)()
@ -385,10 +390,10 @@ func TestDeriveImage(t *testing.T) {
}
}
// TestDeriveImageDefaultRegistry ensures that a Registry which does not have
// TestCleint_New_ImageRegistryDefaults ensures that a Registry which does not have
// a registry prefix has the DefaultRegistry prepended.
// For example "alice" becomes "docker.io/alice"
func TestDeriveImageDefaultRegistry(t *testing.T) {
func TestClient_New_ImageRegistryDefaults(t *testing.T) {
// Create the root Function directory
root := "testdata/example.com/testDeriveImageDefaultRegistry"
defer Using(t, root)()
@ -414,10 +419,10 @@ func TestDeriveImageDefaultRegistry(t *testing.T) {
}
}
// TestDelegation ensures that Create invokes each of the individual
// TestClient_New_Delegation ensures that Create invokes each of the individual
// subcomponents via delegation through Build, Push and
// Deploy (and confirms expected fields calculated).
func TestNewDelegates(t *testing.T) {
func TestClient_New_Delegation(t *testing.T) {
var (
root = "testdata/example.com/testNewDelegates" // .. in which to initialize
expectedName = "testNewDelegates" // expected to be derived
@ -488,13 +493,14 @@ func TestNewDelegates(t *testing.T) {
}
}
// TestRun ensures that the runner is invoked with the absolute path requested.
func TestRun(t *testing.T) {
// TestClient_Run ensures that the runner is invoked with the absolute path requested.
// Implicitly checks that the stop fn returned also is respected.
func TestClient_Run(t *testing.T) {
// Create the root Function directory
root := "testdata/example.com/testRun"
defer Using(t, root)()
// Create a client with the mock runner and the new test Function
// client with the mock runner and the new test Function
runner := mock.NewRunner()
client := fn.New(fn.WithRegistry(TestRegistry), fn.WithRunner(runner))
if err := client.New(context.Background(), fn.Function{Runtime: TestRuntime, Root: root}); err != nil {
@ -502,9 +508,14 @@ func TestRun(t *testing.T) {
}
// Run the newly created function
if err := client.Run(context.Background(), root); err != nil {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
job, err := client.Run(ctx, root)
if err != nil {
t.Fatal(err)
}
defer job.Stop()
// Assert the runner was invoked, and with the expected root.
if !runner.RunInvoked {
@ -515,9 +526,54 @@ func TestRun(t *testing.T) {
}
}
// TestUpdate ensures that the deployer properly invokes the build/push/deploy
// TestClient_Run_DataDir ensures that when a Function is created, it also
// includes a .func (runtime data) directory which is registered as ignored for
// Functions which will be tracked in git source control.
// Note that this test is somewhat testing an implementation detail of `.Run(`
// (it writes runtime data to files in .func) but since the feature of adding
// .func to .gitignore is an important externally visible "feature", an explicit
// test is warranted.
func TestClient_Run_DataDir(t *testing.T) {
root := "testdata/example.com/testRunDataDir"
defer Using(t, root)()
// Create a function at root
client := fn.New(fn.WithRegistry(TestRegistry))
if err := client.New(context.Background(), fn.Function{Root: root, Runtime: TestRuntime}); err != nil {
t.Fatal(err)
}
// Assert the directory exists
if _, err := os.Stat(filepath.Join(root, fn.RunDataDir)); os.IsNotExist(err) {
t.Fatal(err)
}
// Assert that .gitignore was also created and includes an ignore directove
// for the .func directory
if _, err := os.Stat(filepath.Join(root, ".gitignore")); os.IsNotExist(err) {
t.Fatal(err)
}
// Assert that .func is ignored
file, err := os.Open(filepath.Join(root, ".gitignore"))
if err != nil {
t.Fatal(err)
}
defer file.Close()
// Assert the directive exists
scanner := bufio.NewScanner(file)
for scanner.Scan() {
if scanner.Text() == "/"+fn.RunDataDir {
return // success
}
}
t.Errorf(".gitignore does not include '/%v' ignore directive", fn.RunDataDir)
}
// TestClient_Update ensures that the deployer properly invokes the build/push/deploy
// process, erroring if run on a directory uncreated.
func TestUpdate(t *testing.T) {
func TestClient_Update(t *testing.T) {
var (
root = "testdata/example.com/testUpdate"
expectedName = "testUpdate"
@ -591,9 +647,9 @@ func TestUpdate(t *testing.T) {
}
}
// TestRemoveByPath ensures that the remover is invoked to remove
// TestClient_Remove_ByPath ensures that the remover is invoked to remove
// the Function with the name of the function at the provided root.
func TestRemoveByPath(t *testing.T) {
func TestClient_Remove_ByPath(t *testing.T) {
var (
root = "testdata/example.com/testRemoveByPath"
expectedName = "testRemoveByPath"
@ -627,9 +683,9 @@ func TestRemoveByPath(t *testing.T) {
}
// TestRemoveByName ensures that the remover is invoked to remove the function
// TestClient_Remove_ByName ensures that the remover is invoked to remove the function
// of the name provided, with precidence over a provided root path.
func TestRemoveByName(t *testing.T) {
func TestClient_Remove_ByName(t *testing.T) {
var (
root = "testdata/example.com/testRemoveByName"
expectedName = "explicitName.example.com"
@ -668,11 +724,11 @@ func TestRemoveByName(t *testing.T) {
}
}
// TestRemoveUninitializedFails ensures that attempting to remove a Function
// TestClient_Remove_UninitializedFails ensures that removing a Function
// by path only (no name) fails unless the Function has been initialized. I.e.
// the name will not be derived from path and the Function removed by this
// derived name; which could be unexpected and destructive.
func TestRemoveUninitializedFails(t *testing.T) {
func TestClient_Remove_UninitializedFails(t *testing.T) {
var (
root = "testdata/example.com/testRemoveUninitializedFails"
remover = mock.NewRemover()
@ -695,8 +751,8 @@ func TestRemoveUninitializedFails(t *testing.T) {
}
}
// TestList merely ensures that the client invokes the configured lister.
func TestList(t *testing.T) {
// TestClient_List merely ensures that the client invokes the configured lister.
func TestClient_List(t *testing.T) {
lister := mock.NewLister()
client := fn.New(fn.WithLister(lister)) // lists deployed Functions.
@ -710,11 +766,11 @@ func TestList(t *testing.T) {
}
}
// TestListOutsideRoot ensures that a call to a Function (in this case list)
// TestClient_List_OutsideRoot ensures that a call to a Function (in this case list)
// that is not contextually dependent on being associated with a Function,
// can be run from anywhere, thus ensuring that the client itself makes
// a distinction between Function-scoped methods and not.
func TestListOutsideRoot(t *testing.T) {
func TestClient_List_OutsideRoot(t *testing.T) {
lister := mock.NewLister()
// Instantiate in the current working directory, with no name.
@ -729,10 +785,10 @@ func TestListOutsideRoot(t *testing.T) {
}
}
// TestDeployUnbuilt ensures that a call to deploy a Function which was not
// TestClient_Deploy_UnbuiltErrors ensures that a call to deploy a Function which was not
// fully created (ie. was only initialized, not actually built and deploys)
// yields an expected, and informative, error.
func TestDeployUnbuilt(t *testing.T) {
func TestClient_Deploy_UnbuiltErrors(t *testing.T) {
root := "testdata/example.com/testDeployUnbuilt" // Root from which to run the test
defer Using(t, root)()
@ -755,34 +811,10 @@ func TestDeployUnbuilt(t *testing.T) {
}
}
// TestEmit ensures that the client properly invokes the emitter when provided
func TestEmit(t *testing.T) {
sink := "http://testy.mctestface.com"
emitter := mock.NewEmitter()
// Ensure sink passthrough from client
emitter.EmitFn = func(s string) error {
if s != sink {
t.Fatalf("Unexpected sink %v\n", s)
}
return nil
}
// Instantiate in the current working directory, with no name.
client := fn.New(fn.WithEmitter(emitter))
if err := client.Emit(context.Background(), sink); err != nil {
t.Fatal(err)
}
if !emitter.EmitInvoked {
t.Fatal("Client did not invoke emitter.Emit()")
}
}
// Asserts that the client properly writes user provided Builders
// to the Function configuration but uses internal default if
// not provided.
func TestWithConfiguredBuilders(t *testing.T) {
// TestClient_New_BuildersPersisted Asserts that the client preserves user-
// provided Builders on the Function configuration with the internal default
// if not provided.
func TestClient_New_BuildersPersisted(t *testing.T) {
root := "testdata/example.com/testConfiguredBuilders" // Root from which to run the test
defer Using(t, root)()
client := fn.New(fn.WithRegistry(TestRegistry))
@ -796,7 +828,7 @@ func TestWithConfiguredBuilders(t *testing.T) {
}}
// Create the Function, which should preserve custom builders
if err := client.Create(f0); err != nil {
if err := client.New(context.Background(), f0); err != nil {
t.Fatal(err)
}
@ -817,10 +849,10 @@ func TestWithConfiguredBuilders(t *testing.T) {
}
}
// Asserts that the client properly sets user provided Builders
// in the Function configuration, and if one of the provided is
// keyed as "default", this is set as the default Builder.
func TestWithConfiguredBuildersWithDefault(t *testing.T) {
// TestClient_New_BuilderDefault ensures that if a custom builder is
// provided of name "default", this is chosen as the default builder instead
// of the inbuilt static default.
func TestClient_New_BuilderDefault(t *testing.T) {
root := "testdata/example.com/testConfiguredBuildersWithDefault" // Root from which to run the test
defer Using(t, root)()
@ -829,7 +861,7 @@ func TestWithConfiguredBuildersWithDefault(t *testing.T) {
"default": "docker.io/example/default",
}
client := fn.New(fn.WithRegistry(TestRegistry))
if err := client.Create(fn.Function{
if err := client.New(context.Background(), fn.Function{
Runtime: TestRuntime,
Root: root,
Builders: builders,
@ -852,9 +884,9 @@ func TestWithConfiguredBuildersWithDefault(t *testing.T) {
}
}
// Asserts that the client properly sets the Buildpacks property
// in the Function configuration when it is provided.
func TestWithConfiguredBuildpacks(t *testing.T) {
// TestClient_New_BuildpacksPersisted ensures that provided buildpacks are
// persisted on new Functions.
func TestClient_New_BuildpacksPersisted(t *testing.T) {
root := "testdata/example.com/testConfiguredBuildpacks" // Root from which to run the test
defer Using(t, root)()
@ -862,7 +894,7 @@ func TestWithConfiguredBuildpacks(t *testing.T) {
"docker.io/example/custom-buildpack",
}
client := fn.New(fn.WithRegistry(TestRegistry))
if err := client.Create(fn.Function{
if err := client.New(context.Background(), fn.Function{
Runtime: TestRuntime,
Root: root,
Buildpacks: buildpacks,
@ -880,8 +912,8 @@ func TestWithConfiguredBuildpacks(t *testing.T) {
}
}
// TestRuntimes ensures that the total set of runtimes are returned.
func TestRuntimes(t *testing.T) {
// TestClient_Runtimes ensures that the total set of runtimes are returned.
func TestClient_Runtimes(t *testing.T) {
// TODO: test when a specific repo override is indicated
// (remote repo which takes precidence over embedded and extended)
@ -925,9 +957,9 @@ func TestRuntimes(t *testing.T) {
}
}
// TestCreateStamp ensures that the creation timestamp is set on functions
// TestClient_New_Timestamp ensures that the creation timestamp is set on functions
// which are successfully initialized using the client library.
func TestCreateStamp(t *testing.T) {
func TestClient_New_Timestamp(t *testing.T) {
root := "testdata/example.com/testCreateStamp"
defer Using(t, root)()
@ -947,3 +979,222 @@ func TestCreateStamp(t *testing.T) {
t.Fatalf("expected function timestamp to be after '%v', got '%v'", start, f.Created)
}
}
// TestClient_Invoke_HTTP ensures that the client will attempt to invoke a default HTTP
// Function using a simple HTTP POST method with the invoke message as form
// field values (as though a simple form were posted).
func TestClient_Invoke_HTTP(t *testing.T) {
root := "testdata/example.com/testInvokeHTTP"
defer Using(t, root)()
// Flag indicating the Function was invoked
var invoked bool
// The message to send to the Function
// Individual fields can be overridden, by default all fields are populeted
// with values intended as illustrative examples plus a unique request ID.
message := fn.NewInvokeMessage()
// An HTTP handler which masquarades as a running Function and verifies the
// invoker POSTed the invocation message.
handler := http.NewServeMux()
handler.HandleFunc("/", func(res http.ResponseWriter, req *http.Request) {
invoked = true
if err := req.ParseForm(); err != nil {
t.Fatal(err)
}
// Verify that we POST to HTTP endpoints by default
if req.Method != "POST" {
t.Fatalf("expected 'POST' request, got '%v'", req.Method)
}
// Verify the values came through via a spot-check of the unique ID
if req.Form.Get("ID") != message.ID {
t.Fatalf("expected message ID '%v', got '%v'", message.ID, req.Form.Get("ID"))
}
})
// Expose the masquarading Function on an OS-chosen port.
l, err := net.Listen("tcp4", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
s := http.Server{Handler: handler}
go func() {
if err = s.Serve(l); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "error serving: %v", err)
}
}()
defer s.Close()
// Create a client with a mock runner which will report the port at which the
// interloping Function is listening.
runner := mock.NewRunner()
runner.RunFn = func(ctx context.Context, f fn.Function) (*fn.Job, error) {
_, p, _ := net.SplitHostPort(l.Addr().String())
errs := make(chan error, 10)
stop := func() {}
return fn.NewJob(f, p, errs, stop)
}
client := fn.New(fn.WithRegistry(TestRegistry), fn.WithRunner(runner))
// Create a new default HTTP function
f := fn.Function{Runtime: TestRuntime, Root: root, Template: "http"}
if err := client.New(context.Background(), f); err != nil {
t.Fatal(err)
}
// Run the Function
job, err := client.Run(context.Background(), root)
if err != nil {
t.Fatal(err)
}
defer job.Stop()
// Invoke the Function, which will use the mock Runner
if err := client.Invoke(context.Background(), f.Root, "", message); err != nil {
t.Fatal(err)
}
// Fail if the Function was never invoked.
if !invoked {
t.Fatal("Function was not invoked")
}
// Also fail if the mock runner was never invoked.
if !runner.RunInvoked {
t.Fatal("the runner was not")
}
}
// TestClient_Invoke_CloudEvent ensures that the client will attempt to invoke a
// default CloudEvent Function. This also uses the HTTP protocol but asserts
// the invoker is sending the invocation message as a CloudEvent rather than
// a standard HTTP form POST.
func TestClient_Invoke_CloudEvent(t *testing.T) {
root := "testdata/example.com/testInvokeCloudEvent"
defer Using(t, root)()
var (
invoked bool // flag the Function was invoked
ctx = context.Background()
message = fn.NewInvokeMessage() // message to send to the Function
)
// A CloudEvent Receiver which masquarades as a running Function and
// verifies the invoker sent the message as a populated CloudEvent.
receiver := func(ctx context.Context, event cloudevents.Event) {
invoked = true
if event.ID() != message.ID {
t.Fatalf("expected event ID '%v', got '%v'", message.ID, event.ID())
}
}
// A cloudevent receive handler which will expect the HTTP protocol
protocol, err := cloudevents.NewHTTP() // Use HTTP protocol when receiving
if err != nil {
t.Fatal(err)
}
handler, err := cloudevents.NewHTTPReceiveHandler(ctx, protocol, receiver)
if err != nil {
t.Fatal(err)
}
// Listen and serve on an OS-chosen port
l, err := net.Listen("tcp4", "127.0.0.1:")
if err != nil {
t.Fatal(err)
}
s := http.Server{Handler: handler}
go func() {
if err := s.Serve(l); err != nil && err != http.ErrServerClosed {
fmt.Fprintf(os.Stderr, "error serving: %v", err)
}
}()
defer s.Close()
// Create a client with a mock Runner which returns its address.
runner := mock.NewRunner()
runner.RunFn = func(ctx context.Context, f fn.Function) (*fn.Job, error) {
_, p, _ := net.SplitHostPort(l.Addr().String())
errs := make(chan error, 10)
stop := func() {}
return fn.NewJob(f, p, errs, stop)
}
client := fn.New(fn.WithRegistry(TestRegistry), fn.WithRunner(runner))
// Create a new default CloudEvents function
f := fn.Function{Runtime: TestRuntime, Root: root, Template: "cloudevents"}
if err := client.New(context.Background(), f); err != nil {
t.Fatal(err)
}
// Run the Function
job, err := client.Run(context.Background(), root)
if err != nil {
t.Fatal(err)
}
defer job.Stop()
// Invoke the Function, which will use the mock Runner
if err := client.Invoke(context.Background(), f.Root, "", message); err != nil {
t.Fatal(err)
}
// Fail if the Function was never invoked.
if !invoked {
t.Fatal("Function was not invoked")
}
// Also fail if the mock runner was never invoked.
if !runner.RunInvoked {
t.Fatal("the runner was not invoked")
}
}
// TestClient_Instances ensures that when a Function is run (locally) its metadata
// is available to other clients inspecting the same Function using .Instances
func TestClient_Instances(t *testing.T) {
root := "testdata/example.com/testInstances"
defer Using(t, root)()
// A mock runner
runner := mock.NewRunner()
runner.RunFn = func(_ context.Context, f fn.Function) (*fn.Job, error) {
errs := make(chan error, 10)
stop := func() {}
return fn.NewJob(f, "8080", errs, stop)
}
// Client with the mock runner
client := fn.New(fn.WithRegistry(TestRegistry), fn.WithRunner(runner))
// Create the new Function
if err := client.New(context.Background(), fn.Function{Root: root, Runtime: TestRuntime}); err != nil {
t.Fatal(err)
}
// Run the function, awaiting start and then canceling
job, err := client.Run(context.Background(), root)
if err != nil {
t.Fatal(err)
}
defer job.Stop()
// Load the (now fully initialized) Function metadata
f, err := fn.NewFunction(root)
if err != nil {
t.Fatal(err)
}
// Get the local function instance info
instance, err := client.Instances().Local(context.Background(), f)
if err != nil {
t.Fatal(err)
}
// Assert the endpoint route is as expected
expectedEndpoint := "http://localhost:8080/"
if instance.Route != expectedEndpoint {
t.Fatalf("Expected endpoint '%v', got '%v'", expectedEndpoint, instance.Route)
}
}

View File

@ -1,71 +0,0 @@
package cloudevents
import (
"context"
"fmt"
nethttp "net/http"
cloudevents "github.com/cloudevents/sdk-go/v2"
"github.com/cloudevents/sdk-go/v2/client"
"github.com/cloudevents/sdk-go/v2/event"
"github.com/cloudevents/sdk-go/v2/protocol/http"
"github.com/cloudevents/sdk-go/v2/types"
"github.com/google/uuid"
)
const (
DefaultSource = "/boson/fn"
DefaultType = "boson.fn"
)
type Emitter struct {
Endpoint string
Source string
Type string
Id string
Data string
ContentType string
Transport nethttp.RoundTripper
}
func NewEmitter() *Emitter {
return &Emitter{
Source: DefaultSource,
Type: DefaultType,
Id: uuid.NewString(),
Data: "",
ContentType: event.TextPlain,
Transport: nethttp.DefaultTransport,
}
}
func (e *Emitter) Emit(ctx context.Context, endpoint string) (err error) {
p, err := http.New(http.WithTarget(endpoint), http.WithRoundTripper(e.Transport))
if err != nil {
return err
}
c, err := client.New(p)
if err != nil {
return err
}
evt := event.Event{
Context: event.EventContextV1{
Type: e.Type,
Source: *types.ParseURIRef(e.Source),
ID: e.Id,
}.AsV1(),
}
if err = evt.SetData(e.ContentType, e.Data); err != nil {
return
}
event, result := c.Request(ctx, evt)
if !cloudevents.IsACK(result) {
return fmt.Errorf(result.Error())
}
if event != nil {
fmt.Printf("%v", event)
}
return nil
}

View File

@ -1,143 +0,0 @@
//go:build !integration
// +build !integration
package cloudevents
import (
"context"
"fmt"
"testing"
"time"
"github.com/cloudevents/sdk-go/v2/client"
"github.com/cloudevents/sdk-go/v2/event"
"github.com/cloudevents/sdk-go/v2/protocol/http"
"github.com/google/go-cmp/cmp"
)
func makeClient(t *testing.T) (c client.Client, p *http.Protocol) {
p, err := http.New()
if err != nil {
t.Fatal(err)
}
c, err = client.New(p)
if err != nil {
t.Errorf("failed to make client %s", err.Error())
}
return
}
func receiveEvents(t *testing.T, ctx context.Context, events chan<- event.Event) (p *http.Protocol) {
c, p := makeClient(t)
go func() {
err := c.StartReceiver(ctx, func(ctx context.Context, event event.Event) error {
go func() {
events <- event
}()
return nil
})
if err != nil {
t.Errorf("failed to start receiver %s", err.Error())
}
}()
time.Sleep(1 * time.Second) // let the server start
return
}
func TestEmitterDefaults(t *testing.T) {
events := make(chan event.Event)
ctx, cancel := context.WithCancel(context.Background())
// start a cloudevent client that receives events
// and sends them to a channel
p := receiveEvents(t, ctx, events)
emitter := NewEmitter()
if err := emitter.Emit(ctx, fmt.Sprintf("http://localhost:%v", p.GetListeningPort())); err != nil {
t.Fatalf("Error emitting event: %v\n", err)
}
// received event
got := <-events
cancel() // stop the client
time.Sleep(1 * time.Second) // let the server stop
if got.Source() != "/boson/fn" {
t.Fatal("Expected /boson/fn as default source")
}
if got.Type() != "boson.fn" {
t.Fatal("Expected boson.fn as default type")
}
}
func TestEmitter(t *testing.T) {
testCases := map[string]struct {
cesource string
cetype string
ceid string
cedata string
}{
"with-source": {
cesource: "/my/source",
},
"with-type": {
cetype: "my.type",
},
"with-id": {
ceid: "11223344",
},
"with-data": {
cedata: "Some event data",
},
}
for n, tc := range testCases {
t.Run(n, func(t *testing.T) {
events := make(chan event.Event)
ctx, cancel := context.WithCancel(context.Background())
// start a cloudevent client that receives events
// and sends them to a channel
p := receiveEvents(t, ctx, events)
emitter := NewEmitter()
if tc.cesource != "" {
emitter.Source = tc.cesource
}
if tc.cetype != "" {
emitter.Type = tc.cetype
}
if tc.ceid != "" {
emitter.Id = tc.ceid
}
if tc.cedata != "" {
emitter.Data = tc.cedata
}
if err := emitter.Emit(ctx, fmt.Sprintf("http://localhost:%v", p.GetListeningPort())); err != nil {
t.Fatalf("Error emitting event: %v\n", err)
}
// received event
got := <-events
cancel() // stop the client
time.Sleep(100 * time.Millisecond) // let the server stop
if tc.cesource != "" && got.Source() != tc.cesource {
t.Fatalf("%s: Expected %s as source, got %s", n, tc.cesource, got.Source())
}
if tc.cetype != "" && got.Type() != tc.cetype {
t.Fatalf("%s: Expected %s as type, got %s", n, tc.cetype, got.Type())
}
if tc.ceid != "" && got.ID() != tc.ceid {
t.Fatalf("%s: Expected %s as id, got %s", n, tc.ceid, got.ID())
}
if tc.cedata != "" {
if diff := cmp.Diff(tc.cedata, string(got.Data())); diff != "" {
t.Errorf("Unexpected difference (-want, +got): %v", diff)
}
}
})
}
}

View File

@ -12,9 +12,9 @@ import (
"knative.dev/kn-plugin-func/mock"
)
// TestBuildInvalidRegistry ensures that running build specifying the name of the
// TestBuild_InvalidRegistry ensures that running build specifying the name of the
// registry explicitly as an argument invokes the registry validation code.
func TestBuildInvalidRegistry(t *testing.T) {
func TestBuild_InvalidRegistry(t *testing.T) {
var (
args = []string{"--registry", "foo/bar/foobar/boofar"} // provide an invalid registry name
builder = mock.NewBuilder() // with a mock builder
@ -54,7 +54,7 @@ created: 2021-01-01T00:00:00+00:00
}
}
func Test_runBuild(t *testing.T) {
func TestBuild_runBuild(t *testing.T) {
tests := []struct {
name string
pushFlag bool
@ -142,7 +142,7 @@ created: 2009-11-10 23:00:00`,
}
}
func Test_newBuildClient(t *testing.T) {
func TestBuild_newBuildClient(t *testing.T) {
tests := []struct {
name string
cfg buildConfig

View File

@ -56,7 +56,7 @@ NAME
{{.Prefix}}func create - Create a Function project.
SYNOPSIS
func create [-l|--language] [-t|--template] [-r|--repository]
{{.Prefix}}func create [-l|--language] [-t|--template] [-r|--repository]
[-c|--confirm] [-v|--verbose] [path]
DESCRIPTION
@ -292,6 +292,53 @@ func newCreateConfig(args []string, clientFn createClientFn) (cfg createConfig,
return
}
// Validate the current state of the config, returning any errors.
// Note this is a deeper validation using a client already configured with a
// preliminary config object from flags/config, such that the client instance
// can be used to determine possible values for runtime, templates, etc. a
// pre-client validation should not be required, as the Client does its own
// validation.
func (c createConfig) Validate(client *fn.Client) (err error) {
// Confirm Name is valid
// Note that this is highly constricted, as it must currently adhere to the
// naming of a Knative Service, which itself is constrained to a Kubernetes
// Service, which itself is constrained to a DNS label (a subdomain).
// TODO: refactor to be git-like with no name at time of creation, but rather
// with named deployment targets in a one-to-many configuration.
dirName, _ := deriveNameAndAbsolutePathFromPath(c.Path)
if err = utils.ValidateFunctionName(dirName); err != nil {
return
}
// Validate Runtime and Template Name
//
// Perhaps additional validation would be of use here in the CLI, but
// the client libray itself is ultimately responsible for validating all input
// prior to exeuting any requests.
// Client validates both language runtime and template exist, with language runtime
// being a mandatory flag while defaulting template if not present to 'http'.
// However, if either of them are invalid, or the chosen combination does not exist,
// the error message is a rather terse one-liner. This is suitable for libraries, but
// for a CLI it behooves us to be more verbose, including valid options for
// each. So here, we check that the values entered (if any) are both valid
// and valid together.
if c.Runtime == "" {
return noRuntimeError(client)
}
if c.Runtime != "" && c.Repository == "" &&
!isValidRuntime(client, c.Runtime) {
return newInvalidRuntimeError(client, c.Runtime)
}
if c.Template != "" && c.Repository == "" &&
!isValidTemplate(client, c.Runtime, c.Template) {
return newInvalidTemplateError(client, c.Runtime, c.Template)
}
return
}
// isValidRuntime determines if the given language runtime is a valid choice.
func isValidRuntime(client *fn.Client, runtime string) bool {
runtimes, err := client.Runtimes()
@ -373,53 +420,6 @@ func newInvalidTemplateError(client *fn.Client, runtime, template string) error
return ErrInvalidTemplate(errors.New(b.String()))
}
// Validate the current state of the config, returning any errors.
// Note this is a deeper validation using a client already configured with a
// preliminary config object from flags/config, such that the client instance
// can be used to determine possible values for runtime, templates, etc. a
// pre-client validation should not be required, as the Client does its own
// validation.
func (c createConfig) Validate(client *fn.Client) (err error) {
// Confirm Name is valid
// Note that this is highly constricted, as it must currently adhere to the
// naming of a Knative Service, which itself is constrained to a Kubernetes
// Service, which itself is constrained to a DNS label (a subdomain).
// TODO: refactor to be git-like with no name at time of creation, but rather
// with named deployment targets in a one-to-many configuration.
dirName, _ := deriveNameAndAbsolutePathFromPath(c.Path)
if err = utils.ValidateFunctionName(dirName); err != nil {
return
}
// Validate Runtime and Template Name
//
// Perhaps additional validation would be of use here in the CLI, but
// the client libray itself is ultimately responsible for validating all input
// prior to exeuting any requests.
// Client validates both language runtime and template exist, with language runtime
// being a mandatory flag while defaulting template if not present to 'http'.
// However, if either of them are invalid, or the chosen combination does not exist,
// the error message is a rather terse one-liner. This is suitable for libraries, but
// for a CLI it behooves us to be more verbose, including valid options for
// each. So here, we check that the values entered (if any) are both valid
// and valid together.
if c.Runtime == "" {
return noRuntimeError(client)
}
if c.Runtime != "" && c.Repository == "" &&
!isValidRuntime(client, c.Runtime) {
return newInvalidRuntimeError(client, c.Runtime)
}
if c.Template != "" && c.Repository == "" &&
!isValidTemplate(client, c.Runtime, c.Template) {
return newInvalidTemplateError(client, c.Runtime, c.Template)
}
return
}
// prompt the user with value of config members, allowing for interactively
// mutating the values. The provided clientFn is used to construct a transient
// client for use during prompt autocompletion/suggestions (such as suggesting

View File

@ -12,9 +12,9 @@ import (
"knative.dev/kn-plugin-func/utils"
)
// TestCreate ensures that an invocation of create with minimal settings
// TestCreate_Execute ensures that an invocation of create with minimal settings
// and valid input completes without error; degenerate case.
func TestCreate(t *testing.T) {
func TestCreate_Execute(t *testing.T) {
defer fromTempDir(t)()
// command with a client factory which yields a fully default client.
@ -28,9 +28,9 @@ func TestCreate(t *testing.T) {
}
}
// TestCreateWithNoRuntime ensures that an invocation of create must be
// TestCreate_NoRuntime ensures that an invocation of create must be
// done with a runtime.
func TestCreateWithNoRuntime(t *testing.T) {
func TestCreate_NoRuntime(t *testing.T) {
defer fromTempDir(t)()
// command with a client factory which yields a fully default client.
@ -44,9 +44,9 @@ func TestCreateWithNoRuntime(t *testing.T) {
}
}
// TestCreateWithNoRuntime ensures that an invocation of create must be
// TestCreate_WithNoRuntime ensures that an invocation of create must be
// done with one of the valid runtimes only.
func TestCreateWithInvalidRuntime(t *testing.T) {
func TestCreate_WithInvalidRuntime(t *testing.T) {
defer fromTempDir(t)()
// command with a client factory which yields a fully default client.
@ -62,9 +62,9 @@ func TestCreateWithInvalidRuntime(t *testing.T) {
}
}
// TestCreateWithInvalidTemplate ensures that an invocation of create must be
// TestCreate_InvalidTemplate ensures that an invocation of create must be
// done with one of the valid templates only.
func TestCreateWithInvalidTemplate(t *testing.T) {
func TestCreate_InvalidTemplate(t *testing.T) {
defer fromTempDir(t)()
// command with a client factory which yields a fully default client.
@ -81,9 +81,9 @@ func TestCreateWithInvalidTemplate(t *testing.T) {
}
}
// TestCreateValidatesName ensures that the create command only accepts
// TestCreate_ValidatesName ensures that the create command only accepts
// DNS-1123 labels for Function name.
func TestCreateValidatesName(t *testing.T) {
func TestCreate_ValidatesName(t *testing.T) {
defer fromTempDir(t)()
// Create a new Create command with a fn.Client construtor
@ -100,10 +100,10 @@ func TestCreateValidatesName(t *testing.T) {
}
}
// TestCreateRepositoriesPath ensures that the create command utilizes the
// TestCreate_RepositoriesPath ensures that the create command utilizes the
// expected repositories path, respecting the setting for XDG_CONFIG_PATH
// when deriving the default
func TestCreateRepositoriesPath(t *testing.T) {
func TestCreate_RepositoriesPath(t *testing.T) {
defer fromTempDir(t)()
// Update XDG_CONFIG_HOME to point to some arbitrary location.

View File

@ -8,9 +8,9 @@ import (
"knative.dev/kn-plugin-func/mock"
)
// TestDeleteByName ensures that running delete specifying the name of the
// TestDelete_ByName ensures that running delete specifying the name of the
// Function explicitly as an argument invokes the remover appropriately.
func TestDeleteByName(t *testing.T) {
func TestDelete_ByName(t *testing.T) {
var (
testname = "testname" // explicit name for the Function
args = []string{testname} // passed as the lone argument
@ -45,9 +45,9 @@ func TestDeleteByName(t *testing.T) {
}
}
// TestDeleteByProject ensures that running delete with a valid project as its
// TestDelete_ByProject ensures that running delete with a valid project as its
// context invokes remove and with the correct name (reads name from func.yaml)
func TestDeleteByProject(t *testing.T) {
func TestDelete_ByProject(t *testing.T) {
// from within a new temporary directory
defer fromTempDir(t)()
@ -97,12 +97,12 @@ created: 2021-01-01T00:00:00+00:00
}
}
// TestDeleteNameAndPathExclusivity ensures that providing both a name and a
// TestDelete_NameAndPathExclusivity ensures that providing both a name and a
// path generates an error.
// Providing the --path (-p) flag indicates the name of the function to delete
// is to be taken from the Function at the given path. Providing the name as
// an argument as well is therefore redundant and an error.
func TestDeleteNameAndPathExclusivity(t *testing.T) {
func TestDelete_NameAndPathExclusivity(t *testing.T) {
// A mock remover which will be sampled to ensure it is not invoked.
remover := mock.NewRemover()

View File

@ -1,209 +0,0 @@
package cmd
import (
"context"
"errors"
"io/ioutil"
"net/http"
"github.com/google/uuid"
"github.com/ory/viper"
"github.com/spf13/cobra"
fn "knative.dev/kn-plugin-func"
"knative.dev/kn-plugin-func/cloudevents"
fnhttp "knative.dev/kn-plugin-func/http"
"knative.dev/kn-plugin-func/knative"
)
func init() {
root.AddCommand(NewEmitCmd(newEmitClient))
}
// create a fn.Client with an instance of a
func newEmitClient(cfg emitConfig) (*fn.Client, error) {
e := cloudevents.NewEmitter()
e.Id = cfg.Id
e.Source = cfg.Source
e.Type = cfg.Type
e.ContentType = cfg.ContentType
e.Data = cfg.Data
if e.Transport != nil {
e.Transport = cfg.Transport
}
if cfg.File != "" {
// See config.Validate for --Data and --file exclusivity enforcement
b, err := ioutil.ReadFile(cfg.File)
if err != nil {
return nil, err
}
e.Data = string(b)
}
return fn.New(fn.WithEmitter(e)), nil
}
type emitClientFn func(emitConfig) (*fn.Client, error)
func NewEmitCmd(clientFn emitClientFn) *cobra.Command {
cmd := &cobra.Command{
Use: "emit",
Short: "Emit a CloudEvent to a function endpoint",
Long: `Emit event
Emits a CloudEvent, sending it to the deployed function.
`,
Example: `
# Send a CloudEvent to the deployed function with no data and default values
# for source, type and ID
kn func emit
# Send a CloudEvent to the deployed function with the data found in ./test.json
kn func emit --file ./test.json
# Send a CloudEvent to the function running locally with a CloudEvent containing
# "Hello World!" as the data field, with a content type of "text/plain"
kn func emit --data "Hello World!" --content-type "text/plain" -s local
# Send a CloudEvent to the function running locally with an event type of "my.event"
kn func emit --type my.event --sink local
# Send a CloudEvent to the deployed function found at /path/to/fn with an id of "fn.test"
kn func emit --path /path/to/fn -i fn.test
# Send a CloudEvent to an arbitrary endpoint
kn func emit --sink "http://my.event.broker.com"
`,
SuggestFor: []string{"meit", "emti", "send"},
PreRunE: bindEnv("source", "type", "id", "data", "file", "path", "sink", "content-type"),
}
cmd.Flags().StringP("sink", "k", "", "Send the CloudEvent to the function running at [sink]. The special value \"local\" can be used to send the event to a function running on the local host. When provided, the --path flag is ignored (Env: $FUNC_SINK)")
cmd.Flags().StringP("source", "s", cloudevents.DefaultSource, "CloudEvent source (Env: $FUNC_SOURCE)")
cmd.Flags().StringP("type", "t", cloudevents.DefaultType, "CloudEvent type (Env: $FUNC_TYPE)")
cmd.Flags().StringP("id", "i", uuid.NewString(), "CloudEvent ID (Env: $FUNC_ID)")
cmd.Flags().StringP("data", "d", "", "Any arbitrary string to be sent as the CloudEvent data. Ignored if --file is provided (Env: $FUNC_DATA)")
cmd.Flags().StringP("file", "f", "", "Path to a local file containing CloudEvent data to be sent (Env: $FUNC_FILE)")
cmd.Flags().StringP("content-type", "c", "application/json", "The MIME Content-Type for the CloudEvent data (Env: $FUNC_CONTENT_TYPE)")
setPathFlag(cmd)
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runEmit(cmd, args, clientFn)
}
return cmd
}
func runEmit(cmd *cobra.Command, _ []string, clientFn emitClientFn) (err error) {
config := newEmitConfig()
// Validate things like invalid config combinations.
if err := config.Validate(); err != nil {
return err
}
// Determine the final endpoint, taking into account the special value "local",
// and sampling the function's current route if not explicitly provided
endpoint, err := endpoint(cmd.Context(), config)
if err != nil {
return err
}
// Instantiate a client based on the final value of config
transport := fnhttp.NewRoundTripper()
defer transport.Close()
config.Transport = transport
client, err := clientFn(config)
if err != nil {
return err
}
// Emit the event to the endpoint
return client.Emit(cmd.Context(), endpoint)
}
// endpoint returns the final effective endpoint.
// By default, the contextually active Function is queried for it's current
// address (route).
// If "local" is specified in cfg.Sink, localhost is used.
// Otherwise the value of Sink is used verbatim if defined.
func endpoint(ctx context.Context, cfg emitConfig) (url string, err error) {
var (
f fn.Function
d fn.Describer
i fn.Info
)
// If the special value "local" was requested,
// use localhost.
if cfg.Sink == "local" {
return "http://localhost:8080", nil
}
// If a sink was expressly provided, use that verbatim
if cfg.Sink != "" {
return cfg.Sink, nil
}
// If no sink was specified, use the route to the currently
// contectually active function
if f, err = fn.NewFunction(cfg.Path); err != nil {
return
}
// TODO: Decide what happens if the function hasn't been deployed but they
// don't run with --local=true. Perhaps an error in .Validate()?
if d, err = knative.NewDescriber(""); err != nil {
return
}
// Get the current state of the function.
if i, err = d.Describe(ctx, f.Name); err != nil {
return
}
// Probably wise to be defensive here:
if len(i.Routes) == 0 {
err = errors.New("function has no active routes")
return
}
// The first route should be the destination.
return i.Routes[0], nil
}
type emitConfig struct {
Path string
Source string
Type string
Id string
Data string
File string
ContentType string
Sink string
Verbose bool
Transport http.RoundTripper
}
func newEmitConfig() emitConfig {
return emitConfig{
Path: viper.GetString("path"),
Source: viper.GetString("source"),
Type: viper.GetString("type"),
Id: viper.GetString("id"),
Data: viper.GetString("data"),
File: viper.GetString("file"),
ContentType: viper.GetString("content-type"),
Sink: viper.GetString("sink"),
Verbose: viper.GetBool("verbose"),
}
}
func (c emitConfig) Validate() error {
if c.Data != "" && c.File != "" {
return errors.New("Only one of --data or --file may be specified")
}
// TODO: should we verify that sink is a url or "local"?
return nil
}

View File

@ -129,7 +129,7 @@ func newInfoConfig(args []string) infoConfig {
// Output Formatting (serializers)
// -------------------------------
type info fn.Info
type info fn.Instance
func (i info) Human(w io.Writer) error {
fmt.Fprintln(w, "Function name:")

404
cmd/invoke.go Normal file
View File

@ -0,0 +1,404 @@
package cmd
import (
"encoding/base64"
"fmt"
"io/ioutil"
"os"
"text/template"
"github.com/AlecAivazis/survey/v2"
"github.com/google/uuid"
"github.com/ory/viper"
"github.com/spf13/cobra"
"knative.dev/kn-plugin-func/utils"
fn "knative.dev/kn-plugin-func"
fnhttp "knative.dev/kn-plugin-func/http"
knative "knative.dev/kn-plugin-func/knative"
)
func init() {
root.AddCommand(NewInvokeCmd(newInvokeClient))
}
type invokeClientFn func(invokeConfig) (*fn.Client, error)
func newInvokeClient(cfg invokeConfig) (*fn.Client, error) {
describer, err := knative.NewDescriber(cfg.Namespace)
if err != nil {
return nil, err
}
describer.Verbose = cfg.Verbose
return fn.New(
fn.WithDescriber(describer),
fn.WithTransport(fnhttp.NewRoundTripper()),
fn.WithVerbose(cfg.Verbose),
), nil
}
func NewInvokeCmd(clientFn invokeClientFn) *cobra.Command {
cmd := &cobra.Command{
Use: "invoke",
Short: "Invoke a Function",
Long: `
NAME
{{.Prefix}}func invoke - Invoke a Function.
SYNOPSIS
{{.Prefix}}func invoke [-t|--target] [-f|--format]
[--id] [--source] [--type] [--data] [--file] [--content-type]
[-s|--save] [-p|--path] [-c|--confirm] [-v|--verbose]
DESCRIPTION
Invokes the Function by sending a test request to the currently running
Function instance, either locally or remote. If the Function is running
both locally and remote, the local instance will be invoked. This behavior
can be manually overridden using the --target flag.
Functions are invoked with a test data structure consisting of five values:
id: A unique identifier for the request.
source: A sender name for the request (sender).
type: A type for the request.
data: Data (content) for this request.
content-type: The MIME type of the value contained in 'data'.
The values of these parameters can be individually altered from their defaults
using their associated flags. Data can also be provided from a file using the
--file flag.
Invocation Target
The Function instance to invoke can be specified using the --target flag
which accepts the values "local", "remote", or <URL>. By default the
local Function instance is chosen if running (see {{.Prefix}}func run).
To explicitly target the remote (deployed) Function:
{{.Prefix}}func invoke --target=remote
To target an arbitrary endpoint, provide a URL:
{{.Prefix}}func invoke --target=https://myfunction.example.com
Invocation Data
Providing a filename in the --file flag will base64 encode its contents
as the "data" parameter sent to the Function. The value of --content-type
should be set to the type from the source file. For example, the following
would send a JPEG base64 encoded in the "data" POST parameter:
{{.Prefix}}func invoke --file=example.jpeg --content-type=image/jpeg
Message Format
By default Functions are sent messages which match the invocation format
of the template they were created using; for example "http" or "cloudevent".
To override this behavior, use the --format (-f) flag.
{{.Prefix}}func invoke -f=cloudevent -t=http://my-sink.my-cluster
EXAMPLES
o Invoke the default (local or remote) running Function with default values
$ {{.Prefix}}func invoke
o Run the Function locally and then invoke it with a test request:
(run in two terminals or by running the first in the background)
$ {{.Prefix}}func run
$ {{.Prefix}}func invoke
o Deploy and then invoke the remote Function:
$ {{.Prefix}}func deploy
$ {{.Prefix}}func invoke
o Invoke a remote (deployed) Function when it is already running locally:
(overrides the default behavior of preferring locally running instances)
$ {{.Prefix}}func invoke --target=remote
o Specify the data to send to the Function as a flag
$ {{.Prefix}}func invoke --data="Hello World!"
o Send a JPEG to the Function
$ {{.Prefix}}func invoke --file=example.jpeg --content-type=image/jpeg
o Invoke an arbitrary endpoint (HTTP POST)
$ {{.Prefix}}func invoke --target="https://my-http-handler.example.com"
o Invoke an arbitrary endpoint (CloudEvent)
$ {{.Prefix}}func invoke -f=cloudevent -t="https://my-event-broker.example.com"
`,
SuggestFor: []string{"emit", "emti", "send", "emit", "exec", "nivoke", "onvoke", "unvoke", "knvoke", "imvoke", "ihvoke", "ibvoke"},
PreRunE: bindEnv("path", "format", "target", "id", "source", "type", "data", "content-type", "file", "confirm", "namespace"),
}
// Flags
cmd.Flags().StringP("path", "p", cwd(), "Path to the Function which should have its instance invoked. (Env: $FUNC_PATH)")
cmd.Flags().StringP("format", "f", "", "Format of message to send, 'http' or 'cloudevent'. Default is to choose automatically. (Env: $FUNC_FORMAT)")
cmd.Flags().StringP("target", "t", "", "Function instance to invoke. Can be 'local', 'remote' or a URL. Defaults to auto-discovery if not provided. (Env: $FUNC_TARGET)")
cmd.Flags().StringP("id", "", uuid.NewString(), "ID for the request data. (Env: $FUNC_ID)")
cmd.Flags().StringP("source", "", fn.DefaultInvokeSource, "Source value for the request data. (Env: $FUNC_SOURCE)")
cmd.Flags().StringP("type", "", fn.DefaultInvokeType, "Type value for the request data. (Env: $FUNC_TYPE)")
cmd.Flags().StringP("content-type", "", fn.DefaultInvokeContentType, "Content Type of the data. (Env: $FUNC_CONTENT_TYPE)")
cmd.Flags().StringP("data", "", fn.DefaultInvokeData, "Data to send in the request. (Env: $FUNC_DATA)")
cmd.Flags().StringP("file", "", "", "Path to a file to use as data. Overrides --data flag and should be sent with a correct --content-type. (Env: $FUNC_FILE)")
cmd.Flags().BoolP("confirm", "c", false, "Prompt to confirm all options interactively. (Env: $FUNC_CONFIRM)")
setNamespaceFlag(cmd)
// Help Action
cmd.SetHelpFunc(func(cmd *cobra.Command, args []string) {
runInvokeHelp(cmd, args, clientFn)
})
// Run Action
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runInvoke(cmd, args, clientFn)
}
return cmd
}
// Run
func runInvoke(cmd *cobra.Command, args []string, clientFn invokeClientFn) (err error) {
// Gather flag values for the invocation
cfg, err := newInvokeConfig(clientFn)
if err != nil {
return
}
// Client instance from env vars, flags, args and user prompts (if --confirm)
client, err := clientFn(cfg)
if err != nil {
return err
}
// Message to send the running Function built from parameters gathered
// from the user (or defaults)
m := fn.InvokeMessage{
ID: cfg.ID,
Source: cfg.Source,
Type: cfg.Type,
ContentType: cfg.ContentType,
Data: cfg.Data,
Format: cfg.Format,
}
// If --file was specified, use its content for message data
if cfg.File != "" {
content, err := os.ReadFile(cfg.File)
if err != nil {
return err
}
m.Data = base64.StdEncoding.EncodeToString(content)
}
// Invoke
err = client.Invoke(cmd.Context(), cfg.Path, cfg.Target, m)
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStderr(), "Invoked %v\n", cfg.Target)
return
}
func runInvokeHelp(cmd *cobra.Command, args []string, clientFn invokeClientFn) {
var (
body = cmd.Long + "\n\n" + cmd.UsageString()
t = template.New("invoke")
tpl = template.Must(t.Parse(body))
)
var data = struct {
Prefix string
}{
Prefix: pluginPrefix(),
}
if err := tpl.Execute(cmd.OutOrStdout(), data); err != nil {
fmt.Fprintf(cmd.ErrOrStderr(), "unable to display help text: %v", err)
}
}
type invokeConfig struct {
Path string
Target string
Format string
ID string
Source string
Type string
Data string
ContentType string
File string
Namespace string
Confirm bool
Verbose bool
}
func newInvokeConfig(clientFn invokeClientFn) (cfg invokeConfig, err error) {
cfg = invokeConfig{
Path: viper.GetString("path"),
Target: viper.GetString("target"),
ID: viper.GetString("id"),
Source: viper.GetString("source"),
Type: viper.GetString("type"),
Data: viper.GetString("data"),
ContentType: viper.GetString("content-type"),
File: viper.GetString("file"),
Confirm: viper.GetBool("confirm"),
Verbose: viper.GetBool("verbose"),
}
// If file was passed, read it in as data
if cfg.File != "" {
b, err := ioutil.ReadFile(cfg.File)
if err != nil {
return cfg, err
}
cfg.Data = string(b)
}
// if not in confirm/prompting mode, the cfg structure is complete.
if !cfg.Confirm {
return
}
// Client instance for use during prompting.
client, err := clientFn(cfg)
if err != nil {
return
}
// If in interactive terminal mode, prompt to modify defaults.
if interactiveTerminal() {
return cfg.prompt(client)
}
// Confirming, but noninteractive, is essentially a selective verbose mode
// which prints out the effective values of config as a confirmation.
fmt.Printf("Path: %v\n", cfg.Path)
fmt.Printf("Target: %v\n", cfg.Target)
fmt.Printf("ID: %v\n", cfg.ID)
fmt.Printf("Source: %v\n", cfg.Source)
fmt.Printf("Type: %v\n", cfg.Type)
fmt.Printf("Data: %v\n", cfg.Data)
fmt.Printf("Content Type: %v\n", cfg.ContentType)
fmt.Printf("File: %v\n", cfg.File)
return
}
func (c invokeConfig) prompt(client *fn.Client) (invokeConfig, error) {
var qs []*survey.Question
// First get path to effective Function
qs = []*survey.Question{
{
Name: "Path",
Prompt: &survey.Input{
Message: "Function Path:",
Default: c.Path,
},
Validate: func(val interface{}) error {
if val.(string) != "" {
derivedName, _ := deriveNameAndAbsolutePathFromPath(val.(string))
return utils.ValidateFunctionName(derivedName)
}
return nil
},
Transform: func(ans interface{}) interface{} {
if ans.(string) != "" {
_, absolutePath := deriveNameAndAbsolutePathFromPath(ans.(string))
return absolutePath
}
return ""
},
},
}
if err := survey.Ask(qs, &c); err != nil {
return c, err
}
qs = []*survey.Question{
{
Name: "Target",
Prompt: &survey.Input{
Message: "(Optional) Target ('local', 'remote' or URL). If not provided, local will be preferred over remote.",
Default: "",
},
},
{
Name: "Format",
Prompt: &survey.Select{
Message: "(Optional) Format Override",
Options: []string{"", "http", "cloudevent"},
Default: c.Format,
},
},
}
if err := survey.Ask(qs, &c); err != nil {
return c, err
}
// Prompt for the next set of values, with defaults set first by the Function
// as it exists on disk, followed by environment variables, and finally flags.
// user interactive prompts therefore are the last applied, and thus highest
// precidence values.
qs = []*survey.Question{
{
Name: "ID",
Prompt: &survey.Input{
Message: "Data ID",
Default: c.ID,
},
}, {
Name: "Source",
Prompt: &survey.Input{
Message: "Data Source",
Default: c.Source,
},
}, {
Name: "Type",
Prompt: &survey.Input{
Message: "Data Type",
Default: c.Type,
},
}, {
Name: "File",
Prompt: &survey.Input{
Message: "(Optional) Load Data Content from File",
Default: c.File,
},
},
}
if err := survey.Ask(qs, &c); err != nil {
return c, err
}
// If the user did not specify a file for data content, prompt for it
if c.File == "" {
qs = []*survey.Question{
{
Name: "Data",
Prompt: &survey.Input{
Message: "Data Content",
Default: c.Data,
},
},
}
if err := survey.Ask(qs, &c); err != nil {
return c, err
}
}
// Finally, allow mutation of the data content type.
contentTypeMessage := "Content type of data"
if c.File != "" {
contentTypeMessage = "Content type of file"
}
qs = []*survey.Question{
{
Name: "ContentType",
Prompt: &survey.Input{
Message: contentTypeMessage,
Default: c.ContentType,
},
}}
if err := survey.Ask(qs, &c); err != nil {
return c, err
}
return c, nil
}

View File

@ -9,10 +9,10 @@ import (
"knative.dev/kn-plugin-func/mock"
)
// TestRepositoryList ensures that the 'list' subcommand shows the client's
// TestRepository_List ensures that the 'list' subcommand shows the client's
// set of repositories by name, respects the repositories flag (provides it to
// the client), and prints the list as expected.
func TestRepositoryList(t *testing.T) {
func TestRepository_List(t *testing.T) {
var (
client = mock.NewClient()
list = NewRepositoryListCmd(testRepositoryClientFn(client))
@ -41,10 +41,10 @@ func TestRepositoryList(t *testing.T) {
}
}
// TestRepositoryAdd ensures that the 'add' subcommand accepts its positional
// TestRepository_Add ensures that the 'add' subcommand accepts its positional
// arguments, respects the repositories path flag, and the expected name is echoed
// upon subsequent 'list'.
func TestRepositoryAdd(t *testing.T) {
func TestRepository_Add(t *testing.T) {
var (
client = mock.NewClient()
add = NewRepositoryAddCmd(testRepositoryClientFn(client))
@ -81,10 +81,10 @@ func TestRepositoryAdd(t *testing.T) {
}
}
// TestRepositoryRename ensures that the 'rename' subcommand accepts its
// TestRepository_Rename ensures that the 'rename' subcommand accepts its
// positional arguments, respects the repositories path flag, and the name is
// reflected as having been reanamed upon subsequent 'list'.
func TestRepositoryRename(t *testing.T) {
func TestRepository_Rename(t *testing.T) {
var (
client = mock.NewClient()
add = NewRepositoryAddCmd(testRepositoryClientFn(client))
@ -129,10 +129,10 @@ func TestRepositoryRename(t *testing.T) {
}
}
// TestReposotoryRemove ensures that the 'remove' subcommand accepts name as
// TestReposotory_Remove ensures that the 'remove' subcommand accepts name as
// its argument, respects the repositorieis flag, and the entry is removed upon
// subsequent 'list'.
func TestRepositoryRemove(t *testing.T) {
func TestRepository_Remove(t *testing.T) {
var (
client = mock.NewClient()
add = NewRepositoryAddCmd(testRepositoryClientFn(client))

View File

@ -34,12 +34,12 @@ curl $(kn service describe myfunc -o url)
// resultant binary with no arguments prints the help/usage text.
var root = &cobra.Command{
Use: "func",
Short: "Serverless functions",
Short: "Serverless Functions",
SilenceErrors: true, // we explicitly handle errors in Execute()
SilenceUsage: true, // no usage dump on error
Long: `Serverless functions
Long: `Serverless Functions
Create, build and deploy functions in serverless containers for multiple runtimes on Knative`,
Create, build and deploy Functions in serverless containers for multiple runtimes on Knative`,
}
func init() {

View File

@ -10,7 +10,7 @@ import (
fn "knative.dev/kn-plugin-func"
)
func Test_mergeEnvMaps(t *testing.T) {
func TestRoot_mergeEnvMaps(t *testing.T) {
a := "A"
b := "B"
@ -118,7 +118,7 @@ func Test_mergeEnvMaps(t *testing.T) {
}
}
func Test_CMDParameterized(t *testing.T) {
func TestRoot_CMDParameterized(t *testing.T) {
if root.Use != "func" {
t.Fatalf("default command use should be \"func\".")

View File

@ -1,6 +1,8 @@
package cmd
import (
"context"
"errors"
"fmt"
"github.com/ory/viper"
@ -98,14 +100,34 @@ func runRun(cmd *cobra.Command, args []string, clientFn runClientFn) (err error)
return fmt.Errorf("the given path '%v' does not contain an initialized function", config.Path)
}
// Client for use running (and potentially building)
client := clientFn(config)
// Build if not built and --build
if config.Build && !function.Built() {
if err = client.Build(cmd.Context(), config.Path); err != nil {
return
}
}
return client.Run(cmd.Context(), config.Path)
// Run the Function at path
job, err := client.Run(cmd.Context(), config.Path)
if err != nil {
return
}
defer job.Stop()
fmt.Fprintf(cmd.OutOrStderr(), "Function started on port %v\n", job.Port)
select {
case <-cmd.Context().Done():
if !errors.Is(cmd.Context().Err(), context.Canceled) {
err = cmd.Context().Err()
}
return
case err = <-job.Errors:
return
}
}
type runConfig struct {

View File

@ -1,6 +1,7 @@
package cmd
import (
"context"
"fmt"
"os"
"testing"
@ -10,104 +11,133 @@ import (
"knative.dev/kn-plugin-func/mock"
)
func TestRunRun(t *testing.T) {
func TestRun_Run(t *testing.T) {
tests := []struct {
name string
fileContents string
buildErrors bool
buildFlag bool
shouldBuild bool
shouldRun bool
name string // name of the test
desc string // description of the test
funcState string // Function state, as described in func.yaml
buildFlag bool // value to which the --build flag should be set
buildError error // Set the builder to yield this error
runError error // Set the runner to yield this error
buildInvoked bool // should Builder.Build be invoked?
runInvoked bool // should Runner.Run be invoked?
}{
{
name: "Should attempt to run even if build is skipped",
fileContents: `name: test-func
name: "run when not building",
desc: "Should run when build is not enabled",
funcState: `name: test-func
runtime: go
created: 2009-11-10 23:00:00`,
buildFlag: false,
shouldBuild: false,
shouldRun: true,
buildFlag: false,
buildInvoked: false,
runInvoked: true,
},
{
name: "Prebuilt image doesn't get built again",
fileContents: `name: test-func
name: "run and build",
desc: "Should run and build when build is enabled and there is no image",
funcState: `name: test-func
runtime: go
image: unexistant
created: 2009-11-10 23:00:00`,
buildFlag: true,
shouldBuild: false,
shouldRun: true,
buildFlag: true,
buildInvoked: true,
runInvoked: true,
},
{
name: "Build when image is empty and build flag is true",
fileContents: `name: test-func
name: "skip rebuild",
desc: "Built image doesn't get built again",
// TODO: this might be improved by checking if the user provided
// the --build=true flag, allowing an override to force rebuild.
// This could be accomplished by adding a 'provideBuildFlag' struct
// member.
funcState: `name: test-func
runtime: go
image: exampleimage
created: 2009-11-10 23:00:00`,
buildFlag: true,
shouldBuild: true,
shouldRun: true,
buildFlag: true,
buildInvoked: false,
runInvoked: true,
},
{
name: "Build error skips execution",
fileContents: `name: test-func
name: "Build errors return",
desc: "Errors building cause an immediate return with error",
funcState: `name: test-func
runtime: go
created: 2009-11-10 23:00:00`,
buildFlag: true,
shouldBuild: true,
shouldRun: false,
buildErrors: true,
buildFlag: true,
buildError: fmt.Errorf("generic build error"),
buildInvoked: true,
runInvoked: false,
},
}
for _, tt := range tests {
mockRunner := mock.NewRunner()
mockBuilder := mock.NewBuilder()
errorBuilder := mock.Builder{
BuildFn: func(f fn.Function) error { return fmt.Errorf("build failed") },
}
cmd := NewRunCmd(func(rc runConfig) *fn.Client {
buildOption := fn.WithBuilder(mockBuilder)
if tt.buildErrors {
buildOption = fn.WithBuilder(&errorBuilder)
}
return fn.New(
fn.WithRunner(mockRunner),
buildOption,
fn.WithRegistry("ghcr.com/reg"),
)
})
tempDir, err := os.MkdirTemp("", "func-tests")
if err != nil {
t.Fatalf("temp dir couldn't be created %v", err)
}
t.Log("tempDir created:", tempDir)
t.Cleanup(func() {
os.RemoveAll(tempDir)
})
fullPath := tempDir + "/func.yaml"
tempFile, err := os.Create(fullPath)
if err != nil {
t.Fatalf("temp file couldn't be created %v", err)
}
cmd.SetArgs([]string{"--path=" + tempDir})
viper.SetDefault("build", tt.buildFlag)
_, err = tempFile.WriteString(tt.fileContents)
if err != nil {
t.Fatalf("file content was not written %v", err)
}
// run as a sub-test
t.Run(tt.name, func(t *testing.T) {
err := cmd.Execute()
if err == nil && tt.buildErrors {
t.Errorf("Expected error: %v but got %v", tt.buildErrors, err)
defer fromTempDir(t)()
runner := mock.NewRunner()
if tt.runError != nil {
runner.RunFn = func(context.Context, fn.Function) (*fn.Job, error) { return nil, tt.runError }
}
if tt.shouldBuild && !(mockBuilder.BuildInvoked || errorBuilder.BuildInvoked) {
t.Errorf("Function was expected to build is: %v but build execution was: %v", tt.shouldBuild, mockBuilder.BuildInvoked || errorBuilder.BuildInvoked)
builder := mock.NewBuilder()
if tt.buildError != nil {
builder.BuildFn = func(f fn.Function) error { return tt.buildError }
}
if mockRunner.RunInvoked != tt.shouldRun {
t.Errorf("Function was expected to run is: %v but run execution was: %v", tt.shouldRun, mockRunner.RunInvoked)
// using a command whose client will be populated with mock
// builder and mock runner, each of which may be set to error if the
// test has an error defined.
cmd := NewRunCmd(func(rc runConfig) *fn.Client {
return fn.New(
fn.WithRunner(runner),
fn.WithBuilder(builder),
fn.WithRegistry("ghcr.com/reg"),
)
})
// set test case's build
viper.SetDefault("build", tt.buildFlag)
// set test case's func.yaml
if err := os.WriteFile("func.yaml", []byte(tt.funcState), os.ModePerm); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
runErrCh := make(chan error, 1)
go func() {
t0 := tt // capture tt into closure
_, err := cmd.ExecuteContextC(ctx)
if err != nil && t0.buildError != nil {
// This is an expected error, so simply continue execution ignoring
// the error (send nil on the channel to release the parent routine
runErrCh <- nil
return
} else if err != nil {
runErrCh <- err // error not expected
return
}
// No errors, but an error was expected:
if t0.buildError != nil {
runErrCh <- fmt.Errorf("Expected error: %v but got %v\n", t0.buildError, err)
}
// Ensure invocations match expectations
if builder.BuildInvoked != tt.buildInvoked {
runErrCh <- fmt.Errorf("Function was expected to build is: %v but build execution was: %v", tt.buildInvoked, builder.BuildInvoked)
}
if runner.RunInvoked != tt.runInvoked {
runErrCh <- fmt.Errorf("Function was expected to run is: %v but run execution was: %v", tt.runInvoked, runner.RunInvoked)
}
close(runErrCh) // release the waiting parent process
}()
cancel() // trigger the return of cmd.ExecuteContextC in the routine
<-ctx.Done()
if err := <-runErrCh; err != nil { // wait for completion of assertions
t.Fatal(err)
}
})
}

View File

@ -11,8 +11,8 @@ import (
"testing"
)
// TestConfigPathDefault ensures that config defaults to XDG_CONFIG_HOME/func
func TestConfigPathDefault(t *testing.T) {
// TestConfig_PathDefault ensures that config defaults to XDG_CONFIG_HOME/func
func TestConfig_PathDefault(t *testing.T) {
// TODO
// Set XDG_CONFIG_PATH to ./testdata/config
// Confirm the config is populated from the test files.
@ -20,7 +20,7 @@ func TestConfigPathDefault(t *testing.T) {
// TestConfigPath ensure that the config path provided via the WithConfig
// option is respected.
func TestConfigPath(t *testing.T) {
func TestConfig_Path(t *testing.T) {
// TODO
// Create a client specifying ./testdata/config
// Confirm the config is populated from the test files.
@ -28,7 +28,7 @@ func TestConfigPath(t *testing.T) {
// TestConfigRepositoriesPath ensures that the repositories directory within
// the effective config path is created if it does not already exist.
func TestConfigRepositoriesPath(t *testing.T) {
func TestConfig_RepositoriesPath(t *testing.T) {
// TODO
// Create a temporary directory
// Specify this directory as the config path when instantiating a client.

View File

@ -155,6 +155,8 @@ func TestCheckAuth(t *testing.T) {
}
func startServer(t *testing.T) (addr, addrTLS string, stopServer func()) {
// TODO: this should be refactored to use OS-chosen ports so as not to
// fail when a user is running a Function on the default port.)
listener, err := net.Listen("tcp", "localhost:8080")
if err != nil {
t.Fatal(err)

View File

@ -3,15 +3,15 @@ package docker
import (
"context"
"fmt"
"net"
"os"
"regexp"
"strings"
"time"
"github.com/docker/docker/client"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/client"
"github.com/docker/docker/pkg/stdcopy"
"github.com/docker/go-connections/nat"
"github.com/pkg/errors"
@ -19,9 +19,24 @@ import (
fn "knative.dev/kn-plugin-func"
)
// Runner of functions using the docker command.
const (
// DefaultHost is the standard ipv4 looback
DefaultHost = "127.0.0.1"
// DefaultPort is used as the preferred port, and in the unlikly event of an
// error querying the OS for a free port during allocation.
DefaultPort = "8080"
// DefaultDialTimeout when checking if a port is available.
DefaultDialTimeout = 2 * time.Second
// DefaultStopTimeout when attempting to stop underlying containers.
DefaultStopTimeout = 10 * time.Second
)
// Runner starts and stops Functions as local contaieners.
type Runner struct {
// Verbose logging flag.
// Verbose logging
Verbose bool
}
@ -30,122 +45,201 @@ func NewRunner() *Runner {
return &Runner{}
}
// Run the function at path
func (n *Runner) Run(ctx context.Context, f fn.Function) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Run the Function.
func (n *Runner) Run(ctx context.Context, f fn.Function) (job *fn.Job, err error) {
cli, _, err := NewClient(client.DefaultDockerHost)
if err != nil {
return errors.Wrap(err, "failed to create docker api client")
}
defer cli.Close()
var (
port = choosePort(DefaultHost, DefaultPort, DefaultDialTimeout)
c client.CommonAPIClient // Docker client
id string // ID of running container
conn net.Conn // Connection to container's stdio
// Channels for gathering runtime errors from the container instance
copyErrCh = make(chan error, 10)
contBodyCh <-chan container.ContainerWaitOKBody
contErrCh <-chan error
// Combined runtime error channel for sending all errors to caller
runtimeErrCh = make(chan error, 10)
)
if f.Image == "" {
return errors.New("Function has no associated Image. Has it been built? Using the --build flag will build the image if it hasn't been built yet")
return job, errors.New("Function has no associated image. Has it been built?")
}
if c, _, err = NewClient(client.DefaultDockerHost); err != nil {
return job, errors.Wrap(err, "failed to create Docker API client")
}
if id, err = newContainer(ctx, c, f, port, n.Verbose); err != nil {
return job, errors.Wrap(err, "runner unable to create container")
}
if conn, err = copyStdio(ctx, c, id, copyErrCh); err != nil {
return
}
// Wait for errors or premature exits
contBodyCh, contErrCh = c.ContainerWait(ctx, id, container.WaitConditionNextExit)
go func() {
for {
select {
case err = <-copyErrCh:
runtimeErrCh <- err
case body := <-contBodyCh:
// NOTE: currently an exit is not expected and thus a return, for any
// reason, is considered an error even when the exit code is 0.
// Functions are expected to be long-running processes that do not exit
// of their own accord when run locally. Should this expectation
// change in the future, this channel-based wait may need to be
// expanded to accept the case of a voluntary, successful exit.
runtimeErrCh <- fmt.Errorf("exited code %v", body.StatusCode)
case err = <-contErrCh:
runtimeErrCh <- err
}
}
}()
// Start
if err = c.ContainerStart(ctx, id, types.ContainerStartOptions{}); err != nil {
return job, errors.Wrap(err, "runner unable to start container")
}
// Stopper
stop := func() {
var (
timeout = DefaultStopTimeout
ctx = context.Background()
)
if err = c.ContainerStop(ctx, id, &timeout); err != nil {
fmt.Fprintf(os.Stderr, "error stopping container %v: %v\n", id, err)
}
if err = c.ContainerRemove(ctx, id, types.ContainerRemoveOptions{}); err != nil {
fmt.Fprintf(os.Stderr, "error removing container %v: %v\n", id, err)
}
if err = conn.Close(); err != nil {
fmt.Fprintf(os.Stderr, "error closing connection to container: %v\n", err)
}
if err = c.Close(); err != nil {
fmt.Fprintf(os.Stderr, "error closing daemon client: %v\n", err)
}
}
// Job reporting port, runtime errors and provides a mechanism for stopping.
return fn.NewJob(f, port, runtimeErrCh, stop)
}
// Dial the given (tcp) port on the given interface, returning an error if it is
// unreachable.
func dial(host, port string, dialTimeout time.Duration) (err error) {
address := net.JoinHostPort(host, port)
conn, err := net.DialTimeout("tcp", address, dialTimeout)
if err != nil {
return
}
defer conn.Close()
return
}
// choosePort returns an unused port
// Note this is not fool-proof becase of a race with any other processes
// looking for a port at the same time.
// Note that TCP is presumed.
func choosePort(host string, preferredPort string, dialTimeout time.Duration) string {
// If we can not dial the preferredPort, it is assumed to be open.
if err := dial(host, preferredPort, dialTimeout); err != nil {
return preferredPort
}
// Use an OS-chosen port
lis, err := net.Listen("tcp", net.JoinHostPort(host, "")) // listen on any open port
if err != nil {
fmt.Fprintf(os.Stderr, "unable to check for open ports. using fallback %v. %v", DefaultPort, err)
return DefaultPort
}
defer lis.Close()
_, port, err := net.SplitHostPort(lis.Addr().String())
if err != nil {
fmt.Fprintf(os.Stderr, "unable to extract port from allocated listener address '%v'. %v", lis.Addr(), err)
return DefaultPort
}
return port
}
func newContainer(ctx context.Context, c client.CommonAPIClient, f fn.Function, port string, verbose bool) (id string, err error) {
var (
containerCfg container.Config
hostCfg container.HostConfig
)
if containerCfg, err = newContainerConfig(f, port, verbose); err != nil {
return
}
if hostCfg, err = newHostConfig(port); err != nil {
return
}
t, err := c.ContainerCreate(ctx, &containerCfg, &hostCfg, nil, nil, "")
if err != nil {
return
}
return t.ID, nil
}
func newContainerConfig(f fn.Function, _ string, verbose bool) (c container.Config, err error) {
envs, err := newEnvironmentVariables(f, verbose)
if err != nil {
return
}
// httpPort := nat.Port(fmt.Sprintf("%v/tcp", port))
httpPort := nat.Port("8080/tcp")
return container.Config{
Image: f.Image,
Env: envs,
Tty: false,
AttachStderr: true,
AttachStdout: true,
AttachStdin: false,
ExposedPorts: map[nat.Port]struct{}{httpPort: {}},
}, nil
}
func newHostConfig(port string) (c container.HostConfig, err error) {
// httpPort := nat.Port(fmt.Sprintf("%v/tcp", port))
httpPort := nat.Port("8080/tcp")
ports := map[nat.Port][]nat.PortBinding{
httpPort: {
nat.PortBinding{
HostPort: port,
HostIP: "127.0.0.1",
},
},
}
return container.HostConfig{PortBindings: ports}, nil
}
func newEnvironmentVariables(f fn.Function, verbose bool) ([]string, error) {
// TODO: this has code-smell. It may not be ideal to have fn.Function
// represent Envs as pointers, as this causes the clearly odd situation of
// needing to check if an env defined in f is just nil pointers: an invalid
// data structure.
envs := []string{}
for _, env := range f.Envs {
if env.Name != nil && env.Value != nil {
value, set, err := processEnvValue(*env.Value)
if err != nil {
return err
return envs, err
}
if set {
envs = append(envs, *env.Name+"="+value)
}
}
}
if n.Verbose {
if verbose {
envs = append(envs, "VERBOSE=true")
}
httpPort := nat.Port("8080/tcp")
ports := map[nat.Port][]nat.PortBinding{
httpPort: {
nat.PortBinding{
HostPort: "8080",
HostIP: "127.0.0.1",
},
},
}
conf := &container.Config{
Env: envs,
Tty: false,
AttachStderr: true,
AttachStdout: true,
AttachStdin: false,
Image: f.Image,
ExposedPorts: map[nat.Port]struct{}{httpPort: {}},
}
hostConf := &container.HostConfig{
PortBindings: ports,
}
cont, err := cli.ContainerCreate(ctx, conf, hostConf, nil, nil, "")
if err != nil {
return errors.Wrap(err, "failed to create container")
}
defer func() {
err := cli.ContainerRemove(context.Background(), cont.ID, types.ContainerRemoveOptions{})
if err != nil {
fmt.Fprintf(os.Stderr, "failed to remove container: %v", err)
}
}()
attachOptions := types.ContainerAttachOptions{
Stdout: true,
Stderr: true,
Stdin: false,
Stream: true,
}
resp, err := cli.ContainerAttach(ctx, cont.ID, attachOptions)
if err != nil {
return errors.Wrap(err, "failed to attach container")
}
defer resp.Close()
copyErrChan := make(chan error, 1)
go func() {
_, err := stdcopy.StdCopy(os.Stdout, os.Stderr, resp.Reader)
copyErrChan <- err
}()
waitBodyChan, waitErrChan := cli.ContainerWait(ctx, cont.ID, container.WaitConditionNextExit)
err = cli.ContainerStart(ctx, cont.ID, types.ContainerStartOptions{})
if err != nil {
return errors.Wrap(err, "failed to start container")
}
defer func() {
t := time.Second * 10
err := cli.ContainerStop(context.Background(), cont.ID, &t)
if err != nil {
fmt.Fprintf(os.Stderr, "failed to stop container: %v", err)
}
}()
select {
case body := <-waitBodyChan:
if body.StatusCode != 0 {
return fmt.Errorf("failed with status code: %d", body.StatusCode)
}
case err := <-waitErrChan:
return err
case err := <-copyErrChan:
return err
case <-ctx.Done():
return ctx.Err()
}
return nil
return envs, nil
}
// run command supports only ENV values in from FOO=bar or FOO={{ env:LOCAL_VALUE }}
// run command supports only ENV values in form:
// FOO=bar or FOO={{ env:LOCAL_VALUE }}
var evRegex = regexp.MustCompile(`^{{\s*(\w+)\s*:(\w+)\s*}}$`)
const (
@ -172,3 +266,25 @@ func processEnvValue(val string) (string, bool, error) {
}
return val, true, nil
}
// copy stdin and stdout from the container of the given ID. Errors encountered
// during copy are communicated via a provided errs channel.
func copyStdio(ctx context.Context, c client.CommonAPIClient, id string, errs chan error) (conn net.Conn, err error) {
var (
res types.HijackedResponse
opt = types.ContainerAttachOptions{
Stdout: true,
Stderr: true,
Stdin: false,
Stream: true,
}
)
if res, err = c.ContainerAttach(ctx, id, opt); err != nil {
return conn, errors.Wrap(err, "runner unable to attach to container's stdio")
}
go func() {
_, err := stdcopy.StdCopy(os.Stdout, os.Stderr, res.Reader)
errs <- err
}()
return res.Conn, nil
}

View File

@ -32,12 +32,12 @@ func TestDockerRun(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// TODO: This test is too tricky, as it requires the related image be
// already built. Build the function prior to running?
// NOTE: test requires that the image be built already.
runner := docker.NewRunner()
runner.Verbose = true
if err = runner.Run(context.Background(), f); err != nil {
if _, err = runner.Run(context.Background(), f); err != nil {
t.Fatal(err)
}
/* TODO
@ -50,9 +50,11 @@ func TestDockerRun(t *testing.T) {
func TestDockerRunImagelessError(t *testing.T) {
runner := docker.NewRunner()
f := fn.NewFunctionWith(fn.Function{})
err := runner.Run(context.Background(), f)
expectedErrorMessage := "Function has no associated Image. Has it been built? Using the --build flag will build the image if it hasn't been built yet"
_, err := runner.Run(context.Background(), f)
// TODO: switch to typed error:
expectedErrorMessage := "Function has no associated image. Has it been built?"
if err == nil || err.Error() != expectedErrorMessage {
t.Fatalf("The expected error message is \"%v\" but got instead %v", expectedErrorMessage, err)
t.Fatalf("Expected error '%v', got '%v'", expectedErrorMessage, err)
}
}

View File

@ -127,40 +127,43 @@ When run as a `kn` plugin.
kn func delete <name> [-n namespace, -p path]
```
## `emit`
## `invoke`
Emits a CloudEvent, sending it to the deployed function. The user may specify the event type, source and ID,
and may provide event data on the command line or in a file on disk. By default, `event` works on the local
directory, assuming that it is a function project. Alternatively the user may provide a path to a project
directory using the `--path` flag, or send an event to an arbitrary endpoint using the `--sink` flag. The
`--sink` flag also accepts the special value `local` to send an event to the function running locally, for
example, when run via `func run`.
Invokes a running function. By default, a locally running instance will be preferred
over a remote if both are running. The user may specify the event type, source,
ID, and may provide event data on the command line or in a file on disk.
`invoke` works on the local directory, assuming that it is a function project.
Alternatively the user may provide a path to a project directory using the
`--path` flag, or send an event to an arbitrary endpoint using the `--target`
flag. The `--target` flag also accepts the special values `local` and `remote`
to send an event to a locally running function instance or a function running
on the remote cluster in the active deployed context.
Similar `kn` command when using the [kn-plugin-event](https://github.com/knative-sandbox/kn-plugin-event): `kn event send [FLAGS]`
Examples:
```console
# Send a CloudEvent to the deployed function with no data and default values
# Send a request to the local function with no data and default values
# for source, type and ID
kn func emit
kn func invoke
# Send a CloudEvent to the deployed function with the data found in ./test.json
kn func emit --file ./test.json
# Send a message to the local function with the data found in ./test.json
kn func invoke --file ./test.json
# Send a CloudEvent to the function running locally with a CloudEvent containing
# Send a message to the deployed function containing
# "Hello World!" as the data field, with a content type of "text/plain"
kn func emit --data "Hello World!" --content-type "text/plain" -s local
kn func invoke --data "Hello World!" --content-type "text/plain" --target remote
# Send a CloudEvent to the function running locally with an event type of "my.event"
kn func emit --type my.event --sink local
# Send a message to the deployed function with an event type of "my.event"
kn func invoke --type my.event --target remote
# Send a CloudEvent to the deployed function found at /path/to/fn with an id of "fn.test"
kn func emit --path /path/to/fn -i fn.test
# Send a message to the local function found at /path/to/fn with an id of "fn.test"
kn func invoke --path /path/to/fn --id fn.test
# Send a CloudEvent to an arbitrary endpoint
kn func emit --sink "http://my.event.broker.com"
```
kn func invoke --target "http://my.event.broker.com" --format=cloudevent
## `config`

View File

@ -100,6 +100,22 @@ type Function struct {
// according to the client which is in charge of what constitutes being
// fully "Created" (aka initialized)
Created time.Time
// Invocation defines hints for use when invoking this function.
// See Client.Invoke for usage.
Invocation Invocation `yaml:"invocation,omitempty"`
}
// Invocation defines hints on how to accomplish a Function invocation.
type Invocation struct {
// Format indicates the expected format of the invocation. Either 'http'
// (a basic HTTP POST of standard form fields) or 'cloudevent'
// (a CloudEvents v2 formatted http request).
Format string `yaml:"format,omitempty"`
// Protocol Note:
// Protocol is currently always HTTP. Method etc. determined by the single,
// simple switch of the Format field.
}
// NewFunctionWith defaults as provided.
@ -354,6 +370,7 @@ func assertEmptyRoot(path string) (err error) {
// Function rooted in the given directory.
var contentiousFiles = []string{
FunctionFile,
".gitignore",
}
// contentiousFilesIn the given directory

View File

@ -12,9 +12,9 @@ import (
. "knative.dev/kn-plugin-func/testing"
)
// TestWriteIdempotency ensures that a Function can be written repeatedly
// TestFunction_WriteIdempotency ensures that a Function can be written repeatedly
// without change.
func TestWriteIdempotency(t *testing.T) {
func TestFunction_WriteIdempotency(t *testing.T) {
root, rm := Mktemp(t)
defer rm()
client := fn.New(fn.WithRegistry(TestRegistry))
@ -47,11 +47,11 @@ func TestWriteIdempotency(t *testing.T) {
}
}
// TestFunctionNameDefault ensures that a Function's name is defaulted to that
// TestFunction_NameDefault ensures that a Function's name is defaulted to that
// which can be derived from the last part of its path.
// Creating a new Function from a path will error if there is no Function at
// that path. Creating using the client initializes the default.
func TestFunctionNameDefault(t *testing.T) {
func TestFunction_NameDefault(t *testing.T) {
// A path at which there is no Function currently
root := "testdata/testFunctionNameDefault"
defer Using(t, root)()

118
instances.go Normal file
View File

@ -0,0 +1,118 @@
package function
import (
"context"
"errors"
"fmt"
)
const (
EnvironmentLocal = "local"
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")
)
// Instances manager
//
// Instances are point-in-time snapshots of a Function's runtime state in
// a given environment. By default 'local' and 'remote' environmnts are
// available when a Function is run locally and deployed (respectively).
type Instances struct {
client *Client
}
// newInstances creates a new manager of instances.
func newInstances(client *Client) *Instances {
return &Instances{client: client}
}
// Get the instance data for a Function in the named environment.
// For convenient access to the default 'local' and 'remote' environment
// see the Local and Remote methods, respectively.
// Instance returned is populated with a point-in-time snapshot of the
// Function state in the named environment.
func (s *Instances) Get(ctx context.Context, f Function, environment string) (Instance, error) {
switch environment {
case EnvironmentLocal:
return s.Local(ctx, f)
case EnvironmentRemote:
return s.Remote(ctx, f.Name, f.Root)
default:
// Future versions will support additional ad-hoc named environments, such
// as for testing. Local and remote remaining the base cases.
return Instance{}, ErrEnvironmentNotFound
}
}
// Local instance details for the Function
// If the Function is not running locally the error returned is ErrNotRunning
func (s *Instances) Local(ctx context.Context, f Function) (Instance, error) {
var i Instance
// To create a local instance the Function must have a root path defined
// which contains an initialized function and be running.
if f.Root == "" {
return i, ErrRootRequired
}
if !f.Initialized() {
return i, ErrNotInitialized
}
ports := jobPorts(f)
if len(ports) == 0 {
return i, ErrNotRunning
}
route := fmt.Sprintf("http://localhost:%s/", ports[0])
return Instance{
Route: route,
Routes: []string{route},
Name: f.Name,
}, nil
}
// Remote instance details for the Function
//
// Since this is specific to the implicitly available 'remote' environment, the
// request can be completed with either a name or the local source. Therefore
// either name or root path can be passed. If name is not passed, the Function
// at root is loaded and its name used for describing the remote instance.
// Name takes precedence.
func (s *Instances) Remote(ctx context.Context, name, root string) (Instance, error) {
var (
f Function
err error
)
// Error if name and root disagree
// If both a name and root were passed but the Function at the root either
// does not exist or does not match the name, fail fast.
// The purpose of this method's signature is to allow passing either name or
// root, but doing so requires that we manually validate.
if name != "" && root != "" {
f, err = NewFunction(root)
if err != nil {
return Instance{}, err
}
if name != f.Name {
return Instance{}, errors.New(
"name passed does not match name of the Function at root. " +
"Try passing either name or root rather than both.")
}
}
// Name takes precedence if provided
if name != "" {
f = Function{Name: name}
} else {
if f, err = NewFunction(root); err != nil {
return Instance{}, err
}
}
return s.client.describer.Describe(ctx, f.Name)
}

177
invoke.go Normal file
View File

@ -0,0 +1,177 @@
package function
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"time"
cloudevents "github.com/cloudevents/sdk-go/v2"
"github.com/google/uuid"
)
const (
DefaultInvokeSource = "/boson/fn"
DefaultInvokeType = "boson.fn"
DefaultInvokeContentType = "text/plain"
DefaultInvokeData = "Hello World"
DefaultInvokeFormat = "http"
)
// InvokeMesage is the message used by the convenience method Invoke to provide
// a simple way to trigger the execution of a Function during development.
type InvokeMessage struct {
ID string
Source string
Type string
ContentType string
Data string
Format string //optional override for Function-defined message format
}
// NewInvokeMessage creates a new InvokeMessage with fields populated
func NewInvokeMessage() InvokeMessage {
return InvokeMessage{
ID: uuid.NewString(),
Source: DefaultInvokeSource,
Type: DefaultInvokeType,
ContentType: DefaultInvokeContentType,
Data: DefaultInvokeData,
// Format override not set by default: value from Function being preferred.
}
}
// invoke the Function instance in the target environment with the
// invocation message.
func invoke(ctx context.Context, c *Client, f Function, target string, m InvokeMessage) error {
// Get the first available route from 'local', 'remote', a named environment
// or treat target
route, err := invocationRoute(ctx, c, f, target) // choose instance to invoke
if err != nil {
return err
}
// Format" either 'http' or 'cloudevent'
// TODO: discuss if providing a Format on Message should a) update the
// Function to use the new format if none is defined already (backwards
// compatibility fix) or b) always update the Function, even if it was already
// set. Once decided, codify in a test.
format := DefaultInvokeFormat
if f.Invocation.Format != "" {
// Prefer the format set during Function creation if defined.
format = f.Invocation.Format
}
if m.Format != "" {
// Use the override specified on the message if provided
format = m.Format
}
switch format {
case "http":
return sendPost(ctx, route, m, c.transport)
case "cloudevent":
return sendEvent(ctx, route, m, c.transport)
default:
return fmt.Errorf("format '%v' not supported.", format)
}
}
// invocationRoute returns a route to the named target instance of a Func:
// 'local': local environment; locally running Function (error if not running)
// 'remote': remote environment; first available instance (error if none)
// '<environment>': A valid alternate target which contains instances.
// '<url>': An explicit URL
// '': Default if no target is passed is to first use local, then remote.
// errors if neither are available.
func invocationRoute(ctx context.Context, c *Client, f Function, target string) (string, error) {
// TODO: this function has code-smell; will de-smellify it in next pass.
if target == EnvironmentLocal {
instance, err := c.Instances().Get(ctx, f, target)
if err != nil {
if errors.Is(err, ErrEnvironmentNotFound) {
return "", errors.New("not running locally")
}
return "", err
}
return instance.Route, nil
} else if target == EnvironmentRemote {
instance, err := c.Instances().Get(ctx, f, target)
if err != nil {
if errors.Is(err, ErrEnvironmentNotFound) {
return "", errors.New("not running in remote")
}
return "", err
}
return instance.Route, nil
} else if target == "" { // target blank, check local first then remote.
instance, err := c.Instances().Get(ctx, f, EnvironmentLocal)
if err != nil && !errors.Is(err, ErrNotRunning) {
return "", err // unexpected errors are anything other than ErrNotRunning
}
if err == nil {
return instance.Route, nil // found instance in local environment
}
instance, err = c.Instances().Get(ctx, f, EnvironmentRemote)
if errors.Is(err, ErrNotRunning) {
return "", errors.New("not running locally or in the remote")
}
if err != nil {
return "", err // unexpected error
}
return instance.Route, nil
} else { // treat an unrecognized target as an ad-hoc verbatim endpoint
return target, nil
}
}
// sendEvent to the route populated with data in the invoke message.
func sendEvent(ctx context.Context, route string, m InvokeMessage, t http.RoundTripper) (err error) {
event := cloudevents.NewEvent()
event.SetID(m.ID)
event.SetSource(m.Source)
event.SetType(m.Type)
if err = event.SetData(m.ContentType, m.Data); err != nil {
return
}
c, err := cloudevents.NewClientHTTP(
cloudevents.WithTarget(route),
cloudevents.WithRoundTripper(t))
if err != nil {
return
}
result := c.Send(cloudevents.ContextWithTarget(ctx, route), event)
if cloudevents.IsUndelivered(result) {
err = fmt.Errorf("unable to invoke: %v", result)
}
return
}
// sendPost to the route populated with data in the invoke message.
func sendPost(ctx context.Context, route string, m InvokeMessage, t http.RoundTripper) error {
client := http.Client{
Transport: t,
Timeout: 10 * time.Second,
}
resp, err := client.PostForm(route, url.Values{
"ID": {m.ID},
"Source": {m.Source},
"Type": {m.Type},
"ContentType": {m.ContentType},
"Data": {m.Data},
})
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return fmt.Errorf("failure invoking '%v' (HTTP %v)", route, resp.StatusCode)
}
return nil
}

92
job.go Normal file
View File

@ -0,0 +1,92 @@
package function
import (
"fmt"
"os"
"path/filepath"
)
const (
// RunDataDir holds transient runtime metadata
// By default it is excluded from source control.
RunDataDir = ".func"
)
// Job represents a running Function job (presumably started by this process'
// Runner instance.
type Job struct {
Function Function
Port string
Errors chan error
onStop func()
}
// Create a new Job which represents a running Function task by providing
// the port on which it was started, a channel on which runtime errors can
// be received, and a stop function.
func NewJob(f Function, port string, errs chan error, onStop func()) (*Job, error) {
j := &Job{
Function: f,
Port: port,
Errors: errs,
onStop: onStop,
}
return j, j.save() // Everything is a file: save instance data to disk.
}
// Stop the Job, running the provided stop delegate and removing runtime
// metadata from disk.
func (j *Job) Stop() {
_ = j.remove() // Remove representation on disk
j.onStop()
}
func (j *Job) save() error {
instancesDir := filepath.Join(j.Function.Root, RunDataDir, "instances")
// job metadata is stored in <root>/.func/instances
mkdir(instancesDir)
// create a file <root>/.func/instances/<port>
file, err := os.Create(filepath.Join(instancesDir, j.Port))
if err != nil {
return err
}
return file.Close()
// Store the effective port for use by other client instances, possibly
// in other processes, such as to run Invoke from other terminal in CLI apps.
/*
if err := writeFunc(f, "port", []byte(port)); err != nil {
return j, err
}
return j, nil
*/
}
func (j *Job) remove() error {
filename := filepath.Join(j.Function.Root, RunDataDir, "instances", j.Port)
return os.Remove(filename)
}
// jobPorts returns all the ports on which an instance of the given Function is
// running. len is 0 when not running.
// Improperly initialized or nonexistent (zero value) Functions are considered
// to not be running.
func jobPorts(f Function) []string {
if f.Root == "" || !f.Initialized() {
return []string{}
}
instancesDir := filepath.Join(f.Root, RunDataDir, "instances")
mkdir(instancesDir)
files, err := os.ReadDir(instancesDir)
if err != nil {
fmt.Fprintf(os.Stderr, "error reading %v", instancesDir)
return []string{}
}
ports := []string{}
for _, f := range files {
ports = append(ports, f.Name())
}
return ports
}

View File

@ -31,7 +31,7 @@ func NewDescriber(namespaceOverride string) (describer *Describer, err error) {
// restricts to label-syntax, which is thus escaped. Therefore as a knative (kube) implementation
// detal proper full names have to be escaped on the way in and unescaped on the way out. ex:
// www.example-site.com -> www-example--site-com
func (d *Describer) Describe(ctx context.Context, name string) (description fn.Info, err error) {
func (d *Describer) Describe(ctx context.Context, name string) (description fn.Instance, err error) {
servingClient, err := NewServingClient(d.namespace)
if err != nil {
@ -58,6 +58,11 @@ func (d *Describer) Describe(ctx context.Context, name string) (description fn.I
routeURLs = append(routeURLs, route.Status.URL.String())
}
primaryRouteURL := ""
if len(routes.Items) > 0 {
primaryRouteURL = routes.Items[0].Status.URL.String()
}
triggers, err := eventingClient.ListTriggers(ctx)
// IsNotFound -- Eventing is probably not installed on the cluster
if err != nil && !errors.IsNotFound(err) {
@ -86,6 +91,7 @@ func (d *Describer) Describe(ctx context.Context, name string) (description fn.I
description.Name = name
description.Namespace = d.namespace
description.Route = primaryRouteURL
description.Routes = routeURLs
description.Subscriptions = subscriptions

23
mock/describer.go Normal file
View File

@ -0,0 +1,23 @@
package mock
import (
"context"
fn "knative.dev/kn-plugin-func"
)
type Describer struct {
DescribeInvoked bool
DescribeFn func(string) (fn.Instance, error)
}
func NewDescriber() *Describer {
return &Describer{
DescribeFn: func(string) (fn.Instance, error) { return fn.Instance{}, nil },
}
}
func (l *Describer) Describe(_ context.Context, name string) (fn.Instance, error) {
l.DescribeInvoked = true
return l.DescribeFn(name)
}

View File

@ -1,21 +0,0 @@
package mock
import (
"context"
)
type Emitter struct {
EmitInvoked bool
EmitFn func(string) error
}
func NewEmitter() *Emitter {
return &Emitter{
EmitFn: func(string) error { return nil },
}
}
func (i *Emitter) Emit(ctx context.Context, s string) error {
i.EmitInvoked = true
return i.EmitFn(s)
}

View File

@ -2,21 +2,35 @@ package mock
import (
"context"
"sync"
fn "knative.dev/kn-plugin-func"
)
// Runner runs a Function in a separate process, canceling it on context.Cancel.
// Immediately returned is the port of the running Function.
type Runner struct {
RunInvoked bool
RootRequested string
RunFn func(context.Context, fn.Function) (*fn.Job, error)
sync.Mutex
}
func NewRunner() *Runner {
return &Runner{}
return &Runner{
RunFn: func(ctx context.Context, f fn.Function) (*fn.Job, error) {
errs := make(chan error, 1)
stop := func() {}
return fn.NewJob(f, "8080", errs, stop)
},
}
}
func (r *Runner) Run(ctx context.Context, f fn.Function) error {
func (r *Runner) Run(ctx context.Context, f fn.Function) (*fn.Job, error) {
r.Lock()
defer r.Unlock()
r.RunInvoked = true
r.RootRequested = f.Root
return nil
return r.RunFn(ctx, f)
}

File diff suppressed because one or more lines are too long

View File

@ -20,9 +20,9 @@ import (
// requisite test.
const RepositoriesTestRepo = "repository"
// TestRepositoriesList ensures the base case of listing
// TestRepositories_List ensures the base case of listing
// repositories without error in the default scenario of builtin only.
func TestRepositoriesList(t *testing.T) {
func TestRepositories_List(t *testing.T) {
root, rm := Mktemp(t)
defer rm()
@ -38,9 +38,9 @@ func TestRepositoriesList(t *testing.T) {
}
}
// TestRepositoriesGetInvalid ensures that attempting to get an invalid repo
// TestRepositories_GetInvalid ensures that attempting to get an invalid repo
// results in error.
func TestRepositoriesGetInvalid(t *testing.T) {
func TestRepositories_GetInvalid(t *testing.T) {
client := fn.New(fn.WithRepositories("testdata/repositories"))
// invalid should error
@ -50,8 +50,8 @@ func TestRepositoriesGetInvalid(t *testing.T) {
}
}
// TestRepositoriesGet ensures a repository can be accessed by name.
func TestRepositoriesGet(t *testing.T) {
// TestRepositories_Get ensures a repository can be accessed by name.
func TestRepositories_Get(t *testing.T) {
client := fn.New(fn.WithRepositories("testdata/repositories"))
// valid should not error
@ -66,9 +66,9 @@ func TestRepositoriesGet(t *testing.T) {
}
}
// TestRepositoriesAll ensures repos are returned from
// TestRepositories_All ensures repos are returned from
// .All accessor. Tests both builtin and buitlin+extensible cases.
func TestRepositoriesAll(t *testing.T) {
func TestRepositories_All(t *testing.T) {
uri := TestRepoURI(RepositoriesTestRepo, t)
root, rm := Mktemp(t)
defer rm()
@ -104,8 +104,8 @@ func TestRepositoriesAll(t *testing.T) {
}
}
// TestRepositoriesAdd checks basic adding of a repository by URI.
func TestRepositoriesAdd(t *testing.T) {
// TestRepositories_Add checks basic adding of a repository by URI.
func TestRepositories_Add(t *testing.T) {
uri := TestRepoURI(RepositoriesTestRepo, t) // ./testdata/$RepositoriesTestRepo.git
root, rm := Mktemp(t) // create and cd to a temp dir, returning path.
defer rm()
@ -137,9 +137,9 @@ func TestRepositoriesAdd(t *testing.T) {
}
}
// TestRepositoriesAddDefaultName ensures that repository name is optional,
// TestRepositories_AddDefaultName ensures that repository name is optional,
// by default being set to the name of the repoisotory from the URI.
func TestRepositoriesAddDeafultName(t *testing.T) {
func TestRepositories_AddDeafultName(t *testing.T) {
// The test repository is the "base case" repo, which is a manifestless
// repo meant to exemplify the simplest use case: a repo with no metadata
// that simply contains templates, grouped by runtime. It therefore does
@ -171,10 +171,10 @@ func TestRepositoriesAddDeafultName(t *testing.T) {
}
}
// TestRepositoriesAddWithManifest ensures that a repository with
// TestRepositories_AddWithManifest ensures that a repository with
// a manfest wherein a default name is specified, is used as the name for the
// added repository when a name is not explicitly specified.
func TestRepositoriesAddWithManifest(t *testing.T) {
func TestRepositories_AddWithManifest(t *testing.T) {
// repository-b is meant to exemplify the use case of a repository which
// defines a custom language pack and makes full use of the manifest.yaml.
// The manifest.yaml is included which specifies things like custom templates
@ -207,9 +207,9 @@ func TestRepositoriesAddWithManifest(t *testing.T) {
}
}
// TestRepositoriesAddExistingErrors ensures that adding a repository that
// TestRepositories_AddExistingErrors ensures that adding a repository that
// already exists yields an error.
func TestRepositoriesAddExistingErrors(t *testing.T) {
func TestRepositories_AddExistingErrors(t *testing.T) {
uri := TestRepoURI(RepositoriesTestRepo, t)
root, rm := Mktemp(t) // create and cd to a temp dir, returning path.
defer rm()
@ -242,8 +242,8 @@ func TestRepositoriesAddExistingErrors(t *testing.T) {
}
}
// TestRepositoriesRename ensures renaming a repository succeeds.
func TestRepositoriesRename(t *testing.T) {
// TestRepositories_Rename ensures renaming a repository succeeds.
func TestRepositories_Rename(t *testing.T) {
uri := TestRepoURI(RepositoriesTestRepo, t)
root, rm := Mktemp(t) // create and cd to a temp dir, returning path.
defer rm()
@ -275,9 +275,9 @@ func TestRepositoriesRename(t *testing.T) {
}
}
// TestRepositoriesRemove ensures that removing a repository by name
// TestRepositories_Remove ensures that removing a repository by name
// removes it from the list and FS.
func TestRepositoriesRemove(t *testing.T) {
func TestRepositories_Remove(t *testing.T) {
uri := TestRepoURI(RepositoriesTestRepo, t) // ./testdata/repository.git
root, rm := Mktemp(t) // create and cd to a temp dir
defer rm()
@ -310,9 +310,9 @@ func TestRepositoriesRemove(t *testing.T) {
}
}
// TestRepositoriesURL ensures that a repository populates its URL member
// TestRepositories_URL ensures that a repository populates its URL member
// from the git repository's origin url (if it is a git repo and exists)
func TestRepositoriesURL(t *testing.T) {
func TestRepositories_URL(t *testing.T) {
// FIXME: This test is temporarily disabled. See not in Repository.Write
// in short: as a side-effect of removing the double-clone, the in-memory
// repo is insufficient as it does not include a .git directory.
@ -344,13 +344,13 @@ func TestRepositoriesURL(t *testing.T) {
}
}
// TestRepositoriesMissing ensures that a missing repositores directory
// TestRepositories_Missing ensures that a missing repositores directory
// does not cause an error unless it was explicitly set (zero value indicates
// no repos should be loaded from os).
// This may change in an upcoming release where the repositories directory
// will be created at the config path if it does not exist, but this requires
// first moving the defaulting path logic from CLI into the client lib.
func TestRepositoriesMissing(t *testing.T) {
func TestRepositories_Missing(t *testing.T) {
// Client with no repositories path defined.
client := fn.New()

View File

@ -26,6 +26,10 @@ const (
DefaultLivenessEndpoint = "/health/liveness"
// DefaultTemplatesPath is the root of the defined repository
DefaultTemplatesPath = "."
// DefaultInvocationFormat is a named invocation hint for the convenience
// helper .Invoke. It is usually set at the template level. The default
// ('http') is a plain HTTP POST.
DefaultInvocationFormat = "http"
// Defaults for Builder and Builders not expressly defined as a purposeful
// delegation of choice.
@ -66,6 +70,10 @@ type Repository struct {
// TODO upgrade to fs.FS introduced in go1.16
FS Filesystem
// Invocation hints for all templates in this repo
// (it is more likely this will be set only at the template level)
Invocation Invocation `yaml:"invocation,omitempty"`
uri string // URI which was used when initially creating
}
@ -92,6 +100,10 @@ type Runtime struct {
// of Runtime.
BuildConfig `yaml:",inline"`
// Invocation hints for all templates in this runtime
// (it is more likely this will be set only at the template level)
Invocation Invocation `yaml:"invocation,omitempty"`
// Templates defined for the runtime
Templates []Template
}
@ -121,6 +133,7 @@ func NewRepository(name, uri string) (r Repository, err error) {
Liveness: DefaultLivenessEndpoint,
Readiness: DefaultLivenessEndpoint,
},
Invocation: Invocation{Format: DefaultInvocationFormat},
}
r.FS, err = filesystemFromURI(uri) // Get a Filesystem from the URI
if err != nil {
@ -240,6 +253,7 @@ func repositoryRuntimes(r Repository) (runtimes []Runtime, err error) {
BuildConfig: r.BuildConfig,
HealthEndpoints: r.HealthEndpoints,
BuildEnvs: r.BuildEnvs,
Invocation: r.Invocation,
}
// Runtime Manifest
// Load the file if it exists, which may override values inherited from the
@ -293,6 +307,7 @@ func runtimeTemplates(r Repository, runtime Runtime) (templates []Template, err
BuildConfig: runtime.BuildConfig,
HealthEndpoints: runtime.HealthEndpoints,
BuildEnvs: runtime.BuildEnvs,
Invocation: runtime.Invocation,
}
// Template Manifeset

View File

@ -11,9 +11,9 @@ import (
fn "knative.dev/kn-plugin-func"
)
// TestRepositoryTemplatesPath ensures that repositories can specify
// TestRepository_TemplatesPath ensures that repositories can specify
// an alternate location for templates using a manifest.
func TestRepositoryTemplatesPath(t *testing.T) {
func TestRepository_TemplatesPath(t *testing.T) {
client := fn.New(fn.WithRepositories("testdata/repositories"))
// The repo ./testdata/repositories/customLanguagePackRepo includes a
@ -34,11 +34,11 @@ func TestRepositoryTemplatesPath(t *testing.T) {
}
}
// TestRepositoryInheritance ensures that repositories which define a manifest
// TestRepository_Inheritance ensures that repositories which define a manifest
// properly inherit values defined at the repo level, runtime level
// and template level. The tests check for both embedded structures:
// HealthEndpoints BuildConfig.
func TestRepositoryInheritance(t *testing.T) {
func TestRepository_Inheritance(t *testing.T) {
client := fn.New(fn.WithRepositories("testdata/repositories"))
// The repo ./testdata/repositories/customLanguagePack includes a manifest

View File

@ -138,6 +138,10 @@
"Created": {
"type": "string",
"format": "date-time"
},
"invocation": {
"$schema": "http://json-schema.org/draft-04/schema#",
"$ref": "#/definitions/Invocation"
}
},
"additionalProperties": false,
@ -170,6 +174,15 @@
"additionalProperties": false,
"type": "object"
},
"Invocation": {
"properties": {
"format": {
"type": "string"
}
},
"additionalProperties": false,
"type": "object"
},
"Label": {
"required": [
"key"

View File

@ -5,22 +5,31 @@ type Template struct {
// Name (short name) of this template within the repository.
// See .Fullname for the calculated field which is the unique primary id.
Name string `yaml:"-"` // use filesystem for name, not yaml
// Runtime for which this template applies.
Runtime string
// Repository within which this template is contained. Value is set to the
// currently effective name of the repository, which may vary. It is user-
// defined when the repository is added, and can be set to "default" when
// the client is loaded in single repo mode. I.e. not canonical.
Repository string
// BuildConfig defines builders and buildpacks. the denormalized view of
// members which can be defined per repo or per runtime first.
BuildConfig `yaml:",inline"`
// HealthEndpoints. The denormalized view of members which can be defined
// first per repo or per runtime.
HealthEndpoints `yaml:"healthEndpoints,omitempty"`
// BuildEnvs defines environment variables related to the builders,
// this can be used to parameterize the builders
BuildEnvs []Env `yaml:"buildEnvs,omitempty"`
// Invocation defines invocation hints for a Functions which is created
// from this template prior to being materially modified.
Invocation Invocation `yaml:"invocation,omitempty"`
}
// Fullname is a calculated field of [repo]/[name] used

View File

@ -160,6 +160,9 @@ func (t *Templates) Write(f Function) (Function, error) {
if f.HealthEndpoints.Readiness == "" {
f.HealthEndpoints.Readiness = template.HealthEndpoints.Readiness
}
if f.Invocation.Format == "" {
f.Invocation.Format = template.Invocation.Format
}
// Copy the template files from the repo filesystem to the new Function's root
// removing the manifest (if it exists; errors ignored)

View File

@ -0,0 +1,7 @@
# optional. Invocation defines hints for how Functions created using this
# template can be invoked. These settings can be updated on the resultant
# Function as development progresses to ensure 'invoke' can always trigger the
# execution of a running Function instance for testing and development.
invocation:
# Invocations of Functions from this template is via basic HTTP CloudEvent
format: "cloudevent"

View File

@ -0,0 +1,8 @@
# optional. Invocation defines hints for how Functions created using this
# template can be invoked. These settings can be updated on the resultant
# Function as development progresses to ensure 'invoke' can always trigger the
# execution of a running Function instance for testing and development.
invocation:
# The invocation format for this template is a standard HTTP request, which
# by default is an HTTP POST of form fields of the invocation message.
format: "http"

View File

@ -1,2 +1,4 @@
builders:
default: quay.io/boson/faas-python-builder:v0.8.4
invocation:
format: "cloudevent"

View File

@ -2,3 +2,6 @@ builders:
default: quay.io/boson/faas-jvm-builder:v0.8.4
jvm: quay.io/boson/faas-jvm-builder:v0.8.4
native: quay.io/boson/faas-quarkus-native-builder:v0.8.4
invocation:
format: "cloudevent"

View File

@ -6,3 +6,6 @@ builders:
buildpacks:
- paketo-buildpacks/nodejs
- ghcr.io/boson-project/typescript-function-buildpack:tip
invocation:
format: "cloudevent"

View File

@ -17,9 +17,9 @@ import (
. "knative.dev/kn-plugin-func/testing"
)
// TestTemplatesList ensures that all templates are listed taking into account
// TestTemplates_List ensures that all templates are listed taking into account
// both internal and extensible (prefixed) repositories.
func TestTemplatesList(t *testing.T) {
func TestTemplates_List(t *testing.T) {
// A client which specifies a location of exensible repositoreis on disk
// will list all builtin plus exensible
client := fn.New(fn.WithRepositories("testdata/repositories"))
@ -45,13 +45,14 @@ func TestTemplatesList(t *testing.T) {
}
}
// TestTemplatesListExtendedNotFound ensures that an error is not returned
// TestTemplates_List_ExtendedNotFound ensures that an error is not returned
// when retrieving the list of templates for a runtime that does not exist
// in an extended repository, but does in the default.
func TestTemplatesListExtendedNotFound(t *testing.T) {
func TestTemplates_List_ExtendedNotFound(t *testing.T) {
client := fn.New(fn.WithRepositories("testdata/repositories"))
// list templates for the "python" runtime - not supplied by the extended repos
// list templates for the "python" runtime -
// not supplied by the extended repos
templates, err := client.Templates().List("python")
if err != nil {
t.Fatal(err)
@ -67,9 +68,9 @@ func TestTemplatesListExtendedNotFound(t *testing.T) {
}
}
// TestTemplatesGet ensures that a template's metadata object can
// TestTemplates_Get ensures that a template's metadata object can
// be retrieved by full name (full name prefix optional for embedded).
func TestTemplatesGet(t *testing.T) {
func TestTemplates_Get(t *testing.T) {
client := fn.New(fn.WithRepositories("testdata/repositories"))
// Check embedded
@ -95,10 +96,10 @@ func TestTemplatesGet(t *testing.T) {
}
}
// TestTemplateEmbedded ensures that embedded templates are copied on write.
func TestTemplateEmbedded(t *testing.T) {
// TestTemplates_Embedded ensures that embedded templates are copied on write.
func TestTemplates_Embedded(t *testing.T) {
// create test directory
root := "testdata/testTemplateEmbedded"
root := "testdata/testTemplatesEmbedded"
defer Using(t, root)()
// Client whose internal (builtin default) templates will be used.
@ -121,12 +122,12 @@ func TestTemplateEmbedded(t *testing.T) {
}
}
// TestTemplateCustom ensures that a template from a filesystem source
// TestTemplates_Custom ensures that a template from a filesystem source
// (ie. custom provider on disk) can be specified as the source for a
// template.
func TestTemplateCustom(t *testing.T) {
func TestTemplates_Custom(t *testing.T) {
// Create test directory
root := "testdata/testTemplateCustom"
root := "testdata/testTemplatesCustom"
defer Using(t, root)()
// CLient which uses custom repositories
@ -154,11 +155,11 @@ func TestTemplateCustom(t *testing.T) {
}
}
// TestTemplateRemote ensures that a Git template repository provided via URI
// TestTemplates_Remote ensures that a Git template repository provided via URI
// can be specificed on creation of client, with subsequent calls to Create
// using this remote by default.
func TestTemplateRemote(t *testing.T) {
root := "testdata/testTemplateRemote"
func TestTemplates_Remote(t *testing.T) {
root := "testdata/testTemplatesRemote"
defer Using(t, root)()
// The difference between HTTP vs File protocol is internal to the
@ -197,11 +198,11 @@ func TestTemplateRemote(t *testing.T) {
}
}
// TestTemplateDefault ensures that the expected default template
// TestTemplates_Default ensures that the expected default template
// is used when none specified.
func TestTemplateDefault(t *testing.T) {
func TestTemplates_Default(t *testing.T) {
// create test directory
root := "testdata/testTemplateDefault"
root := "testdata/testTemplates_Default"
defer Using(t, root)()
client := fn.New(fn.WithRegistry(TestRegistry))
@ -220,11 +221,11 @@ func TestTemplateDefault(t *testing.T) {
}
}
// TestTemplateInvalidErrors ensures that specifying unrecgognized
// TestTemplates_InvalidErrors ensures that specifying unrecgognized
// runtime/template errors
func TestTemplateInvalidErrors(t *testing.T) {
func TestTemplates_InvalidErrors(t *testing.T) {
// create test directory
root := "testdata/testTemplateInvalidErrors"
root := "testdata/testTemplates_InvalidErrors"
defer Using(t, root)()
client := fn.New(fn.WithRegistry(TestRegistry))
@ -240,6 +241,7 @@ func TestTemplateInvalidErrors(t *testing.T) {
if !errors.Is(err, fn.ErrRuntimeNotFound) {
t.Fatalf("Expected ErrRuntimeNotFound, got %v", err)
}
os.Remove(filepath.Join(root, ".gitignore"))
// Test for error writing an invalid template
err = client.Create(fn.Function{
@ -252,16 +254,16 @@ func TestTemplateInvalidErrors(t *testing.T) {
}
}
// TestTemplateModeEmbedded ensures that templates written from the embedded
// TestTemplates_ModeEmbedded ensures that templates written from the embedded
// templates retain their mode.
func TestTemplateModeEmbedded(t *testing.T) {
func TestTemplates_ModeEmbedded(t *testing.T) {
if runtime.GOOS == "windows" {
return
// not applicable
}
// set up test directory
root := "testdata/testTemplateModeEmbedded"
root := "testdata/testTemplatesModeEmbedded"
defer Using(t, root)()
client := fn.New(fn.WithRegistry(TestRegistry))
@ -287,15 +289,15 @@ func TestTemplateModeEmbedded(t *testing.T) {
}
}
// TestTemplateModeCustom ensures that templates written from custom templates
// TestTemplates_ModeCustom ensures that templates written from custom templates
// retain their mode.
func TestTemplateModeCustom(t *testing.T) {
func TestTemplates_ModeCustom(t *testing.T) {
if runtime.GOOS == "windows" {
return // not applicable
}
// test directories
root := "testdata/testTemplateModeCustom"
root := "testdata/testTemplates_ModeCustom"
defer Using(t, root)()
client := fn.New(
@ -322,15 +324,15 @@ func TestTemplateModeCustom(t *testing.T) {
}
}
// TestTemplateModeRemote ensures that templates written from remote templates
// TestTemplates_ModeRemote ensures that templates written from remote templates
// retain their mode.
func TestTemplateModeRemote(t *testing.T) {
func TestTemplates_ModeRemote(t *testing.T) {
if runtime.GOOS == "windows" {
return // not applicable
}
// test directories
root := "testdata/testTemplateModeRemote"
root := "testdata/testTemplates_ModeRemote"
defer Using(t, root)()
// Clone a repository from a local file path
@ -376,11 +378,11 @@ func TestTemplateModeRemote(t *testing.T) {
// TODO: test typed errors for custom and remote (embedded checked)
// TestRuntimeManifestBuildEnvs ensures that BuildEnvs specified in a
// TestTemplates_RuntimeManifestBuildEnvs ensures that BuildEnvs specified in a
// runtimes's manifest are included in the final Function.
func TestRuntimeManifestBuildEnvs(t *testing.T) {
func TestTemplates_RuntimeManifestBuildEnvs(t *testing.T) {
// create test directory
root := "testdata/testRuntimeManifestBuildEnvs"
root := "testdata/testTemplatesRuntimeManifestBuildEnvs"
defer Using(t, root)()
// Client whose internal templates will be used.
@ -423,11 +425,11 @@ func TestRuntimeManifestBuildEnvs(t *testing.T) {
}
}
// TestTemplateManifestBuildEnvs ensures that BuildEnvs specified in a
// TestTemplates_ManifestBuildEnvs ensures that BuildEnvs specified in a
// template's manifest are included in the final Function.
func TestTemplateManifestBuildEnvs(t *testing.T) {
func TestTemplates_ManifestBuildEnvs(t *testing.T) {
// create test directory
root := "testdata/testTemplateManifestBuildEnvs"
root := "testdata/testTemplatesManifestBuildEnvs"
defer Using(t, root)()
// Client whose internal templates will be used.
@ -470,9 +472,9 @@ func TestTemplateManifestBuildEnvs(t *testing.T) {
}
}
// TestRepositoryManifestBuildEnvs ensures that BuildEnvs specified in a
// TestTemplates_RepositoryManifestBuildEnvs ensures that BuildEnvs specified in a
// repository's manifest are included in the final Function.
func TestRepositoryManifestBuildEnvs(t *testing.T) {
func TestTemplates_RepositoryManifestBuildEnvs(t *testing.T) {
// create test directory
root := "testdata/testRepositoryManifestBuildEnvs"
defer Using(t, root)()
@ -517,9 +519,38 @@ func TestRepositoryManifestBuildEnvs(t *testing.T) {
}
}
// TestTemplateManifestRemoved ensures that the manifest is not left in
// TestTemplates_ManifestInvocationHints ensures that invocation hints
// from a template's manifest are included in the final Function.
func TestTemplates_ManifestInvocationHints(t *testing.T) {
root := "testdata/testTemplatesManifestInvocationHints"
defer Using(t, root)()
client := fn.New(
fn.WithRegistry(TestRegistry),
fn.WithRepositories("testdata/repositories"))
err := client.Create(fn.Function{
Root: root,
Runtime: "manifestedRuntime",
Template: "customLanguagePackRepo/manifestedTemplate",
})
if err != nil {
t.Fatal(err)
}
f, err := fn.NewFunction(root)
if err != nil {
t.Fatal(err)
}
if f.Invocation.Format != "format" {
t.Fatalf("expected invocation format 'format', got '%v'", f.Invocation.Format)
}
}
// TestTemplates_ManifestRemoved ensures that the manifest is not left in
// the resultant Function after write.
func TestTemplateManifestRemoved(t *testing.T) {
func TestTemplates_ManifestRemoved(t *testing.T) {
// create test directory
root := "testdata/testTemplateManifestRemoved"
defer Using(t, root)()
@ -551,3 +582,35 @@ func TestTemplateManifestRemoved(t *testing.T) {
}
}
// TestTemplates_InvocationDefault ensures that creating a Function which
// does not define an invocation hint defaults to the DefaultInvocationFormat
// (http post)
func TestTemplates_InvocationDefault(t *testing.T) {
root := "testdata/testTemplatesInvocationDefault"
defer Using(t, root)()
client := fn.New(
fn.WithRegistry(TestRegistry),
fn.WithRepositories("testdata/repositories"))
// The customTemplateRepo explicitly does not
// include manifests as it exemplifies an entirely default template repo.
err := client.Create(fn.Function{
Root: root,
Runtime: "customRuntime",
Template: "customTemplateRepo/customTemplate",
})
if err != nil {
t.Fatal(err)
}
f, err := fn.NewFunction(root)
if err != nil {
t.Fatal(err)
}
if f.Invocation.Format != fn.DefaultInvocationFormat {
t.Fatalf("expected '%v' invocation format. Got '%v'", fn.DefaultInvocationFormat, f.Invocation.Format)
}
}

View File

@ -1,60 +0,0 @@
//go:build e2e
// +build e2e
package e2e
import (
"os"
"path/filepath"
"strings"
"testing"
)
// TestEmitCommand validates func emit command
// A custom node js Function used to test 'func emit' command (see update_templates/node/events/index.js)
// An event is sent using emit with a special event source 'func:emit', expected by the custom function.
// When this source is matched, the event will get stored globally and will be returned
// as HTTP response next time it receives another event with source "e2e:check"
// A better solution could be evaluated in future.
func TestEmitCommand(t *testing.T) {
project := FunctionTestProject{
FunctionName: "emit-test-node",
ProjectPath: filepath.Join(os.TempDir(), "emit-test-node"),
Runtime: "node",
Template: "cloudevents",
}
knFunc := NewKnFuncShellCli(t)
// Create new project
Create(t, knFunc, project)
defer project.RemoveProjectFolder()
//knFunc.Exec("build", "-r", GetRegistry(), "-p", project.ProjectPath, "-b", "quay.io/boson/faas-nodejs-builder:v0.7.1")
// Update the project folder with the content of update_templates/node/events/// and deploy it
Update(t, knFunc, &project)
defer Delete(t, knFunc, &project)
// Issue Func Emit command
emitMessage := "HELLO FROM EMIT"
result := knFunc.Exec("emit", "--content-type", "text/plain", "--data", emitMessage, "--source", "func:emit", "--path", project.ProjectPath)
if result.Error != nil {
t.Fatal()
}
// Issue another event (in order to capture the event sent by emit)
testEvent := SimpleTestEvent{
Type: "e2e:check",
Source: "e2e:check",
ContentType: "text/plain",
Data: "Emit Check",
}
responseBody, _, err := testEvent.pushTo(project.FunctionURL, t)
if err != nil {
t.Fatal("error occurred while sending event", err.Error())
}
if responseBody == "" || !strings.Contains(responseBody, emitMessage) {
t.Fatalf("fail to validate emit command. Expected [%v], returned [%v]", emitMessage, responseBody)
}
}

View File

@ -0,0 +1,120 @@
//go:build e2e
// +build e2e
package e2e
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"testing"
cloudevents "github.com/cloudevents/sdk-go/v2"
. "knative.dev/kn-plugin-func/testing"
)
// TestInvokeFunction is used when testing the 'func invoke' subcommand.
// It responds with a CloudEvent containing an echo of the data received
// If the CloudEvent received has source "func:set" it will update the
// current value of data.
var TestInvokeFunctionImpl = `
package function
import (
"context"
ce "github.com/cloudevents/sdk-go/v2/event"
)
var _data = []byte{}
var _type = "text/plain"
func Handle(ctx context.Context, event ce.Event) (*ce.Event, error) {
if event.Source() == "func:set" {
_data = event.Data()
_type = event.DataContentType()
}
res := ce.New()
res.SetSource("func:testInvokeHandler")
res.SetData(_type, _data)
return &res, nil
}
`
// TestInvoke ensures that invoking a CloudEvent Function succeeds, including
// preserving custom values through the full round-trip.
func TestInvoke(t *testing.T) {
var (
root = "testdata/e2e/testinvoke" // root path for the test Function
bin, prefix = bin() // path to test binary and prefix args
cleanup = Within(t, root) // Create and CD to root.
cwd, _ = os.Getwd() // the current working directory (absolute)
)
defer cleanup()
run(t, bin, prefix, "create", "--verbose=true", "--language=go", "--template=cloudevents", cwd)
set(t, "handle.go", TestInvokeFunctionImpl)
run(t, bin, prefix, "deploy", "--verbose=true", "--registry", GetRegistry())
run(t, bin, prefix, "invoke", "--verbose=true", "--content-type=text/plain", "--source=func:set", "--data=TEST")
// Validate by fetching the contents of the Function's data global
fmt.Println("Validate:")
req := cloudevents.NewEvent()
req.SetID("1")
req.SetSource("func:get")
req.SetType("func.test")
c, err := cloudevents.NewClientHTTP(cloudevents.WithTarget("http://testinvoke.default.127.0.0.1.sslip.io"))
if err != nil {
return
}
res, err := c.Request(context.Background(), req)
if cloudevents.IsUndelivered(err) {
t.Fatal(err)
}
if string(res.Data()) != "TEST" {
t.Fatalf("expected data 'TEST' got '%v'", string(res.Data()))
}
return
}
// bin returns the path to use for the binary plus any leading args that
// should be prepended.
// For example, this will usually either return `/path/to/func` or `kn func`.
// See NewKnFuncShellCli for original source of this logic.
func bin() (path string, args []string) {
if IsUseKnFunc() {
return "kn", []string{"func"}
}
if path = GetFuncBinaryPath(); path == "" {
fmt.Fprintf(os.Stderr, "'E2E_FUNC_BIN_PATH' or 'E2E_USE_KN_FUNC' can be used to specify test binary path.")
return "func", []string{} //default
}
return path, []string{}
}
// run the given binary with the given two sets of arguments
// This allows for swappable running between the two forms:
// func [subcommand] [flags]
// and
// kn func [subcommand] [flags]
func run(t *testing.T, bin string, prefix []string, suffix ...string) {
t.Helper()
args := append(prefix, suffix...)
fmt.Printf("%v %v\n", bin, strings.Join(args, " "))
cmd := exec.Command(bin, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
t.Fatal(err)
}
}
// set the contents of the given file
func set(t *testing.T, path, data string) {
if err := os.WriteFile(path, []byte(data), os.ModePerm); err != nil {
t.Fatal(err)
}
}

View File

@ -1,25 +1,25 @@
'use strict';
const { CloudEvent, HTTP } = require('cloudevents');
let lastEmitEventData = ""
let lastInvokeEventData = ""
/**
* Function used to test 'func emit' command
* The trick here is sending the event using emit with a given event source 'func:emit'.
* Function used to test 'func invoke' command
* The trick here is sending the event using emit with a given event source 'func:invoke'.
* For this source the consumed event will be stored and returned as http response when it received
* another event with source 'e2e:check'.
*
* 1) function will consume and store the data "hello emit"
* kn func emit -c "text/plain" -d "hello emit" -s "func:emit"
* 1) function will consume and store the data "hello invoke"
* kn func invoke -c "text/plain" -d "hello invoke" -s "func:invoke"
*
* 2) the below should return "hello emit" from previous command
* 2) the below should return "hello invoke" from previous command
* curl $node_func_url -X POST \
* -H "Ce-Id: some-message-id" \
* -H "Ce-Specversion: 1.0" \
* -H "Ce-Type: e2e:check" \
* -H "Ce-Source: e2e:check" \
* -H "Content-Type: text/plain" \
* -d 'Emit Check'
* -d 'Invoke Check'
*
*
* @param context
@ -33,16 +33,16 @@ function handle(context, cloudevent) {
};
}
if (cloudevent.source == "func:emit") {
if (cloudevent.source == "func:invoke") {
context.log.info(`CloudEvent received : ${cloudevent.toString()}`);
lastEmitEventData = cloudevent.data
lastInvokeEventData = cloudevent.data
}
if (cloudevent.source == "e2e:check") {
return HTTP.binary(new CloudEvent({
source: 'test:handle',
type: 'test:emit',
data: lastEmitEventData
type: 'test:invoke',
data: lastInvokeEventData
}));
}

View File

@ -9,3 +9,13 @@ buildpacks:
buildEnvs:
- name: "TEST_TEMPLATE_VARIABLE"
value: "test-template"
# optional. Invocation defines hints for how Functions created using this
# template can be invoked. These settings can be updated on the resultant
# Function as development progresses to ensure 'invoke' can always trigger the
# execution of a running Function instance for testing and development.
invocation:
# The default invocation format is 'http': a basic HTTP POST of form values.
# Formats not understood by the system fall back to this such that there
# is graceful degredation of service when new formats are added.
format: "format"