src: testable commands (#415)

* feat: client progress listener 'stopping' state

* src: testable commands

Restructures commands to accept a fn.Client constructor on command
instantiation.  This allows the concrete implementations, or entire
client to be mocked for testing.
Also some minor refacotring as necessary to shoehorn into the pattern.

* fix: increase default timeout to 120s for service creation

* chore: bump kind, knative and kubectl versions
This commit is contained in:
Luke Kingland 2021-07-10 00:15:23 +09:00 committed by GitHub
parent 346cae0192
commit afcde2d551
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 622 additions and 410 deletions

View File

@ -80,11 +80,11 @@ jobs:
- name: Provision Cluster - name: Provision Cluster
uses: container-tools/kind-action@v1 # use ./hack/allocate.sh locally uses: container-tools/kind-action@v1 # use ./hack/allocate.sh locally
with: with:
version: v0.10.0 version: v0.11.1
kubectl_version: v1.20.0 kubectl_version: v1.21.2
knative_serving: v0.22.0 knative_serving: v0.23.0
knative_kourier: v0.22.0 knative_kourier: v0.23.0
knative_eventing: v0.22.0 knative_eventing: v0.23.0
config: testdata/cluster.yaml config: testdata/cluster.yaml
- name: Configure Cluster - name: Configure Cluster
run: ./hack/configure.sh run: ./hack/configure.sh

View File

@ -112,6 +112,10 @@ type ProgressListener interface {
// Complete signals completion, which is expected to be somewhat different than a step increment. // Complete signals completion, which is expected to be somewhat different than a step increment.
Complete(message string) Complete(message string)
// Stopping indicates the process is in the state of stopping, such as when a context cancelation
// has been received
Stopping()
// Done signals a cessation of progress updates. Should be called in a defer statement to ensure // Done signals a cessation of progress updates. Should be called in a defer statement to ensure
// the progress listener can stop any outstanding tasks such as synchronous user updates. // the progress listener can stop any outstanding tasks such as synchronous user updates.
Done() Done()
@ -278,7 +282,15 @@ func WithEmitter(e Emitter) Option {
// Use Create, Build and Deploy independently for lower level control. // Use Create, Build and Deploy independently for lower level control.
func (c *Client) New(ctx context.Context, cfg Function) (err error) { func (c *Client) New(ctx context.Context, cfg Function) (err error) {
c.progressListener.SetTotal(3) c.progressListener.SetTotal(3)
defer c.progressListener.Done() // Always start a concurrent routine listening for context cancellation.
// On this event, immediately indicate the task is canceling.
// (this is useful, for example, when a progress listener is mutating
// stdout, and a context cancelation needs to free up stdout entirely for
// the status or error from said cancelltion.
go func() {
<-ctx.Done()
c.progressListener.Stopping()
}()
// Create local template // Create local template
err = c.Create(cfg) err = c.Create(cfg)
@ -404,6 +416,10 @@ func (c *Client) Create(cfg Function) (err error) {
// not contain a populated Image. // not contain a populated Image.
func (c *Client) Build(ctx context.Context, path string) (err error) { func (c *Client) Build(ctx context.Context, path string) (err error) {
c.progressListener.Increment("Building function image") c.progressListener.Increment("Building function image")
go func() {
<-ctx.Done()
c.progressListener.Stopping()
}()
f, err := NewFunction(path) f, err := NewFunction(path)
if err != nil { if err != nil {
@ -436,6 +452,12 @@ func (c *Client) Build(ctx context.Context, path string) (err error) {
// Deploy the Function at path. Errors if the Function has not been // Deploy the Function at path. Errors if the Function has not been
// initialized with an image tag. // initialized with an image tag.
func (c *Client) Deploy(ctx context.Context, path string) (err error) { func (c *Client) Deploy(ctx context.Context, path string) (err error) {
c.progressListener.Increment("Deployin function")
go func() {
<-ctx.Done()
c.progressListener.Stopping()
}()
f, err := NewFunction(path) f, err := NewFunction(path)
if err != nil { if err != nil {
return return
@ -489,6 +511,10 @@ func (c *Client) Route(path string) (err error) {
// Run the Function whose code resides at root. // Run the Function whose code resides at root.
func (c *Client) Run(ctx context.Context, root string) error { func (c *Client) Run(ctx context.Context, root string) error {
go func() {
<-ctx.Done()
c.progressListener.Stopping()
}()
// Create an instance of a Function representation at the given root. // Create an instance of a Function representation at the given root.
f, err := NewFunction(root) f, err := NewFunction(root)
@ -514,6 +540,10 @@ func (c *Client) List(ctx context.Context) ([]ListItem, error) {
// Describe a Function. Name takes precidence. If no name is provided, // Describe a Function. Name takes precidence. If no name is provided,
// the Function defined at root is used. // the Function defined at root is used.
func (c *Client) Describe(ctx context.Context, name, root string) (d Description, err error) { func (c *Client) Describe(ctx context.Context, name, root string) (d Description, err error) {
go func() {
<-ctx.Done()
c.progressListener.Stopping()
}()
// If name is provided, it takes precidence. // If name is provided, it takes precidence.
// Otherwise load the Function defined at root. // Otherwise load the Function defined at root.
if name != "" { if name != "" {
@ -533,6 +563,10 @@ func (c *Client) Describe(ctx context.Context, name, root string) (d Description
// Remove a Function. Name takes precidence. If no name is provided, // Remove a Function. Name takes precidence. If no name is provided,
// the Function defined at root is used if it exists. // the Function defined at root is used if it exists.
func (c *Client) Remove(ctx context.Context, cfg Function) error { func (c *Client) Remove(ctx context.Context, cfg Function) error {
go func() {
<-ctx.Done()
c.progressListener.Stopping()
}()
// If name is provided, it takes precidence. // If name is provided, it takes precidence.
// Otherwise load the Function deined at root. // Otherwise load the Function deined at root.
if cfg.Name != "" { if cfg.Name != "" {
@ -551,15 +585,23 @@ func (c *Client) Remove(ctx context.Context, cfg Function) error {
// Emit a CloudEvent to a function endpoint // Emit a CloudEvent to a function endpoint
func (c *Client) Emit(ctx context.Context, endpoint string) error { func (c *Client) Emit(ctx context.Context, endpoint string) error {
go func() {
<-ctx.Done()
c.progressListener.Stopping()
}()
return c.emitter.Emit(ctx, endpoint) return c.emitter.Emit(ctx, endpoint)
} }
// Manual implementations (noops) of required interfaces. // Manual implementations (noops) of required interfaces.
// In practice, the user of this client package (for example the CLI) will // In practice, the user of this client package (for example the CLI) will
// provide a concrete implementation for all of the interfaces. For testing or // provide a concrete implementation for only the interfaces necessary to
// development, however, it is usefule that they are defaulted to noops and // complete the given command. Integrators importing the package would
// provded only when necessary. Unit tests for the concrete implementations // provide a concrete implementation for all interfaces to be used. To
// serve to keep the core logic here separate from the imperitive. // enable partial definition (in particular used for testing) they
// are defaulted to noop implementations such that they can be provded
// only when necessary. Unit tests for the concrete implementations
// serve to keep the core logic here separate from the imperitive, and
// with a minimum of external dependencies.
// ----------------------------------------------------- // -----------------------------------------------------
type noopBuilder struct{ output io.Writer } type noopBuilder struct{ output io.Writer }
@ -597,6 +639,7 @@ type noopProgressListener struct{}
func (p *noopProgressListener) SetTotal(i int) {} func (p *noopProgressListener) SetTotal(i int) {}
func (p *noopProgressListener) Increment(m string) {} func (p *noopProgressListener) Increment(m string) {}
func (p *noopProgressListener) Complete(m string) {} func (p *noopProgressListener) Complete(m string) {}
func (p *noopProgressListener) Stopping() {}
func (p *noopProgressListener) Done() {} func (p *noopProgressListener) Done() {}
type noopEmitter struct{} type noopEmitter struct{}

View File

@ -14,23 +14,32 @@ import (
) )
func init() { func init() {
root.AddCommand(buildCmd) // Add to the root a new "Build" command which obtains an appropriate
buildCmd.Flags().StringP("builder", "b", "", "Buildpack builder, either an as a an image name or a mapping name.\nSpecified value is stored in func.yaml for subsequent builds.") // instance of fn.Client from the given client creator function.
buildCmd.Flags().BoolP("confirm", "c", false, "Prompt to confirm all configuration options (Env: $FUNC_CONFIRM)") root.AddCommand(NewBuildCmd(newBuildClient))
buildCmd.Flags().StringP("image", "i", "", "Full image name in the orm [registry]/[namespace]/[name]:[tag] (optional). This option takes precedence over --registry (Env: $FUNC_IMAGE")
buildCmd.Flags().StringP("path", "p", cwd(), "Path to the project directory (Env: $FUNC_PATH)")
buildCmd.Flags().StringP("registry", "r", "", "Registry + namespace part of the image to build, ex 'quay.io/myuser'. The full image name is automatically determined based on the local directory name. If not provided the registry will be taken from func.yaml (Env: $FUNC_REGISTRY)")
err := buildCmd.RegisterFlagCompletionFunc("builder", CompleteBuilderList)
if err != nil {
fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err)
}
} }
var buildCmd = &cobra.Command{ func newBuildClient(cfg buildConfig) (*fn.Client, error) {
Use: "build", builder := buildpacks.NewBuilder()
Short: "Build a function project as a container image", listener := progress.New()
Long: `Build a function project as a container image
builder.Verbose = cfg.Verbose
listener.Verbose = cfg.Verbose
return fn.New(
fn.WithBuilder(builder),
fn.WithProgressListener(listener),
fn.WithRegistry(cfg.Registry), // for image name when --image not provided
fn.WithVerbose(cfg.Verbose)), nil
}
type buildClientFn func(buildConfig) (*fn.Client, error)
func NewBuildCmd(clientFn buildClientFn) *cobra.Command {
cmd := &cobra.Command{
Use: "build",
Short: "Build a function project as a container image",
Long: `Build a function project as a container image
This command builds the function project in the current directory or in the directory This command builds the function project in the current directory or in the directory
specified by --path. The result will be a container image that is pushed to a registry. specified by --path. The result will be a container image that is pushed to a registry.
@ -38,7 +47,7 @@ The func.yaml file is read to determine the image name and registry.
If the project has not already been built, either --registry or --image must be provided If the project has not already been built, either --registry or --image must be provided
and the image name is stored in the configuration file. and the image name is stored in the configuration file.
`, `,
Example: ` Example: `
# Build from the local directory, using the given registry as target. # Build from the local directory, using the given registry as target.
# The full image name will be determined automatically based on the # The full image name will be determined automatically based on the
# project directory name # project directory name
@ -53,12 +62,28 @@ kn func build
# Build with a custom buildpack builder # Build with a custom buildpack builder
kn func build --builder cnbs/sample-builder:bionic kn func build --builder cnbs/sample-builder:bionic
`, `,
SuggestFor: []string{"biuld", "buidl", "built"}, SuggestFor: []string{"biuld", "buidl", "built"},
PreRunE: bindEnv("image", "path", "builder", "registry", "confirm"), PreRunE: bindEnv("image", "path", "builder", "registry", "confirm"),
RunE: runBuild, }
cmd.Flags().StringP("builder", "b", "", "Buildpack builder, either an as a an image name or a mapping name.\nSpecified value is stored in func.yaml for subsequent builds.")
cmd.Flags().BoolP("confirm", "c", false, "Prompt to confirm all configuration options (Env: $FUNC_CONFIRM)")
cmd.Flags().StringP("image", "i", "", "Full image name in the orm [registry]/[namespace]/[name]:[tag] (optional). This option takes precedence over --registry (Env: $FUNC_IMAGE")
cmd.Flags().StringP("path", "p", cwd(), "Path to the project directory (Env: $FUNC_PATH)")
cmd.Flags().StringP("registry", "r", "", "Registry + namespace part of the image to build, ex 'quay.io/myuser'. The full image name is automatically determined based on the local directory name. If not provided the registry will be taken from func.yaml (Env: $FUNC_REGISTRY)")
if err := cmd.RegisterFlagCompletionFunc("builder", CompleteBuilderList); err != nil {
fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err)
}
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runBuild(cmd, args, clientFn)
}
return cmd
} }
func runBuild(cmd *cobra.Command, _ []string) (err error) { func runBuild(cmd *cobra.Command, _ []string, clientFn buildClientFn) (err error) {
config, err := newBuildConfig().Prompt() config, err := newBuildConfig().Prompt()
if err != nil { if err != nil {
if err == terminal.InterruptErr { if err == terminal.InterruptErr {
@ -106,26 +131,12 @@ func runBuild(cmd *cobra.Command, _ []string) (err error) {
return return
} }
builder := buildpacks.NewBuilder() client, err := clientFn(config)
builder.Verbose = config.Verbose if err != nil {
return err
}
listener := progress.New() return client.Build(cmd.Context(), config.Path)
listener.Verbose = config.Verbose
defer listener.Done()
context := cmd.Context()
go func() {
<-context.Done()
listener.Done()
}()
client := fn.New(
fn.WithVerbose(config.Verbose),
fn.WithRegistry(config.Registry), // for deriving image name when --image not provided explicitly.
fn.WithBuilder(builder),
fn.WithProgressListener(listener))
return client.Build(context, config.Path)
} }
type buildConfig struct { type buildConfig struct {

View File

@ -24,13 +24,15 @@ func init() {
// The createClientFn is a client factory which creates a new Client for use by // The createClientFn is a client factory which creates a new Client for use by
// the create command during normal execution (see tests for alternative client // the create command during normal execution (see tests for alternative client
// factories which return clients with various mocks). // factories which return clients with various mocks).
func newCreateClient(repositories string, verbose bool) *fn.Client { func newCreateClient(cfg createConfig) *fn.Client {
return fn.New(fn.WithRepositories(repositories), fn.WithVerbose(verbose)) return fn.New(
fn.WithRepositories(cfg.Repositories),
fn.WithVerbose(cfg.Verbose))
} }
// createClientFn is a factory function which returns a Client suitable for // createClientFn is a factory function which returns a Client suitable for
// use with the Create command. // use with the Create command.
type createClientFn func(repositories string, verbose bool) *fn.Client type createClientFn func(createConfig) *fn.Client
// NewCreateCmd creates a create command using the given client creator. // NewCreateCmd creates a create command using the given client creator.
func NewCreateCmd(clientFn createClientFn) *cobra.Command { func NewCreateCmd(clientFn createClientFn) *cobra.Command {
@ -59,14 +61,10 @@ kn func create --template events myfunc
PreRunE: bindEnv("runtime", "template", "repositories", "confirm"), PreRunE: bindEnv("runtime", "template", "repositories", "confirm"),
} }
cmd.Flags().BoolP("confirm", "c", false, cmd.Flags().BoolP("confirm", "c", false, "Prompt to confirm all configuration options (Env: $FUNC_CONFIRM)")
"Prompt to confirm all configuration options (Env: $FUNC_CONFIRM)") cmd.Flags().StringP("runtime", "l", fn.DefaultRuntime, "Function runtime language/framework. Available runtimes: "+buildpacks.Runtimes()+" (Env: $FUNC_RUNTIME)")
cmd.Flags().StringP("runtime", "l", fn.DefaultRuntime, cmd.Flags().StringP("repositories", "r", filepath.Join(configPath(), "repositories"), "Path to extended template repositories (Env: $FUNC_REPOSITORIES)")
"Function runtime language/framework. Available runtimes: "+buildpacks.Runtimes()+" (Env: $FUNC_RUNTIME)") cmd.Flags().StringP("template", "t", fn.DefaultTemplate, "Function template. Available templates: 'http' and 'events' (Env: $FUNC_TEMPLATE)")
cmd.Flags().StringP("repositories", "r", filepath.Join(configPath(), "repositories"),
"Path to extended template repositories (Env: $FUNC_REPOSITORIES)")
cmd.Flags().StringP("template", "t", fn.DefaultTemplate,
"Function template. Available templates: 'http' and 'events' (Env: $FUNC_TEMPLATE)")
// Register tab-completeion function integration // Register tab-completeion function integration
if err := cmd.RegisterFlagCompletionFunc("runtime", CompleteRuntimeList); err != nil { if err := cmd.RegisterFlagCompletionFunc("runtime", CompleteRuntimeList); err != nil {
@ -103,7 +101,7 @@ func runCreate(cmd *cobra.Command, args []string, clientFn createClientFn) (err
Template: config.Template, Template: config.Template,
} }
client := clientFn(config.Repositories, config.Verbose) client := clientFn(config)
return client.Create(function) return client.Create(function)
} }

View File

@ -17,7 +17,7 @@ func TestCreateValidatesName(t *testing.T) {
// Create a new Create command with a fn.Client construtor // Create a new Create command with a fn.Client construtor
// which returns a default (noop) client suitable for tests. // which returns a default (noop) client suitable for tests.
cmd := NewCreateCmd(func(string, bool) *fn.Client { cmd := NewCreateCmd(func(createConfig) *fn.Client {
return fn.New() return fn.New()
}) })

View File

@ -13,11 +13,33 @@ import (
) )
func init() { func init() {
root.AddCommand(deleteCmd) // Create a new delete command with a reference to
// a function which yields an appropriate concrete client instance.
root.AddCommand(NewDeleteCmd(newDeleteClient))
} }
func NewDeleteCmd(newRemover func(ns string, verbose bool) (fn.Remover, error)) *cobra.Command { // newDeleteClient returns an instance of a Client using the
delCmd := &cobra.Command{ // final config state.
// Testing note: This method is swapped out during testing to allow
// mocking the remover or the client itself to fabricate test states.
func newDeleteClient(cfg deleteConfig) (*fn.Client, error) {
remover, err := knative.NewRemover(cfg.Namespace)
if err != nil {
return nil, err
}
remover.Verbose = cfg.Verbose
return fn.New(
fn.WithRemover(remover),
fn.WithVerbose(cfg.Verbose)), nil
}
// A deleteClientFn is a function which yields a Client instance from a config
type deleteClientFn func(deleteConfig) (*fn.Client, error)
func NewDeleteCmd(clientFn deleteClientFn) *cobra.Command {
cmd := &cobra.Command{
Use: "delete [NAME]", Use: "delete [NAME]",
Short: "Undeploy a function", Short: "Undeploy a function",
Long: `Undeploy a function Long: `Undeploy a function
@ -38,71 +60,65 @@ kn func delete -n apps myfunc
SuggestFor: []string{"remove", "rm", "del"}, SuggestFor: []string{"remove", "rm", "del"},
ValidArgsFunction: CompleteFunctionList, ValidArgsFunction: CompleteFunctionList,
PreRunE: bindEnv("path", "confirm", "namespace"), PreRunE: bindEnv("path", "confirm", "namespace"),
RunE: func(cmd *cobra.Command, args []string) (err error) {
config, err := newDeleteConfig(args).Prompt()
if err != nil {
if err == terminal.InterruptErr {
return nil
}
return
}
var function fn.Function
// Initialize func with explicit name (when provided)
if len(args) > 0 && args[0] != "" {
pathChanged := cmd.Flags().Changed("path")
if pathChanged {
return fmt.Errorf("Only one of --path and [NAME] should be provided")
}
function = fn.Function{
Name: args[0],
}
} else {
function, err = fn.NewFunction(config.Path)
if err != nil {
return
}
// Check if the Function has been initialized
if !function.Initialized() {
return fmt.Errorf("the given path '%v' does not contain an initialized function", config.Path)
}
}
ns := config.Namespace
if ns == "" {
ns = function.Namespace
}
remover, err := newRemover(ns, config.Verbose)
if err != nil {
return
}
client := fn.New(
fn.WithVerbose(config.Verbose),
fn.WithRemover(remover))
return client.Remove(cmd.Context(), function)
},
} }
delCmd.Flags().BoolP("confirm", "c", false, "Prompt to confirm all configuration options (Env: $FUNC_CONFIRM)") cmd.Flags().BoolP("confirm", "c", false, "Prompt to confirm all configuration options (Env: $FUNC_CONFIRM)")
delCmd.Flags().StringP("path", "p", cwd(), "Path to the function project that should be undeployed (Env: $FUNC_PATH)") cmd.Flags().StringP("path", "p", cwd(), "Path to the function project that should be undeployed (Env: $FUNC_PATH)")
delCmd.Flags().StringP("namespace", "n", "", "Namespace of the function to undeploy. By default, the namespace in func.yaml is used or the actual active namespace if not set in the configuration. (Env: $FUNC_NAMESPACE)") cmd.Flags().StringP("namespace", "n", "", "Namespace of the function to undeploy. By default, the namespace in func.yaml is used or the actual active namespace if not set in the configuration. (Env: $FUNC_NAMESPACE)")
return delCmd cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runDelete(cmd, args, clientFn)
}
return cmd
} }
var deleteCmd = NewDeleteCmd(func(ns string, verbose bool) (fn.Remover, error) { func runDelete(cmd *cobra.Command, args []string, clientFn deleteClientFn) (err error) {
r, err := knative.NewRemover(ns) config, err := newDeleteConfig(args).Prompt()
if err != nil { if err != nil {
return nil, err if err == terminal.InterruptErr {
return nil
}
return
} }
r.Verbose = verbose
return r, nil var function fn.Function
})
// Initialize func with explicit name (when provided)
if len(args) > 0 && args[0] != "" {
pathChanged := cmd.Flags().Changed("path")
if pathChanged {
return fmt.Errorf("Only one of --path and [NAME] should be provided")
}
function = fn.Function{
Name: args[0],
}
} else {
function, err = fn.NewFunction(config.Path)
if err != nil {
return
}
// Check if the Function has been initialized
if !function.Initialized() {
return fmt.Errorf("the given path '%v' does not contain an initialized function", config.Path)
}
}
// If not provided, use the function's extant namespace
if config.Namespace == "" {
config.Namespace = function.Namespace
}
// Create a client instance from the now-final config
client, err := clientFn(config)
if err != nil {
return err
}
// Invoke remove using the concrete client impl
return client.Remove(cmd.Context(), function)
}
type deleteConfig struct { type deleteConfig struct {
Name string Name string

View File

@ -1,48 +1,57 @@
package cmd package cmd
import ( import (
"context"
"io/ioutil" "io/ioutil"
"os"
"path/filepath"
"testing" "testing"
fn "github.com/boson-project/func" fn "github.com/boson-project/func"
"github.com/boson-project/func/mock"
) )
type testRemover struct { // TestDeleteByName ensures that running delete specifying the name of the Funciton
invokedWith *string // explicitly as an argument invokes the remover appropriately.
} func TestDeleteByName(t *testing.T) {
var (
testname = "testname" // explict name with which to create the Funciton
args = []string{testname} // passed as the lone argument
remover = mock.NewRemover() // with a mock remover
)
func (t *testRemover) Remove(ctx context.Context, name string) error { // Remover fails the test if it receives the incorrect name
t.invokedWith = &name // an incorrect name.
return nil remover.RemoveFn = func(n string) error {
} if n != testname {
t.Fatalf("expected delete name %v, got %v", testname, n)
}
return nil
}
// test delete outside project just using function name // Create a command with a client constructor fn that instantiates a client
func TestDeleteCmdWithoutProject(t *testing.T) { // with a the mocked remover.
tr := &testRemover{} cmd := NewDeleteCmd(func(_ deleteConfig) (*fn.Client, error) {
cmd := NewDeleteCmd(func(ns string, verbose bool) (fn.Remover, error) { return fn.New(fn.WithRemover(remover)), nil
return tr, nil
}) })
cmd.SetArgs([]string{"foo"}) // Execute the command
cmd.SetArgs(args)
err := cmd.Execute() err := cmd.Execute()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if tr.invokedWith == nil { // Fail if remover's .Remove not invoked at all
t.Fatal("fn.Remover has not been invoked") if !remover.RemoveInvoked {
} t.Fatal("fn.Remover not invoked")
if *tr.invokedWith != "foo" {
t.Fatalf("expected fn.Remover to be called with 'foo', but was called with '%s'", *tr.invokedWith)
} }
} }
// test delete from inside project directory (reading func name from func.yaml) // TestDeleteByProject ensures that running delete with a valid project as its
func TestDeleteCmdWithProject(t *testing.T) { // context invokes remove and with the correct name (reads name from func.yaml)
func TestDeleteByProject(t *testing.T) {
// from within a new temporary directory
defer fromTempDir(t)()
// Write a func.yaml config which specifies a name
funcYaml := `name: bar funcYaml := `name: bar
namespace: "" namespace: ""
runtime: go runtime: go
@ -54,73 +63,65 @@ builderMap:
envs: [] envs: []
annotations: {} annotations: {}
` `
tmpDir, err := ioutil.TempDir("", "bar") if err := ioutil.WriteFile("func.yaml", []byte(funcYaml), 0600); err != nil {
if err != nil {
t.Fatal(err) t.Fatal(err)
} }
defer os.RemoveAll(tmpDir)
f, err := os.Create(filepath.Join(tmpDir, "func.yaml")) // A mock remover which fails if the name from the func.yaml is not received.
if err != nil { remover := mock.NewRemover()
t.Fatal(err) remover.RemoveFn = func(n string) error {
} if n != "bar" {
defer f.Close() t.Fatalf("expected name 'bar', got '%v'", n)
_, err = f.WriteString(funcYaml)
if err != nil {
t.Fatal(err)
}
f.Close()
oldWD, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
defer func() {
err = os.Chdir(oldWD)
if err != nil {
t.Fatal(err)
} }
}() return nil
err = os.Chdir(tmpDir)
if err != nil {
t.Fatal(err)
} }
tr := &testRemover{} // Command with a Client constructor that returns client with the
cmd := NewDeleteCmd(func(ns string, verbose bool) (fn.Remover, error) { // mocked remover.
return tr, nil cmd := NewDeleteCmd(func(_ deleteConfig) (*fn.Client, error) {
return fn.New(fn.WithRemover(remover)), nil
}) })
cmd.SetArgs([]string{"-p", "."}) // Execute the command simulating no arguments.
err = cmd.Execute() cmd.SetArgs([]string{})
err := cmd.Execute()
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if tr.invokedWith == nil { // Also fail if remover's .Remove is not invoked
t.Fatal("fn.Remover has not been invoked") if !remover.RemoveInvoked {
} t.Fatal("fn.Remover not invoked")
if *tr.invokedWith != "bar" {
t.Fatalf("expected fn.Remover to be called with 'bar', but was called with '%s'", *tr.invokedWith)
} }
} }
// test where both name and path are provided // TestDeleteNameAndPathExclusivity ensures that providing both a name and a
func TestDeleteCmdWithBothPathAndName(t *testing.T) { // path generates an error.
tr := &testRemover{} // Providing the --path (-p) flag indicates the name of the funciton to delete
cmd := NewDeleteCmd(func(ns string, verbose bool) (fn.Remover, error) { // is to be taken from the Function at the given path. Providing the name as
return tr, nil // an argument as well is therefore redundant and an error.
func TestDeleteNameAndPathExclusivity(t *testing.T) {
// A mock remover which will be sampled to ensure it is not invoked.
remover := mock.NewRemover()
// Command with a Client constructor using the mock remover.
cmd := NewDeleteCmd(func(_ deleteConfig) (*fn.Client, error) {
return fn.New(fn.WithRemover(remover)), nil
}) })
cmd.SetArgs([]string{"foo", "-p", "/adir/"}) // Execute the command simulating the invalid argument combination of both
// a path and an explicit name.
cmd.SetArgs([]string{"-p", "./testpath", "testname"})
err := cmd.Execute() err := cmd.Execute()
if err == nil { if err == nil {
t.Fatal("error was expected as both name an path cannot be used together") // TODO should really either parse the output or use typed errors to ensure it's
// failing for the expected reason.
t.Fatal(err)
} }
if tr.invokedWith != nil { // Also fail if remover's .Remove is invoked.
t.Fatal("fn.Remove was call when it shouldn't have been") if remover.RemoveInvoked {
t.Fatal("fn.Remover invoked despite invalid combination and an error")
} }
} }

View File

@ -21,22 +21,46 @@ import (
) )
func init() { func init() {
root.AddCommand(deployCmd) root.AddCommand(NewDeployCmd(newDeployClient))
deployCmd.Flags().BoolP("confirm", "c", false, "Prompt to confirm all configuration options (Env: $FUNC_CONFIRM)")
deployCmd.Flags().StringArrayP("env", "e", []string{}, "Environment variable to set in the form NAME=VALUE. "+
"You may provide this flag multiple times for setting multiple environment variables. "+
"To unset, specify the environment variable name followed by a \"-\" (e.g., NAME-).")
deployCmd.Flags().StringP("image", "i", "", "Full image name in the form [registry]/[namespace]/[name]:[tag] (optional). This option takes precedence over --registry (Env: $FUNC_IMAGE")
deployCmd.Flags().StringP("namespace", "n", "", "Namespace of the function to undeploy. By default, the namespace in func.yaml is used or the actual active namespace if not set in the configuration. (Env: $FUNC_NAMESPACE)")
deployCmd.Flags().StringP("path", "p", cwd(), "Path to the project directory (Env: $FUNC_PATH)")
deployCmd.Flags().StringP("registry", "r", "", "Registry + namespace part of the image to build, ex 'quay.io/myuser'. The full image name is automatically determined based on the local directory name. If not provided the registry will be taken from func.yaml (Env: $FUNC_REGISTRY)")
deployCmd.Flags().BoolP("build", "b", true, "Build the image before deploying (Env: $FUNC_BUILD)")
} }
var deployCmd = &cobra.Command{ func newDeployClient(cfg deployConfig) (*fn.Client, error) {
Use: "deploy", listener := progress.New()
Short: "Deploy a function",
Long: `Deploy a function builder := buildpacks.NewBuilder()
pusher, err := docker.NewPusher(docker.WithCredentialsProvider(credentialsProvider))
if err != nil {
return nil, err
}
deployer, err := knative.NewDeployer(cfg.Namespace)
if err != nil {
return nil, err
}
listener.Verbose = cfg.Verbose
builder.Verbose = cfg.Verbose
pusher.Verbose = cfg.Verbose
deployer.Verbose = cfg.Verbose
return fn.New(
fn.WithProgressListener(listener),
fn.WithBuilder(builder),
fn.WithPusher(pusher),
fn.WithDeployer(deployer),
fn.WithRegistry(cfg.Registry), // for deriving name when no --image value
fn.WithVerbose(cfg.Verbose),
), nil
}
type deployClientFn func(deployConfig) (*fn.Client, error)
func NewDeployCmd(clientFn deployClientFn) *cobra.Command {
cmd := &cobra.Command{
Use: "deploy",
Short: "Deploy a function",
Long: `Deploy a function
Builds a container image for the function and deploys it to the connected Knative enabled cluster. Builds a container image for the function and deploys it to the connected Knative enabled cluster.
The function is picked up from the project in the current directory or from the path provided The function is picked up from the project in the current directory or from the path provided
@ -47,7 +71,7 @@ in the configuration file.
If the function is already deployed, it is updated with a new container image If the function is already deployed, it is updated with a new container image
that is pushed to an image registry, and finally the function's Knative service is updated. that is pushed to an image registry, and finally the function's Knative service is updated.
`, `,
Example: ` Example: `
# Build and deploy the function from the current directory's project. The image will be # Build and deploy the function from the current directory's project. The image will be
# pushed to "quay.io/myuser/<function name>" and deployed as Knative service with the # pushed to "quay.io/myuser/<function name>" and deployed as Knative service with the
# same name as the function to the currently connected cluster. # same name as the function to the currently connected cluster.
@ -57,17 +81,33 @@ kn func deploy --registry quay.io/myuser
# the namespace "myns" # the namespace "myns"
kn func deploy --image quay.io/myuser/myfunc -n myns kn func deploy --image quay.io/myuser/myfunc -n myns
`, `,
SuggestFor: []string{"delpoy", "deplyo"}, SuggestFor: []string{"delpoy", "deplyo"},
PreRunE: bindEnv("image", "namespace", "path", "registry", "confirm", "build"), PreRunE: bindEnv("image", "namespace", "path", "registry", "confirm", "build"),
RunE: runDeploy, }
cmd.Flags().BoolP("confirm", "c", false, "Prompt to confirm all configuration options (Env: $FUNC_CONFIRM)")
cmd.Flags().StringArrayP("env", "e", []string{}, "Environment variable to set in the form NAME=VALUE. "+
"You may provide this flag multiple times for setting multiple environment variables. "+
"To unset, specify the environment variable name followed by a \"-\" (e.g., NAME-).")
cmd.Flags().StringP("image", "i", "", "Full image name in the form [registry]/[namespace]/[name]:[tag] (optional). This option takes precedence over --registry (Env: $FUNC_IMAGE")
cmd.Flags().StringP("namespace", "n", "", "Namespace of the function to undeploy. By default, the namespace in func.yaml is used or the actual active namespace if not set in the configuration. (Env: $FUNC_NAMESPACE)")
cmd.Flags().StringP("path", "p", cwd(), "Path to the project directory (Env: $FUNC_PATH)")
cmd.Flags().StringP("registry", "r", "", "Registry + namespace part of the image to build, ex 'quay.io/myuser'. The full image name is automatically determined based on the local directory name. If not provided the registry will be taken from func.yaml (Env: $FUNC_REGISTRY)")
cmd.Flags().BoolP("build", "b", true, "Build the image before deploying (Env: $FUNC_BUILD)")
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runDeploy(cmd, args, clientFn)
}
return cmd
} }
func runDeploy(cmd *cobra.Command, _ []string) (err error) { func runDeploy(cmd *cobra.Command, _ []string, clientFn deployClientFn) (err error) {
config, err := newDeployConfig(cmd) config, err := newDeployConfig(cmd)
if err != nil { if err != nil {
return err return err
} }
config, err = config.Prompt() config, err = config.Prompt()
if err != nil { if err != nil {
if err == terminal.InterruptErr { if err == terminal.InterruptErr {
@ -120,55 +160,26 @@ func runDeploy(cmd *cobra.Command, _ []string) (err error) {
return return
} }
builder := buildpacks.NewBuilder() // Deafult conig namespace is the function's namespace
builder.Verbose = config.Verbose if config.Namespace == "" {
config.Namespace = function.Namespace
}
pusher, err := docker.NewPusher(docker.WithCredentialsProvider(credentialsProvider)) client, err := clientFn(config)
if err != nil { if err != nil {
if err == terminal.InterruptErr { if err == terminal.InterruptErr {
return nil return nil
} }
return err return err
} }
pusher.Verbose = config.Verbose
ns := config.Namespace
if ns == "" {
ns = function.Namespace
}
deployer, err := knative.NewDeployer(ns)
if err != nil {
return
}
listener := progress.New()
defer listener.Done()
deployer.Verbose = config.Verbose
listener.Verbose = config.Verbose
context := cmd.Context()
go func() {
<-context.Done()
listener.Done()
}()
client := fn.New(
fn.WithVerbose(config.Verbose),
fn.WithRegistry(config.Registry), // for deriving image name when --image not provided explicitly.
fn.WithBuilder(builder),
fn.WithPusher(pusher),
fn.WithDeployer(deployer),
fn.WithProgressListener(listener))
if config.Build { if config.Build {
if err := client.Build(context, config.Path); err != nil { if err := client.Build(cmd.Context(), config.Path); err != nil {
return err return err
} }
} }
return client.Deploy(context, config.Path) return client.Deploy(cmd.Context(), config.Path)
// NOTE: Namespace is optional, default is that used by k8s client // NOTE: Namespace is optional, default is that used by k8s client
// (for example kubectl usually uses ~/.kube/config) // (for example kubectl usually uses ~/.kube/config)

View File

@ -16,39 +16,62 @@ import (
) )
func init() { func init() {
root.AddCommand(describeCmd) root.AddCommand(NewDescribeCmd(newDescribeClient))
describeCmd.Flags().StringP("namespace", "n", "", "Namespace of the function. By default, the namespace in func.yaml is used or the actual active namespace if not set in the configuration. (Env: $FUNC_NAMESPACE)")
describeCmd.Flags().StringP("output", "o", "human", "Output format (human|plain|json|xml|yaml|url) (Env: $FUNC_OUTPUT)")
describeCmd.Flags().StringP("path", "p", cwd(), "Path to the project directory (Env: $FUNC_PATH)")
err := describeCmd.RegisterFlagCompletionFunc("output", CompleteOutputFormatList)
if err != nil {
fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err)
}
} }
var describeCmd = &cobra.Command{ func newDescribeClient(cfg describeConfig) (*fn.Client, error) {
Use: "describe <name>", describer, err := knative.NewDescriber(cfg.Namespace)
Short: "Show details of a function", if err != nil {
Long: `Show details of a function return nil, err
}
describer.Verbose = cfg.Verbose
return fn.New(
fn.WithDescriber(describer),
fn.WithVerbose(cfg.Verbose),
), nil
}
type describeClientFn func(describeConfig) (*fn.Client, error)
func NewDescribeCmd(clientFn describeClientFn) *cobra.Command {
cmd := &cobra.Command{
Use: "describe <name>",
Short: "Show details of a function",
Long: `Show details of a function
Prints the name, route and any event subscriptions for a deployed function in Prints the name, route and any event subscriptions for a deployed function in
the current directory or from the directory specified with --path. the current directory or from the directory specified with --path.
`, `,
Example: ` Example: `
# Show the details of a function as declared in the local func.yaml # Show the details of a function as declared in the local func.yaml
kn func describe kn func describe
# Show the details of the function in the myotherfunc directory with yaml output # Show the details of the function in the myotherfunc directory with yaml output
kn func describe --output yaml --path myotherfunc kn func describe --output yaml --path myotherfunc
`, `,
SuggestFor: []string{"desc", "get"}, SuggestFor: []string{"desc", "get"},
ValidArgsFunction: CompleteFunctionList, ValidArgsFunction: CompleteFunctionList,
PreRunE: bindEnv("namespace", "output", "path"), PreRunE: bindEnv("namespace", "output", "path"),
RunE: runDescribe, }
cmd.Flags().StringP("namespace", "n", "", "Namespace of the function. By default, the namespace in func.yaml is used or the actual active namespace if not set in the configuration. (Env: $FUNC_NAMESPACE)")
cmd.Flags().StringP("output", "o", "human", "Output format (human|plain|json|xml|yaml|url) (Env: $FUNC_OUTPUT)")
cmd.Flags().StringP("path", "p", cwd(), "Path to the project directory (Env: $FUNC_PATH)")
if err := cmd.RegisterFlagCompletionFunc("output", CompleteOutputFormatList); err != nil {
fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err)
}
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runDescribe(cmd, args, clientFn)
}
return cmd
} }
func runDescribe(cmd *cobra.Command, args []string) (err error) { func runDescribe(cmd *cobra.Command, args []string, clientFn describeClientFn) (err error) {
config := newDescribeConfig(args) config := newDescribeConfig(args)
function, err := fn.NewFunction(config.Path) function, err := fn.NewFunction(config.Path)
@ -61,16 +84,13 @@ func runDescribe(cmd *cobra.Command, args []string) (err error) {
return fmt.Errorf("the given path '%v' does not contain an initialized function", config.Path) return fmt.Errorf("the given path '%v' does not contain an initialized function", config.Path)
} }
describer, err := knative.NewDescriber(config.Namespace) // Create a client
client, err := clientFn(config)
if err != nil { if err != nil {
return return err
} }
describer.Verbose = config.Verbose
client := fn.New(
fn.WithVerbose(config.Verbose),
fn.WithDescriber(describer))
// Get the description
d, err := client.Describe(cmd.Context(), config.Name, config.Path) d, err := client.Describe(cmd.Context(), config.Name, config.Path)
if err != nil { if err != nil {
return return

View File

@ -1,7 +1,8 @@
package cmd package cmd
import ( import (
"fmt" "context"
"errors"
"io/ioutil" "io/ioutil"
fn "github.com/boson-project/func" fn "github.com/boson-project/func"
@ -13,27 +14,41 @@ import (
) )
func init() { func init() {
e := cloudevents.NewEmitter() root.AddCommand(NewEmitCmd(newEmitClient))
root.AddCommand(emitCmd)
// TODO: do these env vars make sense?
emitCmd.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)")
emitCmd.Flags().StringP("source", "s", e.Source, "CloudEvent source (Env: $FUNC_SOURCE)")
emitCmd.Flags().StringP("type", "t", e.Type, "CloudEvent type (Env: $FUNC_TYPE)")
emitCmd.Flags().StringP("id", "i", uuid.NewString(), "CloudEvent ID (Env: $FUNC_ID)")
emitCmd.Flags().StringP("data", "d", "", "Any arbitrary string to be sent as the CloudEvent data. Ignored if --file is provided (Env: $FUNC_DATA)")
emitCmd.Flags().StringP("file", "f", "", "Path to a local file containing CloudEvent data to be sent (Env: $FUNC_FILE)")
emitCmd.Flags().StringP("content-type", "c", "application/json", "The MIME Content-Type for the CloudEvent data (Env: $FUNC_CONTENT_TYPE)")
emitCmd.Flags().StringP("path", "p", cwd(), "Path to the project directory. Ignored when --sink is provided (Env: $FUNC_PATH)")
} }
var emitCmd = &cobra.Command{ // create a fn.Client with an instance of a
Use: "emit", func newEmitClient(cfg emitConfig) (*fn.Client, error) {
Short: "Emit a CloudEvent to a function endpoint", e := cloudevents.NewEmitter()
Long: `Emit event e.Id = cfg.Id
e.Source = cfg.Source
e.Type = cfg.Type
e.ContentType = cfg.ContentType
e.Data = cfg.Data
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. Emits a CloudEvent, sending it to the deployed function.
`, `,
Example: ` Example: `
# Send a CloudEvent to the deployed function with no data and default values # Send a CloudEvent to the deployed function with no data and default values
# for source, type and ID # for source, type and ID
kn func emit kn func emit
@ -54,66 +69,102 @@ kn func emit --path /path/to/fn -i fn.test
# Send a CloudEvent to an arbitrary endpoint # Send a CloudEvent to an arbitrary endpoint
kn func emit --sink "http://my.event.broker.com" kn func emit --sink "http://my.event.broker.com"
`, `,
SuggestFor: []string{"meit", "emti", "send"}, SuggestFor: []string{"meit", "emti", "send"},
PreRunE: bindEnv("source", "type", "id", "data", "file", "path", "sink", "content-type"), PreRunE: bindEnv("source", "type", "id", "data", "file", "path", "sink", "content-type"),
RunE: runEmit, }
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)")
cmd.Flags().StringP("path", "p", cwd(), "Path to the project directory. Ignored when --sink is provided (Env: $FUNC_PATH)")
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runEmit(cmd, args, clientFn)
}
return cmd
} }
func runEmit(cmd *cobra.Command, args []string) (err error) { func runEmit(cmd *cobra.Command, _ []string, clientFn emitClientFn) (err error) {
config := newEmitConfig() config := newEmitConfig()
var endpoint string
if config.Sink != "" { // Validate things like invalid config combinations.
if config.Sink == "local" { if err := config.Validate(); err != nil {
endpoint = "http://localhost:8080" return err
} else {
endpoint = config.Sink
}
} else {
var f fn.Function
f, err = fn.NewFunction(config.Path)
if err != nil {
return
}
// What happens if the function hasn't been deployed but they don't run with --local=true
// Maybe we should be thinking about saving the endpoint URL in func.yaml after each deploy
var d *knative.Describer
d, err = knative.NewDescriber("")
if err != nil {
return
}
var desc fn.Description
desc, err = d.Describe(cmd.Context(), f.Name)
if err != nil {
return
}
// Use the first available route
endpoint = desc.Routes[0]
} }
emitter := cloudevents.NewEmitter() // Determine the final endpoint, taking into account the special value "local",
emitter.Source = config.Source // and sampling the function's current route if not explicitly provided
emitter.Type = config.Type endpoint, err := endpoint(cmd.Context(), config)
emitter.Id = config.Id if err != nil {
emitter.ContentType = config.ContentType return err
emitter.Data = config.Data
if config.File != "" {
var buf []byte
if emitter.Data != "" && config.Verbose {
return fmt.Errorf("Only one of --data and --file may be specified \n")
}
buf, err = ioutil.ReadFile(config.File)
if err != nil {
return
}
emitter.Data = string(buf)
} }
client := fn.New( // Instantiate a client based on the final value of config
fn.WithEmitter(emitter), client, err := clientFn(config)
) if err != nil {
return err
}
// Emit the event to the endpoint
return client.Emit(cmd.Context(), 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
desc fn.Description
)
// 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 desc, err = d.Describe(ctx, f.Name); err != nil {
return
}
// Probably wise to be defensive here:
if len(desc.Routes) == 0 {
err = errors.New("function has no active routes")
return
}
// The first route should be the destination.
return desc.Routes[0], nil
}
type emitConfig struct { type emitConfig struct {
Path string Path string
Source string Source string
@ -139,3 +190,11 @@ func newEmitConfig() emitConfig {
Verbose: viper.GetBool("verbose"), 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

@ -3,6 +3,7 @@ package cmd
import ( import (
"encoding/json" "encoding/json"
"encoding/xml" "encoding/xml"
"errors"
"fmt" "fmt"
"io" "io"
"os" "os"
@ -17,24 +18,36 @@ import (
) )
func init() { func init() {
root.AddCommand(listCmd) root.AddCommand(NewListCmd(newListClient))
listCmd.Flags().BoolP("all-namespaces", "A", false, "List functions in all namespaces. If set, the --namespace flag is ignored.")
listCmd.Flags().StringP("namespace", "n", "", "Namespace to search for functions. By default, the functions of the actual active namespace are listed. (Env: $FUNC_NAMESPACE)")
listCmd.Flags().StringP("output", "o", "human", "Output format (human|plain|json|xml|yaml) (Env: $FUNC_OUTPUT)")
err := listCmd.RegisterFlagCompletionFunc("output", CompleteOutputFormatList)
if err != nil {
fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err)
}
} }
var listCmd = &cobra.Command{ func newListClient(cfg listConfig) (*fn.Client, error) {
Use: "list", // TODO(lkingland): does an empty namespace mean all namespaces
Short: "List functions", // or the default namespace as defined in user's config?
Long: `List functions lister, err := knative.NewLister(cfg.Namespace)
if err != nil {
return nil, err
}
lister.Verbose = cfg.Verbose
return fn.New(
fn.WithLister(lister),
fn.WithVerbose(cfg.Verbose),
), nil
}
type listClientFn func(listConfig) (*fn.Client, error)
func NewListCmd(clientFn listClientFn) *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List functions",
Long: `List functions
Lists all deployed functions in a given namespace. Lists all deployed functions in a given namespace.
`, `,
Example: ` Example: `
# List all functions in the current namespace with human readable output # List all functions in the current namespace with human readable output
kn func list kn func list
@ -44,40 +57,51 @@ kn func list --namespace test --output yaml
# List all functions in all namespaces with JSON output # List all functions in all namespaces with JSON output
kn func list --all-namespaces --output json kn func list --all-namespaces --output json
`, `,
SuggestFor: []string{"ls", "lsit"}, SuggestFor: []string{"ls", "lsit"},
PreRunE: bindEnv("namespace", "output"), PreRunE: bindEnv("namespace", "output"),
RunE: runList, }
cmd.Flags().BoolP("all-namespaces", "A", false, "List functions in all namespaces. If set, the --namespace flag is ignored.")
cmd.Flags().StringP("namespace", "n", "", "Namespace to search for functions. By default, the functions of the actual active namespace are listed. (Env: $FUNC_NAMESPACE)")
cmd.Flags().StringP("output", "o", "human", "Output format (human|plain|json|xml|yaml) (Env: $FUNC_OUTPUT)")
if err := cmd.RegisterFlagCompletionFunc("output", CompleteOutputFormatList); err != nil {
fmt.Println("internal: error while calling RegisterFlagCompletionFunc: ", err)
}
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runList(cmd, args, clientFn)
}
return cmd
} }
func runList(cmd *cobra.Command, args []string) (err error) { func runList(cmd *cobra.Command, _ []string, clientFn listClientFn) (err error) {
config := newListConfig() config := newListConfig()
lister, err := knative.NewLister(config.Namespace) if err := config.Validate(); err != nil {
if err != nil { return err
return
}
lister.Verbose = config.Verbose
a, err := cmd.Flags().GetBool("all-namespaces")
if err != nil {
return
}
if a {
lister.Namespace = ""
} }
client := fn.New( client, err := clientFn(config)
fn.WithVerbose(config.Verbose), if err != nil {
fn.WithLister(lister)) return err
}
items, err := client.List(cmd.Context()) items, err := client.List(cmd.Context())
if err != nil { if err != nil {
return return
} }
if len(items) < 1 { if len(items) == 0 {
fmt.Printf("No functions found in %v namespace\n", lister.Namespace) // TODO(lkingland): this isn't particularly script friendly. Suggest this
return // prints bo only on --verbose. Possible future tweak, as I don't want to
// make functional changes during a refactor.
if config.Namespace != "" && !config.AllNamespaces {
fmt.Printf("No functions found in '%v' namespace\n", config.Namespace)
} else {
fmt.Println("No functions found")
}
} }
write(os.Stdout, listItems(items), config.Output) write(os.Stdout, listItems(items), config.Output)
@ -89,19 +113,28 @@ func runList(cmd *cobra.Command, args []string) (err error) {
// ------------------------------ // ------------------------------
type listConfig struct { type listConfig struct {
Namespace string Namespace string
Output string Output string
Verbose bool AllNamespaces bool
Verbose bool
} }
func newListConfig() listConfig { func newListConfig() listConfig {
return listConfig{ return listConfig{
Namespace: viper.GetString("namespace"), Namespace: viper.GetString("namespace"),
Output: viper.GetString("output"), Output: viper.GetString("output"),
Verbose: viper.GetBool("verbose"), AllNamespaces: viper.GetBool("all-namespaces"),
Verbose: viper.GetBool("verbose"),
} }
} }
func (c listConfig) Validate() error {
if c.Namespace != "" && c.AllNamespaces {
return errors.New("Both --namespace and --all-namespaces specified.")
}
return nil
}
// Output Formatting (serializers) // Output Formatting (serializers)
// ------------------------------- // -------------------------------

View File

@ -12,35 +12,54 @@ import (
) )
func init() { func init() {
// Add the run command as a subcommand of root. root.AddCommand(NewRunCmd(newRunClient))
root.AddCommand(runCmd)
runCmd.Flags().StringArrayP("env", "e", []string{}, "Environment variable to set in the form NAME=VALUE. "+
"You may provide this flag multiple times for setting multiple environment variables. "+
"To unset, specify the environment variable name followed by a \"-\" (e.g., NAME-).")
runCmd.Flags().StringP("path", "p", cwd(), "Path to the project directory (Env: $FUNC_PATH)")
} }
var runCmd = &cobra.Command{ func newRunClient(cfg runConfig) *fn.Client {
Use: "run", runner := docker.NewRunner()
Short: "Run the function locally", runner.Verbose = cfg.Verbose
Long: `Run the function locally return fn.New(
fn.WithRunner(runner),
fn.WithVerbose(cfg.Verbose))
}
type runClientFn func(runConfig) *fn.Client
func NewRunCmd(clientFn runClientFn) *cobra.Command {
cmd := &cobra.Command{
Use: "run",
Short: "Run the function locally",
Long: `Run the function locally
Runs the function locally in the current directory or in the directory Runs the function locally in the current directory or in the directory
specified by --path flag. The function must already have been built with the 'build' command. specified by --path flag. The function must already have been built with the 'build' command.
`, `,
Example: ` Example: `
# Build function's image first # Build function's image first
kn func build kn func build
# Run it locally as a container # Run it locally as a container
kn func run kn func run
`, `,
SuggestFor: []string{"rnu"}, SuggestFor: []string{"rnu"},
PreRunE: bindEnv("path"), PreRunE: bindEnv("path"),
RunE: runRun, }
cmd.Flags().StringArrayP("env", "e", []string{},
"Environment variable to set in the form NAME=VALUE. "+
"You may provide this flag multiple times for setting multiple environment variables. "+
"To unset, specify the environment variable name followed by a \"-\" (e.g., NAME-).")
cmd.Flags().StringP("path", "p", cwd(), "Path to the project directory (Env: $FUNC_PATH)")
cmd.RunE = func(cmd *cobra.Command, args []string) error {
return runRun(cmd, args, clientFn)
}
return cmd
} }
func runRun(cmd *cobra.Command, args []string) (err error) { func runRun(cmd *cobra.Command, args []string, clientFn runClientFn) (err error) {
config, err := newRunConfig(cmd) config, err := newRunConfig(cmd)
if err != nil { if err != nil {
return return
@ -66,15 +85,9 @@ func runRun(cmd *cobra.Command, args []string) (err error) {
return fmt.Errorf("the given path '%v' does not contain an initialized function", config.Path) return fmt.Errorf("the given path '%v' does not contain an initialized function", config.Path)
} }
runner := docker.NewRunner() client := clientFn(config)
runner.Verbose = config.Verbose
client := fn.New( return client.Run(cmd.Context(), config.Path)
fn.WithRunner(runner),
fn.WithVerbose(config.Verbose))
err = client.Run(cmd.Context(), config.Path)
return
} }
type runConfig struct { type runConfig struct {
@ -92,10 +105,10 @@ type runConfig struct {
EnvToRemove []string EnvToRemove []string
} }
func newRunConfig(cmd *cobra.Command) (runConfig, error) { func newRunConfig(cmd *cobra.Command) (c runConfig, err error) {
envToUpdate, envToRemove, err := envFromCmd(cmd) envToUpdate, envToRemove, err := envFromCmd(cmd)
if err != nil { if err != nil {
return runConfig{}, err return
} }
return runConfig{ return runConfig{

View File

@ -13,7 +13,7 @@ import (
) )
const ( const (
DefaultWaitingTimeout = 60 * time.Second DefaultWaitingTimeout = 120 * time.Second
) )
func NewServingClient(namespace string) (clientservingv1.KnServingClient, error) { func NewServingClient(namespace string) (clientservingv1.KnServingClient, error) {

View File

@ -153,6 +153,13 @@ func (b *Bar) Complete(text string) {
b.Done() // stop spinner b.Done() // stop spinner
} }
// Stopping indicates the process is stopping, such as having received a context
// cancellation.
func (b *Bar) Stopping() {
// currently stopping is equivalent in effect to Done
b.Done()
}
// Done cancels the write loop if being used. // Done cancels the write loop if being used.
// Call in a defer statement after creation to ensure that the spinner stops // Call in a defer statement after creation to ensure that the spinner stops
func (b *Bar) Done() { func (b *Bar) Done() {