From 11ac7033e0ef50886c76aaa7add4a0e260b61122 Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Wed, 7 Sep 2022 09:57:59 -0400 Subject: [PATCH 01/28] Add support for typescript in the nodejs runtime (#1225) --- commands/serverless.go | 21 ++++++++++----------- do/serverless.go | 2 +- 2 files changed, 11 insertions(+), 12 deletions(-) diff --git a/commands/serverless.go b/commands/serverless.go index 1dd5825d7..89231187e 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -39,17 +39,16 @@ var ( // Note: this table has all languages for which we possess samples. Only those with currently // active runtimes will display. languageKeywords map[string][]string = map[string][]string{ - "nodejs": {"javascript", "js"}, - "deno": {"deno"}, - "go": {"go", "golang"}, - "java": {"java"}, - "php": {"php"}, - "python": {"python", "py"}, - "ruby": {"ruby"}, - "rust": {"rust"}, - "swift": {"swift"}, - "dotnet": {"csharp", "cs"}, - "typescript": {"typescript", "ts"}, + "nodejs": {"javascript", "js", "typescript", "ts"}, + "deno": {"deno"}, + "go": {"go", "golang"}, + "java": {"java"}, + "php": {"php"}, + "python": {"python", "py"}, + "ruby": {"ruby"}, + "rust": {"rust"}, + "swift": {"swift"}, + "dotnet": {"csharp", "cs"}, } ) diff --git a/do/serverless.go b/do/serverless.go index a88f3095f..6945e116c 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -227,7 +227,7 @@ const ( // Minimum required version of the sandbox plugin code. The first part is // the version of the incorporated Nimbella CLI and the second part is the // version of the bridge code in the sandbox plugin repository. - minServerlessVersion = "4.1.0-1.3.1" + minServerlessVersion = "4.2.3-1.3.1" // The version of nodejs to download alongsize the plugin download. nodeVersion = "v16.13.0" From a75e480d2b0891503d657f44b74a2c1cc26db0db Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Mon, 12 Sep 2022 11:33:37 -0400 Subject: [PATCH 02/28] Eliminate plugin usage for sls fn invoke (#1226) --- commands/displayers/functions.go | 6 +- commands/functions.go | 60 +++++++++----- commands/functions_test.go | 65 +++++++-------- do/mocks/ServerlessService.go | 29 +++++++ do/serverless.go | 79 ++++++++++++++----- go.mod | 7 +- go.sum | 3 + vendor/github.com/pkg/browser/LICENSE | 23 ++++++ vendor/github.com/pkg/browser/README.md | 55 +++++++++++++ vendor/github.com/pkg/browser/browser.go | 57 +++++++++++++ .../github.com/pkg/browser/browser_darwin.go | 5 ++ .../github.com/pkg/browser/browser_freebsd.go | 14 ++++ .../github.com/pkg/browser/browser_linux.go | 21 +++++ .../github.com/pkg/browser/browser_netbsd.go | 14 ++++ .../github.com/pkg/browser/browser_openbsd.go | 14 ++++ .../pkg/browser/browser_unsupported.go | 12 +++ .../github.com/pkg/browser/browser_windows.go | 7 ++ vendor/modules.txt | 3 + 18 files changed, 400 insertions(+), 74 deletions(-) create mode 100644 vendor/github.com/pkg/browser/LICENSE create mode 100644 vendor/github.com/pkg/browser/README.md create mode 100644 vendor/github.com/pkg/browser/browser.go create mode 100644 vendor/github.com/pkg/browser/browser_darwin.go create mode 100644 vendor/github.com/pkg/browser/browser_freebsd.go create mode 100644 vendor/github.com/pkg/browser/browser_linux.go create mode 100644 vendor/github.com/pkg/browser/browser_netbsd.go create mode 100644 vendor/github.com/pkg/browser/browser_openbsd.go create mode 100644 vendor/github.com/pkg/browser/browser_unsupported.go create mode 100644 vendor/github.com/pkg/browser/browser_windows.go diff --git a/commands/displayers/functions.go b/commands/displayers/functions.go index a96aae7cf..a10c9e8c7 100644 --- a/commands/displayers/functions.go +++ b/commands/displayers/functions.go @@ -18,12 +18,12 @@ import ( "strings" "time" - "github.com/digitalocean/doctl/do" + "github.com/apache/openwhisk-client-go/whisk" ) // Functions is the type of the displayer for functions list type Functions struct { - Info []do.FunctionInfo + Info []whisk.Action } var _ Displayable = &Functions{} @@ -67,7 +67,7 @@ func (i *Functions) KV() []map[string]interface{} { } // findRuntime finds the runtime string amongst the annotations of a function -func findRuntime(annots []do.Annotation) string { +func findRuntime(annots whisk.KeyValueArr) string { for i := range annots { if annots[i].Key == "exec" { return annots[i].Value.(string) diff --git a/commands/functions.go b/commands/functions.go index 024d27d68..fc53f7722 100644 --- a/commands/functions.go +++ b/commands/functions.go @@ -214,18 +214,33 @@ func RunFunctionsInvoke(c *CmdConfig) error { if err != nil { return err } - // Assemble args and flags except for "param" - args := getFlatArgsArray(c, []string{flagWeb, flagFull, flagNoWait, flagResult}, []string{flagParamFile}) - // Add "param" with special handling if present - args, err = appendParams(c, args) + paramFile, _ := c.Doit.GetString(c.NS, flagParamFile) + paramFlags, _ := c.Doit.GetStringSlice(c.NS, flagParam) + params, err := consolidateParams(paramFile, paramFlags) if err != nil { return err } - output, err := ServerlessExec(c, actionInvoke, args...) + web, _ := c.Doit.GetBool(c.NS, flagWeb) + if web { + var mapParams map[string]interface{} = nil + if params != nil { + p, ok := params.(map[string]interface{}) + if !ok { + return fmt.Errorf("cannot invoke via web: parameters do not form a dictionary") + } + mapParams = p + } + return c.Serverless().InvokeFunctionViaWeb(c.Args[0], mapParams) + } + full, _ := c.Doit.GetBool(c.NS, flagFull) + noWait, _ := c.Doit.GetBool(c.NS, flagNoWait) + blocking := !noWait + result := blocking && !full + response, err := c.Serverless().InvokeFunction(c.Args[0], params, blocking, result) if err != nil { return err } - + output := do.ServerlessOutput{Entity: response} return c.PrintServerlessTextOutput(output) } @@ -257,7 +272,7 @@ func RunFunctionsList(c *CmdConfig) error { if err != nil { return err } - var formatted []do.FunctionInfo + var formatted []whisk.Action err = json.Unmarshal(rawOutput, &formatted) if err != nil { return err @@ -265,23 +280,30 @@ func RunFunctionsList(c *CmdConfig) error { return c.Display(&displayers.Functions{Info: formatted}) } -// appendParams determines if there is a 'param' flag (value is a slice, elements -// of the slice should be in KEY:VALUE form), if so, transforms it into the form -// expected by 'nim' (each param is its own --param flag, KEY and VALUE are separate -// tokens). The 'args' argument is the result of getFlatArgsArray and is appended -// to. -func appendParams(c *CmdConfig, args []string) ([]string, error) { - params, err := c.Doit.GetStringSlice(c.NS, flagParam) - if err != nil || len(params) == 0 { - return args, nil // error here is not considered an error (and probably won't occur) +// consolidateParams accepts parameters from a file, the command line, or both, and consolidates all +// such parameters into a simple dictionary. +func consolidateParams(paramFile string, params []string) (interface{}, error) { + consolidated := map[string]interface{}{} + if len(paramFile) > 0 { + contents, err := os.ReadFile(paramFile) + if err != nil { + return nil, err + } + err = json.Unmarshal(contents, &consolidated) + if err != nil { + return nil, err + } } for _, param := range params { parts := strings.Split(param, ":") if len(parts) < 2 { - return args, errors.New("values for --params must have KEY:VALUE form") + return nil, fmt.Errorf("values for --params must have KEY:VALUE form") } parts1 := strings.Join(parts[1:], ":") - args = append(args, dashdashParam, parts[0], parts1) + consolidated[parts[0]] = parts1 + } + if len(consolidated) > 0 { + return consolidated, nil } - return args, nil + return nil, nil } diff --git a/commands/functions_test.go b/commands/functions_test.go index 148b3cade..1b05a025d 100644 --- a/commands/functions_test.go +++ b/commands/functions_test.go @@ -173,50 +173,57 @@ func TestFunctionsGet(t *testing.T) { func TestFunctionsInvoke(t *testing.T) { tests := []struct { - name string - doctlArgs string - doctlFlags map[string]interface{} - expectedNimArgs []string + name string + doctlArgs string + doctlFlags map[string]interface{} + requestResult bool + passedParams interface{} }{ { - name: "no flags", - doctlArgs: "hello", - expectedNimArgs: []string{"hello"}, + name: "no flags", + doctlArgs: "hello", + requestResult: true, + passedParams: nil, }, { - name: "full flag", - doctlArgs: "hello", - doctlFlags: map[string]interface{}{"full": ""}, - expectedNimArgs: []string{"hello", "--full"}, + name: "full flag", + doctlArgs: "hello", + doctlFlags: map[string]interface{}{"full": ""}, + requestResult: false, + passedParams: nil, }, { - name: "param flag", - doctlArgs: "hello", - doctlFlags: map[string]interface{}{"param": "name:world"}, - expectedNimArgs: []string{"hello", "--param", "name", "world"}, + name: "param flag", + doctlArgs: "hello", + doctlFlags: map[string]interface{}{"param": "name:world"}, + requestResult: true, + passedParams: map[string]interface{}{"name": "world"}, }, { - name: "param flag list", - doctlArgs: "hello", - doctlFlags: map[string]interface{}{"param": []string{"name:world", "address:everywhere"}}, - expectedNimArgs: []string{"hello", "--param", "name", "world", "--param", "address", "everywhere"}, + name: "param flag list", + doctlArgs: "hello", + doctlFlags: map[string]interface{}{"param": []string{"name:world", "address:everywhere"}}, + requestResult: true, + passedParams: map[string]interface{}{"name": "world", "address": "everywhere"}, }, { - name: "param flag colon-value", - doctlArgs: "hello", - doctlFlags: map[string]interface{}{"param": []string{"url:https://example.com"}}, - expectedNimArgs: []string{"hello", "--param", "url", "https://example.com"}, + name: "param flag colon-value", + doctlArgs: "hello", + doctlFlags: map[string]interface{}{"param": []string{"url:https://example.com"}}, + requestResult: true, + passedParams: map[string]interface{}{"url": "https://example.com"}, }, } + expectedRemoteResult := map[string]interface{}{ + "body": "Hello world!", + } + for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { buf := &bytes.Buffer{} config.Out = buf - fakeCmd := &exec.Cmd{ - Stdout: config.Out, - } config.Args = append(config.Args, tt.doctlArgs) if tt.doctlFlags != nil { @@ -229,11 +236,7 @@ func TestFunctionsInvoke(t *testing.T) { } } - tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil) - tm.serverless.EXPECT().Cmd("action/invoke", tt.expectedNimArgs).Return(fakeCmd, nil) - tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{ - Entity: map[string]interface{}{"body": "Hello world!"}, - }, nil) + tm.serverless.EXPECT().InvokeFunction(tt.doctlArgs, tt.passedParams, true, tt.requestResult).Return(expectedRemoteResult, nil) expectedOut := `{ "body": "Hello world!" } diff --git a/do/mocks/ServerlessService.go b/do/mocks/ServerlessService.go index f650e00f9..31c4fc153 100644 --- a/do/mocks/ServerlessService.go +++ b/do/mocks/ServerlessService.go @@ -200,6 +200,35 @@ func (mr *MockServerlessServiceMockRecorder) InstallServerless(arg0, arg1 interf return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InstallServerless", reflect.TypeOf((*MockServerlessService)(nil).InstallServerless), arg0, arg1) } +// InvokeFunction mocks base method. +func (m *MockServerlessService) InvokeFunction(arg0 string, arg1 interface{}, arg2, arg3 bool) (map[string]interface{}, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InvokeFunction", arg0, arg1, arg2, arg3) + ret0, _ := ret[0].(map[string]interface{}) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// InvokeFunction indicates an expected call of InvokeFunction. +func (mr *MockServerlessServiceMockRecorder) InvokeFunction(arg0, arg1, arg2, arg3 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InvokeFunction", reflect.TypeOf((*MockServerlessService)(nil).InvokeFunction), arg0, arg1, arg2, arg3) +} + +// InvokeFunctionViaWeb mocks base method. +func (m *MockServerlessService) InvokeFunctionViaWeb(arg0 string, arg1 map[string]interface{}) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "InvokeFunctionViaWeb", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// InvokeFunctionViaWeb indicates an expected call of InvokeFunctionViaWeb. +func (mr *MockServerlessServiceMockRecorder) InvokeFunctionViaWeb(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InvokeFunctionViaWeb", reflect.TypeOf((*MockServerlessService)(nil).InvokeFunctionViaWeb), arg0, arg1) +} + // ListNamespaces mocks base method. func (m *MockServerlessService) ListNamespaces(arg0 context.Context) (do.NamespaceListResponse, error) { m.ctrl.T.Helper() diff --git a/do/serverless.go b/do/serverless.go index 6945e116c..7ea729c1d 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -21,6 +21,7 @@ import ( "io" "io/ioutil" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -31,6 +32,7 @@ import ( "github.com/digitalocean/doctl" "github.com/digitalocean/doctl/pkg/extract" "github.com/digitalocean/godo" + "github.com/pkg/browser" "gopkg.in/yaml.v3" ) @@ -112,25 +114,6 @@ type ServerlessHostInfo struct { Runtimes map[string][]ServerlessRuntime `json:"runtimes"` } -// FunctionInfo is the type of an individual function in the output -// of doctl sls fn list. Only relevant fields are unmarshaled. -// Note: when we start replacing the sandbox plugin path with direct calls -// to backend controller operations, this will be replaced by declarations -// in the golang openwhisk client. -type FunctionInfo struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Updated int64 `json:"updated"` - Version string `json:"version"` - Annotations []Annotation `json:"annotations"` -} - -// Annotation is a key/value type suitable for individual annotations -type Annotation struct { - Key string `json:"key"` - Value interface{} `json:"value"` -} - // ServerlessProject ... type ServerlessProject struct { ProjectPath string `json:"project_path"` @@ -208,6 +191,8 @@ type ServerlessService interface { CheckServerlessStatus(string) error InstallServerless(string, bool) error GetFunction(string, bool) (whisk.Action, []FunctionParameter, error) + InvokeFunction(string, interface{}, bool, bool) (map[string]interface{}, error) + InvokeFunctionViaWeb(string, map[string]interface{}) error GetConnectedAPIHost() (string, error) ReadProject(*ServerlessProject, []string) (ServerlessOutput, error) WriteProject(ServerlessProject) (string, error) @@ -671,6 +656,62 @@ func (s *serverlessService) GetFunction(name string, fetchCode bool) (whisk.Acti return *action, parameters, err } +// InvokeFunction invokes a function via POST with authentication +func (s *serverlessService) InvokeFunction(name string, params interface{}, blocking bool, result bool) (map[string]interface{}, error) { + var empty map[string]interface{} + err := initWhisk(s) + if err != nil { + return empty, err + } + resp, _, err := s.owClient.Actions.Invoke(name, params, blocking, result) + return resp, err +} + +// InvokeFunctionViaWeb invokes a function via GET using its web URL (or error if not a web function) +func (s *serverlessService) InvokeFunctionViaWeb(name string, params map[string]interface{}) error { + // Get the function so we can use its metadata in formulating the request + theFunction, _, err := s.GetFunction(name, false) + if err != nil { + return err + } + // Check that it's a web function + isWeb := false + for _, annot := range theFunction.Annotations { + if annot.Key == "web-export" { + isWeb = true + break + } + } + if !isWeb { + return fmt.Errorf("'%s' is not a web function", name) + } + // Formulate the invocation URL + host, err := s.GetConnectedAPIHost() + if err != nil { + return err + } + nsParts := strings.Split(theFunction.Namespace, "/") + namespace := nsParts[0] + pkg := "default" + if len(nsParts) > 1 { + pkg = nsParts[1] + } + theURL := fmt.Sprintf("%s/api/v1/web/%s/%s/%s", host, namespace, pkg, theFunction.Name) + // Add params, if any + if params != nil { + encoded := url.Values{} + for key, val := range params { + stringVal, ok := val.(string) + if !ok { + return fmt.Errorf("the value of '%s' is not a string; web invocation is not possible", key) + } + encoded.Add(key, stringVal) + } + theURL += "?" + encoded.Encode() + } + return browser.OpenURL(theURL) +} + // GetConnectedAPIHost retrieves the API host to which the service is currently connected func (s *serverlessService) GetConnectedAPIHost() (string, error) { err := initWhisk(s) diff --git a/go.mod b/go.mod index 0678b3e7b..360bee103 100644 --- a/go.mod +++ b/go.mod @@ -44,7 +44,11 @@ require ( sigs.k8s.io/yaml v1.2.0 ) -require github.com/apache/openwhisk-client-go v0.0.0-20211007130743-38709899040b +require ( + github.com/apache/openwhisk-client-go v0.0.0-20211007130743-38709899040b + github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b +) require ( github.com/Microsoft/go-winio v0.5.2 // indirect @@ -86,7 +90,6 @@ require ( google.golang.org/appengine v1.6.7 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/ini.v1 v1.66.4 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect k8s.io/klog/v2 v2.30.0 // indirect k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b // indirect sigs.k8s.io/structured-merge-diff/v4 v4.1.2 // indirect diff --git a/go.sum b/go.sum index 22499a7d3..961828674 100644 --- a/go.sum +++ b/go.sum @@ -639,6 +639,8 @@ github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCko github.com/pelletier/go-toml/v2 v2.0.0-beta.8 h1:dy81yyLYJDwMTifq24Oi/IslOslRrDSb3jwDggjz3Z0= github.com/pelletier/go-toml/v2 v2.0.0-beta.8/go.mod h1:r9LEWfGN8R5k0VXJ+0BkIe7MYkRdwZOjgMj2KwnJFUo= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -1003,6 +1005,7 @@ golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/vendor/github.com/pkg/browser/LICENSE b/vendor/github.com/pkg/browser/LICENSE new file mode 100644 index 000000000..65f78fb62 --- /dev/null +++ b/vendor/github.com/pkg/browser/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2014, Dave Cheney +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/pkg/browser/README.md b/vendor/github.com/pkg/browser/README.md new file mode 100644 index 000000000..72b1976e3 --- /dev/null +++ b/vendor/github.com/pkg/browser/README.md @@ -0,0 +1,55 @@ + +# browser + import "github.com/pkg/browser" + +Package browser provides helpers to open files, readers, and urls in a browser window. + +The choice of which browser is started is entirely client dependant. + + + + + +## Variables +``` go +var Stderr io.Writer = os.Stderr +``` +Stderr is the io.Writer to which executed commands write standard error. + +``` go +var Stdout io.Writer = os.Stdout +``` +Stdout is the io.Writer to which executed commands write standard output. + + +## func OpenFile +``` go +func OpenFile(path string) error +``` +OpenFile opens new browser window for the file path. + + +## func OpenReader +``` go +func OpenReader(r io.Reader) error +``` +OpenReader consumes the contents of r and presents the +results in a new browser window. + + +## func OpenURL +``` go +func OpenURL(url string) error +``` +OpenURL opens a new browser window pointing to url. + + + + + + + + + +- - - +Generated by [godoc2md](http://godoc.org/github.com/davecheney/godoc2md) diff --git a/vendor/github.com/pkg/browser/browser.go b/vendor/github.com/pkg/browser/browser.go new file mode 100644 index 000000000..d7969d74d --- /dev/null +++ b/vendor/github.com/pkg/browser/browser.go @@ -0,0 +1,57 @@ +// Package browser provides helpers to open files, readers, and urls in a browser window. +// +// The choice of which browser is started is entirely client dependant. +package browser + +import ( + "fmt" + "io" + "io/ioutil" + "os" + "os/exec" + "path/filepath" +) + +// Stdout is the io.Writer to which executed commands write standard output. +var Stdout io.Writer = os.Stdout + +// Stderr is the io.Writer to which executed commands write standard error. +var Stderr io.Writer = os.Stderr + +// OpenFile opens new browser window for the file path. +func OpenFile(path string) error { + path, err := filepath.Abs(path) + if err != nil { + return err + } + return OpenURL("file://" + path) +} + +// OpenReader consumes the contents of r and presents the +// results in a new browser window. +func OpenReader(r io.Reader) error { + f, err := ioutil.TempFile("", "browser.*.html") + if err != nil { + return fmt.Errorf("browser: could not create temporary file: %v", err) + } + if _, err := io.Copy(f, r); err != nil { + f.Close() + return fmt.Errorf("browser: caching temporary file failed: %v", err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("browser: caching temporary file failed: %v", err) + } + return OpenFile(f.Name()) +} + +// OpenURL opens a new browser window pointing to url. +func OpenURL(url string) error { + return openBrowser(url) +} + +func runCmd(prog string, args ...string) error { + cmd := exec.Command(prog, args...) + cmd.Stdout = Stdout + cmd.Stderr = Stderr + return cmd.Run() +} diff --git a/vendor/github.com/pkg/browser/browser_darwin.go b/vendor/github.com/pkg/browser/browser_darwin.go new file mode 100644 index 000000000..8507cf7c2 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_darwin.go @@ -0,0 +1,5 @@ +package browser + +func openBrowser(url string) error { + return runCmd("open", url) +} diff --git a/vendor/github.com/pkg/browser/browser_freebsd.go b/vendor/github.com/pkg/browser/browser_freebsd.go new file mode 100644 index 000000000..4fc7ff076 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_freebsd.go @@ -0,0 +1,14 @@ +package browser + +import ( + "errors" + "os/exec" +) + +func openBrowser(url string) error { + err := runCmd("xdg-open", url) + if e, ok := err.(*exec.Error); ok && e.Err == exec.ErrNotFound { + return errors.New("xdg-open: command not found - install xdg-utils from ports(8)") + } + return err +} diff --git a/vendor/github.com/pkg/browser/browser_linux.go b/vendor/github.com/pkg/browser/browser_linux.go new file mode 100644 index 000000000..d26cdddf9 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_linux.go @@ -0,0 +1,21 @@ +package browser + +import ( + "os/exec" + "strings" +) + +func openBrowser(url string) error { + providers := []string{"xdg-open", "x-www-browser", "www-browser"} + + // There are multiple possible providers to open a browser on linux + // One of them is xdg-open, another is x-www-browser, then there's www-browser, etc. + // Look for one that exists and run it + for _, provider := range providers { + if _, err := exec.LookPath(provider); err == nil { + return runCmd(provider, url) + } + } + + return &exec.Error{Name: strings.Join(providers, ","), Err: exec.ErrNotFound} +} diff --git a/vendor/github.com/pkg/browser/browser_netbsd.go b/vendor/github.com/pkg/browser/browser_netbsd.go new file mode 100644 index 000000000..65a5e5a29 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_netbsd.go @@ -0,0 +1,14 @@ +package browser + +import ( + "errors" + "os/exec" +) + +func openBrowser(url string) error { + err := runCmd("xdg-open", url) + if e, ok := err.(*exec.Error); ok && e.Err == exec.ErrNotFound { + return errors.New("xdg-open: command not found - install xdg-utils from pkgsrc(7)") + } + return err +} diff --git a/vendor/github.com/pkg/browser/browser_openbsd.go b/vendor/github.com/pkg/browser/browser_openbsd.go new file mode 100644 index 000000000..4fc7ff076 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_openbsd.go @@ -0,0 +1,14 @@ +package browser + +import ( + "errors" + "os/exec" +) + +func openBrowser(url string) error { + err := runCmd("xdg-open", url) + if e, ok := err.(*exec.Error); ok && e.Err == exec.ErrNotFound { + return errors.New("xdg-open: command not found - install xdg-utils from ports(8)") + } + return err +} diff --git a/vendor/github.com/pkg/browser/browser_unsupported.go b/vendor/github.com/pkg/browser/browser_unsupported.go new file mode 100644 index 000000000..7c5c17d34 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_unsupported.go @@ -0,0 +1,12 @@ +// +build !linux,!windows,!darwin,!openbsd,!freebsd,!netbsd + +package browser + +import ( + "fmt" + "runtime" +) + +func openBrowser(url string) error { + return fmt.Errorf("openBrowser: unsupported operating system: %v", runtime.GOOS) +} diff --git a/vendor/github.com/pkg/browser/browser_windows.go b/vendor/github.com/pkg/browser/browser_windows.go new file mode 100644 index 000000000..63e192959 --- /dev/null +++ b/vendor/github.com/pkg/browser/browser_windows.go @@ -0,0 +1,7 @@ +package browser + +import "golang.org/x/sys/windows" + +func openBrowser(url string) error { + return windows.ShellExecute(0, nil, windows.StringToUTF16Ptr(url), nil, nil, windows.SW_SHOWNORMAL) +} diff --git a/vendor/modules.txt b/vendor/modules.txt index eb0571d20..3bce0abc1 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -185,6 +185,9 @@ github.com/pelletier/go-toml/v2 github.com/pelletier/go-toml/v2/internal/ast github.com/pelletier/go-toml/v2/internal/danger github.com/pelletier/go-toml/v2/internal/tracker +# github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 +## explicit; go 1.14 +github.com/pkg/browser # github.com/pkg/errors v0.9.1 ## explicit github.com/pkg/errors From 908c9ce7512c5b93dfdc2a633c02edf834ff50df Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Tue, 27 Sep 2022 11:00:25 -0400 Subject: [PATCH 03/28] Add doctl serverless trigger support (for scheduled functions) (#1232) * Add support for triggers * Add lastRun field to trigger list output * Hide commands we won't be supporting in EA day 1 * Bump deployer version to pick up bug fix * Fix error handling in services related to triggers Many calls were not checking for errors. * Switch to latest API Change both the triggers command (native to doctl) and the deployer version (which affects the semantics of deploy/undeploy). * Pick up latest deployer (triggers bug fix) * Remove support for prototype API and clean up code * Fix unit tests * Fix misleading comment * Remove added complexity due to successive change * Add filtering by function when listing triggers * Fix omitted code in DeleteTrigger * Guard triggers get/list with status check Otherwise, the credentials read fails with a cryptic error instead of an informative one when you are not connected to a namespace. --- commands/command_config.go | 2 +- commands/displayers/triggers.go | 71 +++++++++++++++ commands/serverless.go | 23 ++++- commands/serverless_test.go | 48 ++++++++-- commands/serverless_util.go | 8 +- commands/triggers.go | 98 +++++++++++++++++++++ commands/triggers_test.go | 147 +++++++++++++++++++++++++++++++ do/mocks/ServerlessService.go | 73 ++++++++++++++++ do/serverless.go | 150 +++++++++++++++++++++++++++++++- 9 files changed, 602 insertions(+), 18 deletions(-) create mode 100644 commands/displayers/triggers.go create mode 100644 commands/triggers.go create mode 100644 commands/triggers_test.go diff --git a/commands/command_config.go b/commands/command_config.go index 8c643b77b..55310e603 100644 --- a/commands/command_config.go +++ b/commands/command_config.go @@ -118,7 +118,7 @@ func NewCmdConfig(ns string, dc doctl.Config, out io.Writer, args []string, init c.Apps = func() do.AppsService { return do.NewAppsService(godoClient) } c.Monitoring = func() do.MonitoringService { return do.NewMonitoringService(godoClient) } c.Serverless = func() do.ServerlessService { - return do.NewServerlessService(godoClient, getServerlessDirectory(), hashAccessToken(c)) + return do.NewServerlessService(godoClient, getServerlessDirectory(), accessToken) } return nil diff --git a/commands/displayers/triggers.go b/commands/displayers/triggers.go new file mode 100644 index 000000000..6c3969781 --- /dev/null +++ b/commands/displayers/triggers.go @@ -0,0 +1,71 @@ +/* +Copyright 2018 The Doctl Authors All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package displayers + +import ( + "io" + "time" + + "github.com/digitalocean/doctl/do" +) + +// Triggers is the type of the displayer for triggers list +type Triggers struct { + List []do.ServerlessTrigger +} + +var _ Displayable = &Triggers{} + +// JSON is the displayer JSON method specialized for triggers list +func (i *Triggers) JSON(out io.Writer) error { + return writeJSON(i.List, out) +} + +// Cols is the displayer Cols method specialized for triggers list +func (i *Triggers) Cols() []string { + return []string{"Name", "Cron", "Function", "Enabled", "LastRun"} +} + +// ColMap is the displayer ColMap method specialized for triggers list +func (i *Triggers) ColMap() map[string]string { + return map[string]string{ + "Name": "Name", + "Cron": "Cron Expression", + "Function": "Invokes", + "Enabled": "Enabled", + "LastRun": "Last Run At", + } +} + +// KV is the displayer KV method specialized for triggers list +func (i *Triggers) KV() []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(i.List)) + for _, ii := range i.List { + lastRunTime, err := time.Parse(time.RFC3339, ii.LastRun) + lastRun := "_" + if err == nil { + lastRun = lastRunTime.Local().Format("01/02 03:04:05") + } + x := map[string]interface{}{ + "Name": ii.Name, + "Cron": ii.Cron, + "Function": ii.Function, + "Enabled": ii.Enabled, + "LastRun": lastRun, + } + out = append(out, x) + } + + return out +} diff --git a/commands/serverless.go b/commands/serverless.go index 3fd4d76ac..c34c92983 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -35,6 +35,9 @@ var ( // errUndeployTooFewArgs is the error returned when neither --all nor args are specified on undeploy errUndeployTooFewArgs = errors.New("either command line arguments or `--all` must be specified") + // errUndeployTrigPkg is the error returned when both --packages and --triggers are specified on undeploy + errUndeployTrigPkg = errors.New("the `--packages` and `--triggers` flags are mutually exclusive") + // languageKeywords maps the backend's runtime category names to keywords accepted as languages // Note: this table has all languages for which we possess samples. Only those with currently // active runtimes will display. @@ -100,11 +103,14 @@ Functions should be listed in `+"`"+`pkgName/fnName`+"`"+` form, or `+"`"+`fnNam The `+"`"+`--packages`+"`"+` flag causes arguments without slash separators to be intepreted as packages, in which case the entire packages are removed.`, Writer) AddBoolFlag(undeploy, "packages", "p", false, "interpret simple name arguments as packages") + AddBoolFlag(undeploy, "triggers", "", false, "interpret all arguments as triggers") AddBoolFlag(undeploy, "all", "", false, "remove all packages and functions") + undeploy.Flags().MarkHidden("triggers") // support is experimental at this point cmd.AddCommand(Activations()) cmd.AddCommand(Functions()) cmd.AddCommand(Namespaces()) + cmd.AddCommand(Triggers()) ServerlessExtras(cmd) return cmd } @@ -359,6 +365,7 @@ func showLanguageInfo(c *CmdConfig, APIHost string) error { func RunServerlessUndeploy(c *CmdConfig) error { haveArgs := len(c.Args) > 0 pkgFlag, _ := c.Doit.GetBool(c.NS, "packages") + trigFlag, _ := c.Doit.GetBool(c.NS, "triggers") all, _ := c.Doit.GetBool(c.NS, "all") if haveArgs && all { return errUndeployAllAndArgs @@ -366,14 +373,28 @@ func RunServerlessUndeploy(c *CmdConfig) error { if !haveArgs && !all { return errUndeployTooFewArgs } + if pkgFlag && trigFlag { + return errUndeployTrigPkg + } + if all && trigFlag { + return cleanTriggers(c) + } if all { return cleanNamespace(c) } var lastError error errorCount := 0 + var ctx context.Context + var sls do.ServerlessService + if trigFlag { + ctx = context.TODO() + sls = c.Serverless() + } for _, arg := range c.Args { var err error - if strings.Contains(arg, "/") || !pkgFlag { + if trigFlag { + err = sls.DeleteTrigger(ctx, arg) + } else if strings.Contains(arg, "/") || !pkgFlag { err = deleteFunction(c, arg) } else { err = deletePackage(c, arg) diff --git a/commands/serverless_test.go b/commands/serverless_test.go index fc4de57e1..d1f9fe654 100644 --- a/commands/serverless_test.go +++ b/commands/serverless_test.go @@ -455,11 +455,13 @@ func TestServerlessUndeploy(t *testing.T) { } tests := []struct { - name string - doctlArgs []string - doctlFlags map[string]string - expectedNimCmds []testNimCmd - expectedError error + name string + doctlArgs []string + doctlFlags map[string]string + expectedNimCmds []testNimCmd + expectedError error + expectTriggerDeletes []string + expectTriggerList bool }{ { name: "no arguments or flags", @@ -519,6 +521,30 @@ func TestServerlessUndeploy(t *testing.T) { expectedNimCmds: nil, expectedError: errUndeployAllAndArgs, }, + { + name: "--triggers and --packages", + doctlArgs: []string{"foo/bar", "baz"}, + doctlFlags: map[string]string{"triggers": "", "packages": ""}, + expectedNimCmds: nil, + expectedError: errUndeployTrigPkg, + }, + { + name: "--triggers and args", + doctlArgs: []string{"fire1", "fire2"}, + doctlFlags: map[string]string{"triggers": ""}, + expectedNimCmds: nil, + expectedError: nil, + expectTriggerDeletes: []string{"fire1", "fire2"}, + }, + { + name: "--triggers and --all", + doctlArgs: nil, + doctlFlags: map[string]string{"triggers": "", "all": ""}, + expectedNimCmds: nil, + expectedError: nil, + expectTriggerDeletes: []string{"fireA", "fireB"}, + expectTriggerList: true, + }, } for _, tt := range tests { @@ -527,6 +553,10 @@ func TestServerlessUndeploy(t *testing.T) { fakeCmd := &exec.Cmd{ Stdout: config.Out, } + cannedTriggerList := []do.ServerlessTrigger{ + {Name: "fireA"}, + {Name: "fireB"}, + } if len(tt.doctlArgs) > 0 { config.Args = append(config.Args, tt.doctlArgs...) @@ -542,13 +572,19 @@ func TestServerlessUndeploy(t *testing.T) { } } - if tt.expectedError == nil { + if tt.expectedError == nil && len(tt.expectedNimCmds) > 0 { tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil) } + if tt.expectTriggerList { + tm.serverless.EXPECT().ListTriggers(context.TODO(), "").Return(cannedTriggerList, nil) + } for i := range tt.expectedNimCmds { tm.serverless.EXPECT().Cmd(tt.expectedNimCmds[i].cmd, tt.expectedNimCmds[i].args).Return(fakeCmd, nil) tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{}, nil) } + for _, trig := range tt.expectTriggerDeletes { + tm.serverless.EXPECT().DeleteTrigger(context.TODO(), trig) + } err := RunServerlessUndeploy(config) if tt.expectedError != nil { require.Error(t, err) diff --git a/commands/serverless_util.go b/commands/serverless_util.go index 9a45b18bc..b0869e5b8 100644 --- a/commands/serverless_util.go +++ b/commands/serverless_util.go @@ -14,8 +14,6 @@ limitations under the License. package commands import ( - "crypto/sha1" - "encoding/hex" "encoding/json" "fmt" "os" @@ -112,11 +110,7 @@ func (c *CmdConfig) PrintServerlessTextOutput(output do.ServerlessOutput) error } func hashAccessToken(c *CmdConfig) string { - token := c.getContextAccessToken() - hasher := sha1.New() - hasher.Write([]byte(token)) - sha := hasher.Sum(nil) - return hex.EncodeToString(sha[:4]) + return do.HashAccessToken(c.getContextAccessToken()) } // Determines whether the serverless appears to be connected. The purpose is diff --git a/commands/triggers.go b/commands/triggers.go new file mode 100644 index 000000000..28bd69f7e --- /dev/null +++ b/commands/triggers.go @@ -0,0 +1,98 @@ +/* +Copyright 2018 The Doctl Authors All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package commands + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/digitalocean/doctl" + "github.com/digitalocean/doctl/commands/displayers" + "github.com/spf13/cobra" +) + +// Triggers generates the serverless 'triggers' subtree for addition to the doctl command +func Triggers() *Command { + cmd := &Command{ + Command: &cobra.Command{ + Use: "triggers", + Short: "Manage triggers associated with your functions", + Long: `When Functions are deployed by ` + "`" + `doctl serverless deploy` + "`" + `, they may have associated triggers. +The subcommands of ` + "`" + `doctl serverless triggers` + "`" + ` are used to list and inspect +triggers. Each trigger has an event source type, and invokes its associated function +when events from that source type occur. Currently, only the ` + "`" + `scheduler` + "`" + ` event source type is supported.`, + Aliases: []string{"trig"}, + Hidden: true, // trigger support uses APIs that are not yet universally available + }, + } + list := CmdBuilder(cmd, RunTriggersList, "list", "Lists your triggers", + `Use `+"`"+`doctl serverless triggers list`+"`"+` to list your triggers.`, + Writer, displayerType(&displayers.Triggers{})) + AddStringFlag(list, "function", "f", "", "list only triggers for the chosen function") + + CmdBuilder(cmd, RunTriggersGet, "get ", "Get the details for a trigger", + `Use `+"`"+`doctl serverless triggers get `+"`"+` for details about .`, + Writer) + + return cmd +} + +// RunTriggersList provides the logic for 'doctl sls trig list' +func RunTriggersList(c *CmdConfig) error { + if len(c.Args) > 0 { + return doctl.NewTooManyArgsErr(c.NS) + } + fcn, _ := c.Doit.GetString(c.NS, "function") + list, err := c.Serverless().ListTriggers(context.TODO(), fcn) + if err != nil { + return err + } + return c.Display(&displayers.Triggers{List: list}) +} + +// RunTriggersGet provides the logic for 'doctl sls trig get' +func RunTriggersGet(c *CmdConfig) error { + err := ensureOneArg(c) + if err != nil { + return err + } + trigger, err := c.Serverless().GetTrigger(context.TODO(), c.Args[0]) + if err != nil { + return err + } + json, err := json.MarshalIndent(&trigger, "", " ") + if err != nil { + return err + } + fmt.Fprintln(c.Out, string(json)) + return nil +} + +// cleanTriggers is the subroutine of undeploy that removes all the triggers of a namespace +func cleanTriggers(c *CmdConfig) error { + sls := c.Serverless() + ctx := context.TODO() + list, err := sls.ListTriggers(ctx, "") + if err != nil { + return err + } + for _, trig := range list { + err = sls.DeleteTrigger(ctx, trig.Name) + if err != nil { + return err + } + } + return nil +} diff --git a/commands/triggers_test.go b/commands/triggers_test.go new file mode 100644 index 000000000..d7576825f --- /dev/null +++ b/commands/triggers_test.go @@ -0,0 +1,147 @@ +/* +Copyright 2018 The Doctl Authors All rights reserved. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package commands + +import ( + "bytes" + "context" + "sort" + "testing" + + "github.com/digitalocean/doctl/do" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTriggersCommand(t *testing.T) { + cmd := Triggers() + assert.NotNil(t, cmd) + expected := []string{"get", "list"} + + names := []string{} + for _, c := range cmd.Commands() { + names = append(names, c.Name()) + } + + sort.Strings(expected) + sort.Strings(names) + assert.Equal(t, expected, names) +} + +func TestTriggersGet(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + buf := &bytes.Buffer{} + config.Out = buf + config.Args = append(config.Args, "aTrigger") + + theTrigger := do.ServerlessTrigger{ + Name: "firePoll1", + Function: "misc/pollStatus", + Cron: "5 * * * *", + Enabled: true, + LastRun: "_", + } + expect := `{ + "name": "firePoll1", + "function": "misc/pollStatus", + "is_enabled": true, + "cron": "5 * * * *", + "last_run_at": "_" +} +` + tm.serverless.EXPECT().GetTrigger(context.TODO(), "aTrigger").Return(theTrigger, nil) + + err := RunTriggersGet(config) + + require.NoError(t, err) + assert.Equal(t, expect, buf.String()) + }) +} + +func TestTriggersList(t *testing.T) { + theList := []do.ServerlessTrigger{ + { + Name: "fireGC", + Function: "misc/garbageCollect", + Cron: "* * * * *", + Enabled: true, + }, + { + Name: "firePoll1", + Function: "misc/pollStatus", + Cron: "5 * * * *", + Enabled: true, + }, + { + Name: "firePoll2", + Function: "misc/pollStatus", + Cron: "10 * * * *", + Enabled: false, + }, + } + tests := []struct { + name string + doctlFlags map[string]interface{} + expectedOutput string + listArg string + listResult []do.ServerlessTrigger + }{ + { + name: "simple list", + doctlFlags: map[string]interface{}{ + "no-header": "", + }, + listResult: theList, + expectedOutput: `fireGC * * * * * misc/garbageCollect true _ +firePoll1 5 * * * * misc/pollStatus true _ +firePoll2 10 * * * * misc/pollStatus false _ +`, + }, + { + name: "filtered list", + doctlFlags: map[string]interface{}{ + "function": "misc/pollStatus", + "no-header": "", + }, + listArg: "misc/pollStatus", + listResult: theList[1:], + expectedOutput: `firePoll1 5 * * * * misc/pollStatus true _ +firePoll2 10 * * * * misc/pollStatus false _ +`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + withTestClient(t, func(config *CmdConfig, tm *tcMocks) { + buf := &bytes.Buffer{} + config.Out = buf + if tt.doctlFlags != nil { + for k, v := range tt.doctlFlags { + if v == "" { + config.Doit.Set(config.NS, k, true) + } else { + config.Doit.Set(config.NS, k, v) + } + } + } + + tm.serverless.EXPECT().ListTriggers(context.TODO(), tt.listArg).Return(tt.listResult, nil) + + err := RunTriggersList(config) + require.NoError(t, err) + assert.Equal(t, tt.expectedOutput, buf.String()) + }) + }) + } +} diff --git a/do/mocks/ServerlessService.go b/do/mocks/ServerlessService.go index 31c4fc153..8fa4ccf33 100644 --- a/do/mocks/ServerlessService.go +++ b/do/mocks/ServerlessService.go @@ -95,6 +95,20 @@ func (mr *MockServerlessServiceMockRecorder) DeleteNamespace(arg0, arg1 interfac return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteNamespace", reflect.TypeOf((*MockServerlessService)(nil).DeleteNamespace), arg0, arg1) } +// DeleteTrigger mocks base method. +func (m *MockServerlessService) DeleteTrigger(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteTrigger", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteTrigger indicates an expected call of DeleteTrigger. +func (mr *MockServerlessServiceMockRecorder) DeleteTrigger(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteTrigger", reflect.TypeOf((*MockServerlessService)(nil).DeleteTrigger), arg0, arg1) +} + // Exec mocks base method. func (m *MockServerlessService) Exec(arg0 *exec.Cmd) (do.ServerlessOutput, error) { m.ctrl.T.Helper() @@ -110,6 +124,20 @@ func (mr *MockServerlessServiceMockRecorder) Exec(arg0 interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockServerlessService)(nil).Exec), arg0) } +// FireTrigger mocks base method. +func (m *MockServerlessService) FireTrigger(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FireTrigger", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// FireTrigger indicates an expected call of FireTrigger. +func (mr *MockServerlessServiceMockRecorder) FireTrigger(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FireTrigger", reflect.TypeOf((*MockServerlessService)(nil).FireTrigger), arg0, arg1) +} + // GetConnectedAPIHost mocks base method. func (m *MockServerlessService) GetConnectedAPIHost() (string, error) { m.ctrl.T.Helper() @@ -186,6 +214,21 @@ func (mr *MockServerlessServiceMockRecorder) GetServerlessNamespace(arg0 interfa return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetServerlessNamespace", reflect.TypeOf((*MockServerlessService)(nil).GetServerlessNamespace), arg0) } +// GetTrigger mocks base method. +func (m *MockServerlessService) GetTrigger(arg0 context.Context, arg1 string) (do.ServerlessTrigger, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetTrigger", arg0, arg1) + ret0, _ := ret[0].(do.ServerlessTrigger) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetTrigger indicates an expected call of GetTrigger. +func (mr *MockServerlessServiceMockRecorder) GetTrigger(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetTrigger", reflect.TypeOf((*MockServerlessService)(nil).GetTrigger), arg0, arg1) +} + // InstallServerless mocks base method. func (m *MockServerlessService) InstallServerless(arg0 string, arg1 bool) error { m.ctrl.T.Helper() @@ -244,6 +287,21 @@ func (mr *MockServerlessServiceMockRecorder) ListNamespaces(arg0 interface{}) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListNamespaces", reflect.TypeOf((*MockServerlessService)(nil).ListNamespaces), arg0) } +// ListTriggers mocks base method. +func (m *MockServerlessService) ListTriggers(arg0 context.Context, arg1 string) ([]do.ServerlessTrigger, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListTriggers", arg0, arg1) + ret0, _ := ret[0].([]do.ServerlessTrigger) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListTriggers indicates an expected call of ListTriggers. +func (mr *MockServerlessServiceMockRecorder) ListTriggers(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListTriggers", reflect.TypeOf((*MockServerlessService)(nil).ListTriggers), arg0, arg1) +} + // ReadCredentials mocks base method. func (m *MockServerlessService) ReadCredentials() (do.ServerlessCredentials, error) { m.ctrl.T.Helper() @@ -274,6 +332,21 @@ func (mr *MockServerlessServiceMockRecorder) ReadProject(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadProject", reflect.TypeOf((*MockServerlessService)(nil).ReadProject), arg0, arg1) } +// SetTriggerEnablement mocks base method. +func (m *MockServerlessService) SetTriggerEnablement(arg0 context.Context, arg1 string, arg2 bool) (do.ServerlessTrigger, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SetTriggerEnablement", arg0, arg1, arg2) + ret0, _ := ret[0].(do.ServerlessTrigger) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SetTriggerEnablement indicates an expected call of SetTriggerEnablement. +func (mr *MockServerlessServiceMockRecorder) SetTriggerEnablement(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTriggerEnablement", reflect.TypeOf((*MockServerlessService)(nil).SetTriggerEnablement), arg0, arg1, arg2) +} + // Stream mocks base method. func (m *MockServerlessService) Stream(arg0 *exec.Cmd) error { m.ctrl.T.Helper() diff --git a/do/serverless.go b/do/serverless.go index a0219d916..3d45b2890 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -15,6 +15,8 @@ package do import ( "context" + "crypto/sha1" + "encoding/hex" "encoding/json" "errors" "fmt" @@ -174,6 +176,27 @@ type ProjectMetadata struct { UnresolvedVariables []string `json:"unresolvedVariables,omitempty"` } +// ServerlessTriggerListResponse is the form returned by the list triggers API +type ServerlessTriggerListResponse struct { + Triggers []ServerlessTrigger `json:"Triggers,omitempty"` +} + +// ServerlessTriggerGetResponse is the form returned by the get trigger API +type ServerlessTriggerGetResponse struct { + Trigger ServerlessTrigger `json:"Trigger,omitempty"` +} + +// ServerlessTrigger is the form used in list and get responses by the triggers API +type ServerlessTrigger struct { + Name string `json:"name,omitempty"` + Function string `json:"function,omitempty"` + Enabled bool `json:"is_enabled"` + Cron string `json:"cron,omitempty"` + Created string `json:"created_at,omitempty"` + LastRun string `json:"last_run_at,omitempty"` + RequestBody interface{} `json:"body,omitempty"` +} + // ServerlessService is an interface for interacting with the sandbox plugin, // with the namespaces service, and with the serverless cluster controller. type ServerlessService interface { @@ -185,6 +208,9 @@ type ServerlessService interface { GetNamespace(context.Context, string) (ServerlessCredentials, error) CreateNamespace(context.Context, string, string) (ServerlessCredentials, error) DeleteNamespace(context.Context, string) error + ListTriggers(context.Context, string) ([]ServerlessTrigger, error) + GetTrigger(context.Context, string) (ServerlessTrigger, error) + DeleteTrigger(context.Context, string) error WriteCredentials(ServerlessCredentials) error ReadCredentials() (ServerlessCredentials, error) GetHostInfo(string) (ServerlessHostInfo, error) @@ -204,6 +230,7 @@ type serverlessService struct { credsDir string node string userAgent string + accessToken string client *godo.Client owClient *whisk.Client } @@ -212,7 +239,7 @@ const ( // Minimum required version of the sandbox plugin code. The first part is // the version of the incorporated Nimbella CLI and the second part is the // version of the bridge code in the sandbox plugin repository. - minServerlessVersion = "4.2.3-1.3.1" + minServerlessVersion = "4.2.6-1.3.1" // The version of nodejs to download alongsize the plugin download. nodeVersion = "v16.13.0" @@ -276,7 +303,7 @@ type ServerlessOutput struct { } // NewServerlessService returns a configured ServerlessService. -func NewServerlessService(client *godo.Client, usualServerlessDir string, credsToken string) ServerlessService { +func NewServerlessService(client *godo.Client, usualServerlessDir string, accessToken string) ServerlessService { nodeBin := "node" if runtime.GOOS == "windows" { nodeBin = "node.exe" @@ -288,6 +315,7 @@ func NewServerlessService(client *godo.Client, usualServerlessDir string, credsT if serverlessDir == "" { serverlessDir = usualServerlessDir } + credsToken := HashAccessToken(accessToken) return &serverlessService{ serverlessJs: filepath.Join(serverlessDir, "sandbox.js"), serverlessDir: serverlessDir, @@ -296,9 +324,19 @@ func NewServerlessService(client *godo.Client, usualServerlessDir string, credsT userAgent: fmt.Sprintf("doctl/%s serverless/%s", doctl.DoitVersion.String(), minServerlessVersion), client: client, owClient: nil, + accessToken: accessToken, } } +// HashAccessToken converts a DO access token string into a shorter but suitably random string +// via hashing. This is used to form part of the path for storing OpenWhisk credentials +func HashAccessToken(token string) string { + hasher := sha1.New() + hasher.Write([]byte(token)) + sha := hasher.Sum(nil) + return hex.EncodeToString(sha[:4]) +} + // InitWhisk is an on-demand initializer for the OpenWhisk client, called when that client // is needed. func initWhisk(s *serverlessService) error { @@ -480,7 +518,7 @@ func (s *serverlessService) InstallServerless(leafCredsDir string, upgrading boo func (s *serverlessService) Cmd(command string, args []string) (*exec.Cmd, error) { args = append([]string{s.serverlessJs, command}, args...) cmd := exec.Command(s.node, args...) - cmd.Env = append(os.Environ(), "NIMBELLA_DIR="+s.credsDir, "NIM_USER_AGENT="+s.userAgent) + cmd.Env = append(os.Environ(), "NIMBELLA_DIR="+s.credsDir, "NIM_USER_AGENT="+s.userAgent, "DO_API_KEY="+s.accessToken) // If DEBUG is specified, we need to open up stderr for that stream. The stdout stream // will continue to work for returning structured results. if os.Getenv("DEBUG") != "" { @@ -745,6 +783,112 @@ func (s *serverlessService) WriteProject(project ServerlessProject) (string, err return "", nil } +// ListTriggers lists the triggers in the connected namespace. If 'fcn' is a non-empty +// string it is assumed to be the package-qualified name of a function and only the triggers +// of that function are listed. If 'fcn' is empty all triggers are listed. +func (s *serverlessService) ListTriggers(ctx context.Context, fcn string) ([]ServerlessTrigger, error) { + empty := []ServerlessTrigger{} + err := s.CheckServerlessStatus(HashAccessToken(s.accessToken)) + if err != nil { + return empty, err + } + creds, err := s.ReadCredentials() + if err != nil { + return empty, err + } + path := "v2/functions/triggers/" + creds.Namespace + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return empty, err + } + decoded := new(ServerlessTriggerListResponse) + _, err = s.client.Do(ctx, req, decoded) + if err != nil { + return empty, err + } + triggers := decoded.Triggers + // The API does not filter by function; that is done here. + if fcn != "" { + filtered := []ServerlessTrigger{} + for _, trigger := range triggers { + if trigger.Function == fcn { + filtered = append(filtered, trigger) + } + } + triggers = filtered + } + return fixBaseDates(triggers), nil +} + +// fixBaseDates applies fixBaseDate to an array of triggers +func fixBaseDates(list []ServerlessTrigger) []ServerlessTrigger { + ans := []ServerlessTrigger{} + for _, trigger := range list { + ans = append(ans, fixBaseDate(trigger)) + } + return ans +} + +// fixBaseDate fixes up the LastRun field of a trigger that has never been run. +// It should properly contain blank but sometimes contain an encoding of the base date (a string +// starting with "000"). +func fixBaseDate(trigger ServerlessTrigger) ServerlessTrigger { + if strings.HasPrefix(trigger.LastRun, "000") { + trigger.LastRun = "_" + } + return trigger +} + +// GetTrigger gets the contents of a trigger for display +func (s *serverlessService) GetTrigger(ctx context.Context, name string) (ServerlessTrigger, error) { + empty := ServerlessTrigger{} + err := s.CheckServerlessStatus(HashAccessToken(s.accessToken)) + if err != nil { + return empty, err + } + creds, err := s.ReadCredentials() + if err != nil { + return empty, err + } + path := "v2/functions/trigger/" + creds.Namespace + "/" + name + req, err := s.client.NewRequest(ctx, http.MethodGet, path, nil) + if err != nil { + return empty, err + } + decoded := new(ServerlessTriggerGetResponse) + _, err = s.client.Do(ctx, req, decoded) + if err != nil { + return empty, err + } + return fixBaseDate(decoded.Trigger), nil +} + +// Delete Trigger deletes a trigger from the namespace (used when undeploying triggers explicitly, +// not part of a more general undeploy; when undeploying a function or the entire namespace we rely +// on the deployer to delete associated triggers). +func (s *serverlessService) DeleteTrigger(ctx context.Context, name string) error { + creds, err := s.ReadCredentials() + if err != nil { + return err + } + path := "v2/functions/trigger/" + creds.Namespace + "/" + name + req, err := s.client.NewRequest(ctx, http.MethodDelete, path, nil) + if err != nil { + return err + } + _, err = s.client.Do(ctx, req, nil) + return err +} + +// fixupCron detects the optional seconds field and removes it +func fixupCron(cron string) string { + parts := strings.Split(cron, " ") + if len(parts) == 6 { + return strings.Join(parts[1:], " ") + } + return cron +} + func readTopLevel(project *ServerlessProject) error { const ( Config = "project.yml" From 7ca75fc871b856bb2d3bfae8ecbe896b3025593f Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Thu, 8 Sep 2022 12:43:50 -0400 Subject: [PATCH 04/28] Eliminate plugin usage in 'doctl sls fn list' This completes the elimination of plugin usage in doctl sls fn and the functions.go source file. --- commands/functions.go | 45 ++++++---- commands/functions_test.go | 154 +++++++++++++++++++++++++++------- do/mocks/ServerlessService.go | 15 ++++ do/serverless.go | 18 ++++ 4 files changed, 186 insertions(+), 46 deletions(-) diff --git a/commands/functions.go b/commands/functions.go index fc53f7722..8800d33ce 100644 --- a/commands/functions.go +++ b/commands/functions.go @@ -19,6 +19,7 @@ import ( "errors" "fmt" "os" + "sort" "strings" "github.com/apache/openwhisk-client-go/whisk" @@ -250,34 +251,48 @@ func RunFunctionsList(c *CmdConfig) error { if argCount > 1 { return doctl.NewTooManyArgsErr(c.NS) } + var pkg string + if argCount == 1 { + pkg = c.Args[0] + } // Determine if '--count' is requested since we will use simple text output in that case. // Count is mutually exclusive with the global format flag. count, _ := c.Doit.GetBool(c.NS, flagCount) if count && c.Doit.IsSet("format") { return errors.New("the --count and --format flags are mutually exclusive") } - // Add JSON flag so we can control output format - if !count { - c.Doit.Set(c.NS, flagJSON, true) - } - output, err := RunServerlessExec(actionList, c, []string{flagCount, flagNameSort, flagNameName, flagJSON}, []string{flagLimit, flagSkip}) + // Retrieve other flags + skip, _ := c.Doit.GetInt(c.NS, flagSkip) + limit, _ := c.Doit.GetInt(c.NS, flagLimit) + nameSort, _ := c.Doit.GetBool(c.NS, flagNameSort) + nameName, _ := c.Doit.GetBool(c.NS, flagNameName) + // Get information from backend + list, err := c.Serverless().ListFunctions(pkg, skip, limit) if err != nil { return err } if count { - return c.PrintServerlessTextOutput(output) + plural := "s" + are := "are" + if len(list) == 1 { + plural = "" + are = "is" + } + fmt.Fprintf(c.Out, "There %s %d function%s in this namespace.\n", are, len(list), plural) + return nil } - // Reparse the output to use a more specific type, which can then be passed to the displayer - rawOutput, err := json.Marshal(output.Entity) - if err != nil { - return err + if nameSort || nameName { + sortFunctionList(list) } - var formatted []whisk.Action - err = json.Unmarshal(rawOutput, &formatted) - if err != nil { - return err + return c.Display(&displayers.Functions{Info: list}) +} + +// sortFunctionList performs a sort of a function list (by name) +func sortFunctionList(list []whisk.Action) { + isLess := func(i, j int) bool { + return list[i].Name < list[j].Name } - return c.Display(&displayers.Functions{Info: formatted}) + sort.Slice(list, isLess) } // consolidateParams accepts parameters from a file, the command line, or both, and consolidates all diff --git a/commands/functions_test.go b/commands/functions_test.go index 1b05a025d..fc671dd5d 100644 --- a/commands/functions_test.go +++ b/commands/functions_test.go @@ -16,7 +16,6 @@ package commands import ( "bytes" "os" - "os/exec" "sort" "testing" @@ -251,53 +250,124 @@ func TestFunctionsInvoke(t *testing.T) { func TestFunctionsList(t *testing.T) { tests := []struct { - name string - doctlArgs string - doctlFlags map[string]string - expectedNimArgs []string + name string + doctlFlags map[string]string + doctlArg string + skip int + limit int + expectedOutput string }{ { - name: "no flags or args", - expectedNimArgs: []string{"--json"}, + name: "no flags or args", + skip: 0, + limit: 0, + expectedOutput: `01/20 12:50:10 0.0.1 nodejs:14 daily/hello +01/20 12:50:20 0.0.2 nodejs:14 daily/goodbye +01/20 12:50:30 0.0.3 nodejs:14 sometimes/meAgain +`, }, { - name: "count flag", - doctlFlags: map[string]string{"count": ""}, - expectedNimArgs: []string{"--count"}, + name: "with package arg", + doctlArg: "daily", + skip: 0, + limit: 0, + expectedOutput: `01/20 12:50:10 0.0.1 nodejs:14 daily/hello +01/20 12:50:20 0.0.2 nodejs:14 daily/goodbye +`, }, { - name: "limit flag", - doctlFlags: map[string]string{"limit": "1"}, - expectedNimArgs: []string{"--json", "--limit", "1"}, + name: "count flag", + doctlFlags: map[string]string{"count": ""}, + skip: 0, + limit: 0, + expectedOutput: "There are 3 functions in this namespace.\n", }, { - name: "name flag", - doctlFlags: map[string]string{"name": ""}, - expectedNimArgs: []string{"--name", "--json"}, + name: "limit flag", + doctlFlags: map[string]string{"limit": "1"}, + skip: 0, + limit: 1, + expectedOutput: "01/20 12:50:10 0.0.1 nodejs:14 daily/hello\n", }, { - name: "name-sort flag", - doctlFlags: map[string]string{"name-sort": ""}, - expectedNimArgs: []string{"--name-sort", "--json"}, + name: "name flag", + doctlFlags: map[string]string{"name": ""}, + skip: 0, + limit: 0, + expectedOutput: `01/20 12:50:20 0.0.2 nodejs:14 daily/goodbye +01/20 12:50:10 0.0.1 nodejs:14 daily/hello +01/20 12:50:30 0.0.3 nodejs:14 sometimes/meAgain +`, }, { - name: "skip flag", - doctlFlags: map[string]string{"skip": "1"}, - expectedNimArgs: []string{"--json", "--skip", "1"}, + name: "name-sort flag", + doctlFlags: map[string]string{"name-sort": ""}, + skip: 0, + limit: 0, + expectedOutput: `01/20 12:50:20 0.0.2 nodejs:14 daily/goodbye +01/20 12:50:10 0.0.1 nodejs:14 daily/hello +01/20 12:50:30 0.0.3 nodejs:14 sometimes/meAgain +`, + }, + { + name: "skip flag", + doctlFlags: map[string]string{"skip": "1"}, + skip: 1, + limit: 0, + expectedOutput: `01/20 12:50:20 0.0.2 nodejs:14 daily/goodbye +01/20 12:50:30 0.0.3 nodejs:14 sometimes/meAgain +`, + }, + } + + theList := []whisk.Action{ + whisk.Action{ + Name: "hello", + Namespace: "theNamespace/daily", + Updated: 1662610000, + Version: "0.0.1", + Annotations: whisk.KeyValueArr{ + whisk.KeyValue{ + Key: "exec", + Value: "nodejs:14", + }, + }, + }, + whisk.Action{ + Name: "goodbye", + Namespace: "theNamespace/daily", + Updated: 1662620000, + Version: "0.0.2", + Annotations: whisk.KeyValueArr{ + whisk.KeyValue{ + Key: "exec", + Value: "nodejs:14", + }, + }, + }, + whisk.Action{ + Name: "meAgain", + Namespace: "theNamespace/sometimes", + Version: "0.0.3", + Updated: 1662630000, + Annotations: whisk.KeyValueArr{ + whisk.KeyValue{ + Key: "exec", + Value: "nodejs:14", + }, + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { - fakeCmd := &exec.Cmd{ - Stdout: config.Out, - } + buf := &bytes.Buffer{} + config.Out = buf - if tt.doctlArgs != "" { - config.Args = append(config.Args, tt.doctlArgs) + if tt.doctlArg != "" { + config.Args = append(config.Args, tt.doctlArg) } - if tt.doctlFlags != nil { for k, v := range tt.doctlFlags { if v == "" { @@ -307,14 +377,36 @@ func TestFunctionsList(t *testing.T) { } } } + config.Doit.Set(config.NS, "no-header", true) - tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil) - tm.serverless.EXPECT().Cmd("action/list", tt.expectedNimArgs).Return(fakeCmd, nil) - tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{}, nil) + answer := selectPackage(theList, tt.doctlArg)[tt.skip:] + if tt.limit != 0 { + answer = answer[0:tt.limit] + } + tm.serverless.EXPECT().ListFunctions(tt.doctlArg, tt.skip, tt.limit).Return(answer, nil) err := RunFunctionsList(config) require.NoError(t, err) + assert.Equal(t, tt.expectedOutput, buf.String()) }) }) } } + +// selectPackage is a testing support utility to trim a master list of functions by package membership +// Also ensures the array is copied, because the logic being tested may sort it in place. +func selectPackage(masterList []whisk.Action, pkg string) []whisk.Action { + if pkg == "" { + copiedList := make([]whisk.Action, len(masterList)) + copy(copiedList, masterList) + return copiedList + } + namespace := "theNamespace/" + pkg + answer := []whisk.Action{} + for _, action := range masterList { + if action.Namespace == namespace { + answer = append(answer, action) + } + } + return answer +} diff --git a/do/mocks/ServerlessService.go b/do/mocks/ServerlessService.go index 8fa4ccf33..f1d036ed2 100644 --- a/do/mocks/ServerlessService.go +++ b/do/mocks/ServerlessService.go @@ -272,6 +272,21 @@ func (mr *MockServerlessServiceMockRecorder) InvokeFunctionViaWeb(arg0, arg1 int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InvokeFunctionViaWeb", reflect.TypeOf((*MockServerlessService)(nil).InvokeFunctionViaWeb), arg0, arg1) } +// ListFunctions mocks base method. +func (m *MockServerlessService) ListFunctions(arg0 string, arg1, arg2 int) ([]whisk.Action, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListFunctions", arg0, arg1, arg2) + ret0, _ := ret[0].([]whisk.Action) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListFunctions indicates an expected call of ListFunctions. +func (mr *MockServerlessServiceMockRecorder) ListFunctions(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListFunctions", reflect.TypeOf((*MockServerlessService)(nil).ListFunctions), arg0, arg1, arg2) +} + // ListNamespaces mocks base method. func (m *MockServerlessService) ListNamespaces(arg0 context.Context) (do.NamespaceListResponse, error) { m.ctrl.T.Helper() diff --git a/do/serverless.go b/do/serverless.go index 3d45b2890..36b58ad27 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -217,6 +217,7 @@ type ServerlessService interface { CheckServerlessStatus(string) error InstallServerless(string, bool) error GetFunction(string, bool) (whisk.Action, []FunctionParameter, error) + ListFunctions(string, int, int) ([]whisk.Action, error) InvokeFunction(string, interface{}, bool, bool) (map[string]interface{}, error) InvokeFunctionViaWeb(string, map[string]interface{}) error GetConnectedAPIHost() (string, error) @@ -697,6 +698,23 @@ func (s *serverlessService) GetFunction(name string, fetchCode bool) (whisk.Acti return *action, parameters, nil } +// ListFunctions lists the functions of the connected namespace +func (s *serverlessService) ListFunctions(pkg string, skip int, limit int) ([]whisk.Action, error) { + err := initWhisk(s) + if err != nil { + return []whisk.Action{}, err + } + if limit == 0 { + limit = 30 + } + options := &whisk.ActionListOptions{ + Skip: skip, + Limit: limit, + } + list, _, err := s.owClient.Actions.List(pkg, options) + return list, err +} + // InvokeFunction invokes a function via POST with authentication func (s *serverlessService) InvokeFunction(name string, params interface{}, blocking bool, result bool) (map[string]interface{}, error) { var empty map[string]interface{} From 0c5d7109ef93e2cda57a403a07474a223db64822 Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Mon, 12 Sep 2022 07:52:58 -0400 Subject: [PATCH 05/28] Hidden flags enabling connection to dev clusters --- commands/serverless.go | 35 ++++++++++++++++++++++++++++++++--- do/mocks/ServerlessService.go | 15 +++++++++++++++ do/serverless.go | 22 ++++++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/commands/serverless.go b/commands/serverless.go index 134e5acc4..486370663 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -83,7 +83,7 @@ The install operation is long-running, and a network connection is required.`, CmdBuilder(cmd, RunServerlessUninstall, "uninstall", "Removes the serverless support", `Removes serverless support from `+"`"+`doctl`+"`", Writer) - CmdBuilder(cmd, RunServerlessConnect, "connect []", "Connects local serverless support to a functions namespace", + connect := CmdBuilder(cmd, RunServerlessConnect, "connect []", "Connects local serverless support to a functions namespace", `This command connects `+"`"+`doctl serverless`+"`"+` support to a functions namespace of your choice. The optional argument should be a (complete or partial) match to a namespace label or id. If there is no argument, all namespaces are matched. If the result is exactly one namespace, @@ -91,6 +91,14 @@ you are connected to it. If there are multiple namespaces, you have an opportun the one you want from a dialog. Use `+"`"+`doctl serverless namespaces`+"`"+` to create, delete, and list your namespaces.`, Writer) + AddBoolFlag(connect, "beta", "", false, "use beta features to connect when no namespace is specified") + connect.Flags().MarkHidden("beta") + // The apihost and auth flags will always be hidden. They support testing using doctl on clusters that are not in production + // and hence are unknown to the portal. + AddStringFlag(connect, "apihost", "", "", "") + AddStringFlag(connect, "auth", "", "", "") + connect.Flags().MarkHidden("apihost") + connect.Flags().MarkHidden("auth") status := CmdBuilder(cmd, RunServerlessStatus, "status", "Provide information about serverless support", `This command reports the status of serverless support and some details concerning its connected functions namespace. @@ -194,13 +202,34 @@ func RunServerlessConnect(c *CmdConfig) error { var ( err error ) + sls := c.Serverless() + + // Support the hidden capability to connect to non-production clusters to support various kinds of testing. + // The presence of 'auth' and 'apihost' flags trumps other parts of the syntax, but both must be present. + apihost, _ := c.Doit.GetString(c.NS, "apihost") + auth, _ := c.Doit.GetString(c.NS, "auth") + if len(apihost) > 0 && len(auth) > 0 { + namespace, err := sls.GetNamespaceFromCluster(apihost, auth) + if err != nil { + return err + } + credential := do.ServerlessCredential{Auth: auth} + creds := do.ServerlessCredentials{ + APIHost: apihost, + Namespace: namespace, + Credentials: map[string]map[string]do.ServerlessCredential{apihost: {namespace: credential}}, + } + return finishConnecting(sls, creds, "", c.Out) + } + if len(apihost) > 0 || len(auth) > 0 { + return fmt.Errorf("If either of 'apihost' or 'auth' is specified then both must be specified") + } + // Neither 'auth' nor 'apihost' was specified, so continue with other options. if len(c.Args) > 1 { return doctl.NewTooManyArgsErr(c.NS) } - sls := c.Serverless() - // Non-standard check for the connect command (only): it's ok to not be connected. err = sls.CheckServerlessStatus(hashAccessToken(c)) if err != nil && err != do.ErrServerlessNotConnected { diff --git a/do/mocks/ServerlessService.go b/do/mocks/ServerlessService.go index f1d036ed2..c6a498ccb 100644 --- a/do/mocks/ServerlessService.go +++ b/do/mocks/ServerlessService.go @@ -199,6 +199,21 @@ func (mr *MockServerlessServiceMockRecorder) GetNamespace(arg0, arg1 interface{} return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNamespace", reflect.TypeOf((*MockServerlessService)(nil).GetNamespace), arg0, arg1) } +// GetNamespaceFromCluster mocks base method. +func (m *MockServerlessService) GetNamespaceFromCluster(arg0, arg1 string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNamespaceFromCluster", arg0, arg1) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNamespaceFromCluster indicates an expected call of GetNamespaceFromCluster. +func (mr *MockServerlessServiceMockRecorder) GetNamespaceFromCluster(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNamespaceFromCluster", reflect.TypeOf((*MockServerlessService)(nil).GetNamespaceFromCluster), arg0, arg1) +} + // GetServerlessNamespace mocks base method. func (m *MockServerlessService) GetServerlessNamespace(arg0 context.Context) (do.ServerlessCredentials, error) { m.ctrl.T.Helper() diff --git a/do/serverless.go b/do/serverless.go index 36b58ad27..969065e63 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -206,6 +206,7 @@ type ServerlessService interface { GetServerlessNamespace(context.Context) (ServerlessCredentials, error) ListNamespaces(context.Context) (NamespaceListResponse, error) GetNamespace(context.Context, string) (ServerlessCredentials, error) + GetNamespaceFromCluster(string, string) (string, error) CreateNamespace(context.Context, string, string) (ServerlessCredentials, error) DeleteNamespace(context.Context, string) error ListTriggers(context.Context, string) ([]ServerlessTrigger, error) @@ -636,6 +637,27 @@ func (s *serverlessService) GetNamespace(ctx context.Context, name string) (Serv return executeNamespaceRequest(ctx, s, req) } +// GetNamespaceFromCluster obtains the namespace that uniquely owns a valid combination of API host and "auth" +// (uuid:key). This can be used to connect to clusters not known to the portal (e.g. dev clusters) or simply +// to check that credentials are valid. +func (s *serverlessService) GetNamespaceFromCluster(APIhost string, auth string) (string, error) { + // We do not use the shared client in serverlessService for this because it uses the stored + // credentials, not the passed ones. + config := whisk.Config{Host: APIhost, AuthToken: auth} + client, err := whisk.NewClient(http.DefaultClient, &config) + if err != nil { + return "", err + } + ns, _, err := client.Namespaces.List() + if err != nil { + return "", err + } + if len(ns) != 1 { + return "", fmt.Errorf("unexpected response when validating apihost and auth") + } + return ns[0].Name, nil +} + // CreateNamespace creates a new namespace and returns its credentials, given a label and region func (s *serverlessService) CreateNamespace(ctx context.Context, label string, region string) (ServerlessCredentials, error) { reqBody := newNamespaceRequest{Namespace: inputNamespace{Label: label, Region: region}} From c5a23612a3b3e8d3f7816c454e4a38bf796acfbc Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Mon, 26 Sep 2022 09:20:24 -0400 Subject: [PATCH 06/28] Fix unit test (date handling is timezone specific) --- commands/functions_test.go | 52 +++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/commands/functions_test.go b/commands/functions_test.go index fc671dd5d..7be08c4b9 100644 --- a/commands/functions_test.go +++ b/commands/functions_test.go @@ -17,7 +17,9 @@ import ( "bytes" "os" "sort" + "strings" "testing" + "time" "github.com/apache/openwhisk-client-go/whisk" "github.com/digitalocean/doctl/do" @@ -249,6 +251,16 @@ func TestFunctionsInvoke(t *testing.T) { } func TestFunctionsList(t *testing.T) { + // The displayer for function list is time-zone sensitive so we need to pre-convert the timestamps using the local + // time-zone to get exact matches. + timestamps := []int64{1662610000, 1662620000, 1662630000} + symbols := []string{"%DATE1%", "%DATE2%", "%DATE3%"} + dates := []string{ + time.UnixMilli(timestamps[0]).Format("01/02 03:04:05"), + time.UnixMilli(timestamps[1]).Format("01/02 03:04:05"), + time.UnixMilli(timestamps[2]).Format("01/02 03:04:05"), + } + tests := []struct { name string doctlFlags map[string]string @@ -261,9 +273,9 @@ func TestFunctionsList(t *testing.T) { name: "no flags or args", skip: 0, limit: 0, - expectedOutput: `01/20 12:50:10 0.0.1 nodejs:14 daily/hello -01/20 12:50:20 0.0.2 nodejs:14 daily/goodbye -01/20 12:50:30 0.0.3 nodejs:14 sometimes/meAgain + expectedOutput: `%DATE1% 0.0.1 nodejs:14 daily/hello +%DATE2% 0.0.2 nodejs:14 daily/goodbye +%DATE3% 0.0.3 nodejs:14 sometimes/meAgain `, }, { @@ -271,8 +283,8 @@ func TestFunctionsList(t *testing.T) { doctlArg: "daily", skip: 0, limit: 0, - expectedOutput: `01/20 12:50:10 0.0.1 nodejs:14 daily/hello -01/20 12:50:20 0.0.2 nodejs:14 daily/goodbye + expectedOutput: `%DATE1% 0.0.1 nodejs:14 daily/hello +%DATE2% 0.0.2 nodejs:14 daily/goodbye `, }, { @@ -287,16 +299,16 @@ func TestFunctionsList(t *testing.T) { doctlFlags: map[string]string{"limit": "1"}, skip: 0, limit: 1, - expectedOutput: "01/20 12:50:10 0.0.1 nodejs:14 daily/hello\n", + expectedOutput: "%DATE1% 0.0.1 nodejs:14 daily/hello\n", }, { name: "name flag", doctlFlags: map[string]string{"name": ""}, skip: 0, limit: 0, - expectedOutput: `01/20 12:50:20 0.0.2 nodejs:14 daily/goodbye -01/20 12:50:10 0.0.1 nodejs:14 daily/hello -01/20 12:50:30 0.0.3 nodejs:14 sometimes/meAgain + expectedOutput: `%DATE2% 0.0.2 nodejs:14 daily/goodbye +%DATE1% 0.0.1 nodejs:14 daily/hello +%DATE3% 0.0.3 nodejs:14 sometimes/meAgain `, }, { @@ -304,9 +316,9 @@ func TestFunctionsList(t *testing.T) { doctlFlags: map[string]string{"name-sort": ""}, skip: 0, limit: 0, - expectedOutput: `01/20 12:50:20 0.0.2 nodejs:14 daily/goodbye -01/20 12:50:10 0.0.1 nodejs:14 daily/hello -01/20 12:50:30 0.0.3 nodejs:14 sometimes/meAgain + expectedOutput: `%DATE2% 0.0.2 nodejs:14 daily/goodbye +%DATE1% 0.0.1 nodejs:14 daily/hello +%DATE3% 0.0.3 nodejs:14 sometimes/meAgain `, }, { @@ -314,8 +326,8 @@ func TestFunctionsList(t *testing.T) { doctlFlags: map[string]string{"skip": "1"}, skip: 1, limit: 0, - expectedOutput: `01/20 12:50:20 0.0.2 nodejs:14 daily/goodbye -01/20 12:50:30 0.0.3 nodejs:14 sometimes/meAgain + expectedOutput: `%DATE2% 0.0.2 nodejs:14 daily/goodbye +%DATE3% 0.0.3 nodejs:14 sometimes/meAgain `, }, } @@ -324,7 +336,7 @@ func TestFunctionsList(t *testing.T) { whisk.Action{ Name: "hello", Namespace: "theNamespace/daily", - Updated: 1662610000, + Updated: timestamps[0], Version: "0.0.1", Annotations: whisk.KeyValueArr{ whisk.KeyValue{ @@ -336,7 +348,7 @@ func TestFunctionsList(t *testing.T) { whisk.Action{ Name: "goodbye", Namespace: "theNamespace/daily", - Updated: 1662620000, + Updated: timestamps[1], Version: "0.0.2", Annotations: whisk.KeyValueArr{ whisk.KeyValue{ @@ -349,7 +361,7 @@ func TestFunctionsList(t *testing.T) { Name: "meAgain", Namespace: "theNamespace/sometimes", Version: "0.0.3", - Updated: 1662630000, + Updated: timestamps[2], Annotations: whisk.KeyValueArr{ whisk.KeyValue{ Key: "exec", @@ -387,7 +399,11 @@ func TestFunctionsList(t *testing.T) { err := RunFunctionsList(config) require.NoError(t, err) - assert.Equal(t, tt.expectedOutput, buf.String()) + expected := tt.expectedOutput + for i := range symbols { + expected = strings.Replace(expected, symbols[i], dates[i], 1) + } + assert.Equal(t, expected, buf.String()) }) }) } From 343ac512ca6c39af3f387b4b6f5ee44a6b3c7208 Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Mon, 26 Sep 2022 17:21:11 -0400 Subject: [PATCH 07/28] Eliminate call to auth/current via the plugin --- commands/functions_test.go | 6 +++--- commands/serverless.go | 21 +++++++++++---------- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/commands/functions_test.go b/commands/functions_test.go index 7be08c4b9..f356eb54f 100644 --- a/commands/functions_test.go +++ b/commands/functions_test.go @@ -333,7 +333,7 @@ func TestFunctionsList(t *testing.T) { } theList := []whisk.Action{ - whisk.Action{ + { Name: "hello", Namespace: "theNamespace/daily", Updated: timestamps[0], @@ -345,7 +345,7 @@ func TestFunctionsList(t *testing.T) { }, }, }, - whisk.Action{ + { Name: "goodbye", Namespace: "theNamespace/daily", Updated: timestamps[1], @@ -357,7 +357,7 @@ func TestFunctionsList(t *testing.T) { }, }, }, - whisk.Action{ + { Name: "meAgain", Namespace: "theNamespace/sometimes", Version: "0.0.3", diff --git a/commands/serverless.go b/commands/serverless.go index 486370663..0d0d09bfe 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -327,7 +327,8 @@ func finishConnecting(sls do.ServerlessService, creds do.ServerlessCredentials, // RunServerlessStatus gives a report on the status of the serverless (installed, up to date, connected) func RunServerlessStatus(c *CmdConfig) error { - status := c.Serverless().CheckServerlessStatus(hashAccessToken(c)) + sls := c.Serverless() + status := sls.CheckServerlessStatus(hashAccessToken(c)) if status == do.ErrServerlessNotInstalled { return status } @@ -350,20 +351,20 @@ func RunServerlessStatus(c *CmdConfig) error { } // Check the connected state more deeply (since this is a status command we want to // be more accurate; the connected check in checkServerlessStatus is lightweight and heuristic). - result, err := ServerlessExec(c, "auth/current", "--apihost", "--name") - if err != nil || len(result.Error) > 0 { - return do.ErrServerlessNotConnected + creds, err := sls.ReadCredentials() + if err != nil { + return nil } - if result.Entity == nil { - return errors.New("Could not retrieve information about the connected namespace") + auth := creds.Credentials[creds.APIHost][creds.Namespace].Auth + checkNS, err := sls.GetNamespaceFromCluster(creds.APIHost, auth) + if err != nil || checkNS != creds.Namespace { + return do.ErrServerlessNotConnected } - mapResult := result.Entity.(map[string]interface{}) - apiHost := mapResult["apihost"].(string) - fmt.Fprintf(c.Out, "Connected to functions namespace '%s' on API host '%s'\n", mapResult["name"], apiHost) + fmt.Fprintf(c.Out, "Connected to functions namespace '%s' on API host '%s'\n", creds.Namespace, creds.APIHost) fmt.Fprintf(c.Out, "Serverless software version is %s\n\n", do.GetMinServerlessVersion()) languages, _ := c.Doit.GetBool(c.NS, "languages") if languages { - return showLanguageInfo(c, apiHost) + return showLanguageInfo(c, creds.APIHost) } return nil } From 8a26142f83bb3f220ce44fa8b55e3f07c7cdb54c Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Mon, 26 Sep 2022 17:30:17 -0400 Subject: [PATCH 08/28] Commit changed test (screwed it up last time) --- commands/serverless_test.go | 54 ++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/commands/serverless_test.go b/commands/serverless_test.go index d1f9fe654..bcdc39e74 100644 --- a/commands/serverless_test.go +++ b/commands/serverless_test.go @@ -127,18 +127,20 @@ func TestServerlessStatusWhenConnected(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { buf := &bytes.Buffer{} config.Out = buf - fakeCmd := &exec.Cmd{ - Stdout: config.Out, - } tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil) - tm.serverless.EXPECT().Cmd("auth/current", []string{"--apihost", "--name"}).Return(fakeCmd, nil) - tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{ - Entity: map[string]interface{}{ - "name": "hello", - "apihost": "https://api.example.com", + tm.serverless.EXPECT().ReadCredentials().Return(do.ServerlessCredentials{ + APIHost: "https://api.example.com", + Namespace: "hello", + Credentials: map[string]map[string]do.ServerlessCredential{ + "https://api.example.com": { + "hello": do.ServerlessCredential{ + Auth: "here-are-some-credentials", + }, + }, }, }, nil) + tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", "here-are-some-credentials").Return("hello", nil) err := RunServerlessStatus(config) require.NoError(t, err) @@ -151,9 +153,6 @@ func TestServerlessStatusWithLanguages(t *testing.T) { buf := &bytes.Buffer{} config.Out = buf config.Doit.Set(config.NS, "languages", true) - fakeCmd := &exec.Cmd{ - Stdout: config.Out, - } fakeHostInfo := do.ServerlessHostInfo{ Runtimes: map[string][]do.ServerlessRuntime{ "go": { @@ -184,14 +183,20 @@ func TestServerlessStatusWithLanguages(t *testing.T) { ` tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil) - tm.serverless.EXPECT().Cmd("auth/current", []string{"--apihost", "--name"}).Return(fakeCmd, nil) - tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{ - Entity: map[string]interface{}{ - "name": "hello", - "apihost": "https://api.example.com", + tm.serverless.EXPECT().ReadCredentials().Return(do.ServerlessCredentials{ + APIHost: "https://api.example.com", + Namespace: "hello", + Credentials: map[string]map[string]do.ServerlessCredential{ + "https://api.example.com": { + "hello": do.ServerlessCredential{ + Auth: "here-are-some-credentials", + }, + }, }, }, nil) + tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", "here-are-some-credentials").Return("hello", nil) tm.serverless.EXPECT().GetHostInfo("https://api.example.com").Return(fakeHostInfo, nil) + err := RunServerlessStatus(config) require.NoError(t, err) assert.Contains(t, buf.String(), expectedDisplay) @@ -200,15 +205,20 @@ func TestServerlessStatusWithLanguages(t *testing.T) { func TestServerlessStatusWhenNotConnected(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { - fakeCmd := &exec.Cmd{ - Stdout: config.Out, - } tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil) - tm.serverless.EXPECT().Cmd("auth/current", []string{"--apihost", "--name"}).Return(fakeCmd, nil) - tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{ - Error: "403", + tm.serverless.EXPECT().ReadCredentials().Return(do.ServerlessCredentials{ + APIHost: "https://api.example.com", + Namespace: "hello", + Credentials: map[string]map[string]do.ServerlessCredential{ + "https://api.example.com": { + "hello": do.ServerlessCredential{ + Auth: "here-are-some-credentials", + }, + }, + }, }, nil) + tm.serverless.EXPECT().GetNamespaceFromCluster("https://api.example.com", "here-are-some-credentials").Return("not-hello", errors.New("an error")) err := RunServerlessStatus(config) require.Error(t, err) From 407f28ae3a540413cc677f2e3636362a09e8b9a8 Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Wed, 28 Sep 2022 07:26:08 -0400 Subject: [PATCH 09/28] Remove accidental re-introduction of --beta flag I believe this happened in merge conflict resolution during the recent rebase. --- commands/serverless.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/commands/serverless.go b/commands/serverless.go index 0d0d09bfe..44fb43a7b 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -91,8 +91,6 @@ you are connected to it. If there are multiple namespaces, you have an opportun the one you want from a dialog. Use `+"`"+`doctl serverless namespaces`+"`"+` to create, delete, and list your namespaces.`, Writer) - AddBoolFlag(connect, "beta", "", false, "use beta features to connect when no namespace is specified") - connect.Flags().MarkHidden("beta") // The apihost and auth flags will always be hidden. They support testing using doctl on clusters that are not in production // and hence are unknown to the portal. AddStringFlag(connect, "apihost", "", "", "") From bd979f2b11c859f307e6ff6c9dc97c37cb6018ba Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Thu, 29 Sep 2022 10:17:05 -0400 Subject: [PATCH 10/28] Bump min nim version to incorporate small bug fix Affects what happens when a failure occurs in the middle of deleting functions and triggers together. --- do/serverless.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/do/serverless.go b/do/serverless.go index 969065e63..9601b227e 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -241,7 +241,7 @@ const ( // Minimum required version of the sandbox plugin code. The first part is // the version of the incorporated Nimbella CLI and the second part is the // version of the bridge code in the sandbox plugin repository. - minServerlessVersion = "4.2.6-1.3.1" + minServerlessVersion = "4.2.7-1.3.1" // The version of nodejs to download alongsize the plugin download. nodeVersion = "v16.13.0" From 64028da52817d6138a4f44a2fd92bac3cc253d34 Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Tue, 11 Oct 2022 11:26:26 -0400 Subject: [PATCH 11/28] Avoid plugin in serverless activations [ get | result ] (#1270) * WIP for converting activations to direct OW flows * Finish recoding 'activations get' in native doctl Tests still to come * Convert the support for sls actv result Tests not converted yet * Generate latet mocks * Fix some comments * Use more realistic timestampes * Revise tests for new paths. Still no output check * Tests are now doing meaningful output comparison Fixed some bugs found once tests were really effective --- commands/activations.go | 210 ++++++++++++++++++- commands/activations_test.go | 366 ++++++++++++++++++++++++++++------ commands/functions_test.go | 2 +- do/mocks/ServerlessService.go | 77 ++++--- do/serverless.go | 64 +++++- 5 files changed, 622 insertions(+), 97 deletions(-) diff --git a/commands/activations.go b/commands/activations.go index 08023933c..d621fd87e 100644 --- a/commands/activations.go +++ b/commands/activations.go @@ -14,10 +14,25 @@ limitations under the License. package commands import ( + "encoding/json" + "fmt" + "io" + "regexp" + "time" + + "github.com/apache/openwhisk-client-go/whisk" "github.com/digitalocean/doctl" + "github.com/digitalocean/doctl/commands/charm/text" "github.com/spf13/cobra" ) +// ShownActivation is what is actually shown as an activation ... it adds a date field which is a human-readable +// version of the start field. +type ShownActivation struct { + whisk.Activation + Date string `json:"date,omitempty"` +} + // Activations generates the serverless 'activations' subtree for addition to the doctl command func Activations() *Command { cmd := &Command{ @@ -85,12 +100,146 @@ func RunActivationsGet(c *CmdConfig) error { if argCount > 1 { return doctl.NewTooManyArgsErr(c.NS) } - replaceFunctionWithAction(c) - output, err := RunServerlessExec(activationGet, c, []string{flagLast, flagLogs, flagResult, flagQuiet}, []string{flagSkip, flagAction}) - if err != nil { - return err + var id string + if argCount > 0 { + id = c.Args[0] + } + logsFlag, _ := c.Doit.GetBool(c.NS, flagLogs) + resultFlag, _ := c.Doit.GetBool(c.NS, flagResult) + quietFlag, _ := c.Doit.GetBool(c.NS, flagQuiet) + // There is also a 'last' flag, which is historical. Since it's behavior is the + // default, and the past convention was to ignore it if a single id was specified, + // (rather than indicating an error), it is completely ignored here but accepted for + // backward compatibility. In the aio implementation (incorporated in nim, previously + // incorporated here), the flag had to be set explicitly (rather than just implied) in + // order to get a "banner" (additional informational line) when requesting logs or + // result only. This seems pointless and we will always display the banner for a + // single logs or result output unless --quiet is specified. + skipFlag, _ := c.Doit.GetInt(c.NS, flagSkip) // 0 if not there + functionFlag, _ := c.Doit.GetString(c.NS, flagFunction) + sls := c.Serverless() + if id == "" { + // If there is no id, the convention is to retrieve the last activation, subject to possible + // filtering or skipping + options := whisk.ActivationListOptions{Limit: 1, Skip: skipFlag} + if functionFlag != "" { + options.Name = functionFlag + } + list, err := sls.ListActivations(options) + if err != nil { + return err + } + if len(list) == 0 { + return fmt.Errorf("no activations were returned") + } + activation := list[0] + id = activation.ActivationID + if !quietFlag && (logsFlag || resultFlag) { + makeBanner(c.Out, activation) + } + } + if logsFlag { + activation, err := sls.GetActivationLogs(id) + if err != nil { + return err + } + if len(activation.Logs) == 0 { + return fmt.Errorf("no logs available") + } + printLogs(c.Out, true, activation) + } else if resultFlag { + response, err := sls.GetActivationResult(id) + if err != nil { + return err + } + if response.Result == nil { + return fmt.Errorf("no result available") + } + printResult(c.Out, response.Result) + } else { + activation, err := sls.GetActivation(id) + if err != nil { + return err + } + printActivationRecord(c.Out, activation) + } + return nil +} + +// makeBanner is a subroutine that prints a single "banner" line summarizing information about an +// activation. This is done in conjunction with a request to print only logs or only the result, since, +// otherwise, it is difficult to know what activation is being talked about. +func makeBanner(writer io.Writer, activation whisk.Activation) { + end := time.UnixMilli(activation.End).Format("01/02 03:04:05") + init := text.NewStyled("=== ").Muted() + body := fmt.Sprintf("%s %s %s %s:%s", activation.ActivationID, statusToString(activation.StatusCode), + end, activation.Name, activation.Version) + msg := text.NewStyled(body).Highlight() + fmt.Fprintln(writer, init.String()+msg.String()) +} + +// statusToString converts numeric status codes to typical string +func statusToString(statusCode int) string { + switch statusCode { + case 0: + return "success" + case 1: + return "application error" + case 2: + return "developer error" + case 3: + return "system error" + default: + return "??" + } +} + +// printLog is a subroutine for printing just the logs of an activation +func printLogs(writer io.Writer, strip bool, activation whisk.Activation) { + for _, log := range activation.Logs { + if strip { + log = stripLog(log) + } + fmt.Fprintln(writer, log) } - return c.PrintServerlessTextOutput(output) +} + +// dtsRegex is a regular expression that matches the prefix of some activation log entries. +// It is used by stripLog to remove that prefix +var dtsRegex = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:.*: `) + +// stripLog strips the prefix from log entries +func stripLog(entry string) string { + // `2019-10-11T19:08:57.298Z stdout: login-success :: { code: ...` + // should become: `login-success :: { code: ...` + found := dtsRegex.FindString(entry) + return entry[len(found):] +} + +// printResult is a subroutine for printing just the result of an activation +func printResult(writer io.Writer, result *whisk.Result) { + var msg string + bytes, err := json.MarshalIndent(result, "", " ") + if err == nil { + msg = string(bytes) + } else { + msg = "" + } + fmt.Fprintln(writer, msg) +} + +// printActivationRecord is a subroutine for printing the entire activation record +func printActivationRecord(writer io.Writer, activation whisk.Activation) { + var msg string + date := time.UnixMilli(activation.Start).Format("2006-01-02 03:04:05") + toShow := ShownActivation{Activation: activation, Date: date} + bytes, err := json.MarshalIndent(toShow, "", " ") + if err == nil { + msg = string(bytes) + } else { + msg = "" + } + fmt.Fprintln(writer, msg) } // RunActivationsList supports the 'activations list' command @@ -142,12 +291,53 @@ func RunActivationsResult(c *CmdConfig) error { if argCount > 1 { return doctl.NewTooManyArgsErr(c.NS) } - replaceFunctionWithAction(c) - output, err := RunServerlessExec(activationResult, c, []string{flagLast, flagQuiet}, []string{flagLimit, flagSkip, flagAction}) - if err != nil { - return err + var id string + if argCount > 0 { + id = c.Args[0] } - return c.PrintServerlessTextOutput(output) + quietFlag, _ := c.Doit.GetBool(c.NS, flagQuiet) + skipFlag, _ := c.Doit.GetInt(c.NS, flagSkip) // 0 if not there + limitFlag, _ := c.Doit.GetInt(c.NS, flagLimit) // 0 if not there + functionFlag, _ := c.Doit.GetString(c.NS, flagFunction) + limit := 1 + if limitFlag > 200 { + limit = 200 + } else if limitFlag > 0 { + limit = limitFlag + } + options := whisk.ActivationListOptions{Limit: limit, Skip: skipFlag} + sls := c.Serverless() + var activations []whisk.Activation + if id == "" { + if functionFlag != "" { + options.Name = functionFlag + } + actv, err := sls.ListActivations(options) + if err != nil { + return err + } + activations = actv + } else { + activations = []whisk.Activation{ + {ActivationID: id}, + } + } + reversed := make([]whisk.Activation, len(activations)) + for i, activation := range activations { + response, err := sls.GetActivationResult(activation.ActivationID) + if err != nil { + return err + } + activation.Result = response.Result + reversed[len(activations)-i-1] = activation + } + for _, activation := range reversed { + if !quietFlag && id == "" { + makeBanner(c.Out, activation) + } + printResult(c.Out, activation.Result) + } + return nil } // replaceFunctionWithAction detects that --function was specified and renames it to --action (which is what nim diff --git a/commands/activations_test.go b/commands/activations_test.go index 53fb9a81f..f463bdf5b 100644 --- a/commands/activations_test.go +++ b/commands/activations_test.go @@ -14,10 +14,14 @@ limitations under the License. package commands import ( + "bytes" "os/exec" "sort" + "strings" "testing" + "time" + "github.com/apache/openwhisk-client-go/whisk" "github.com/digitalocean/doctl/do" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -38,55 +42,238 @@ func TestActivationsCommand(t *testing.T) { assert.Equal(t, expected, names) } +// theActivations is the set of activation assumed to be present, used to mock whisk API behavior +var theActivations = []whisk.Activation{ + { + Namespace: "my-namespace", + Name: "hello1", + Version: "0.0.1", + ActivationID: "activation-1", + Start: 1664538810000, + End: 1664538820000, + Response: whisk.Response{ + Status: "success", + StatusCode: 0, + Success: true, + Result: &whisk.Result{ + "body": "Hello stranger!", + }, + }, + Logs: []string{ + "2022-09-30T11:53:50.567914279Z stdout: Hello stranger!", + }, + }, + { + Namespace: "my-namespace", + Name: "hello2", + Version: "0.0.2", + ActivationID: "activation-2", + Start: 1664538830000, + End: 1664538840000, + Response: whisk.Response{ + Status: "success", + StatusCode: 0, + Success: true, + Result: &whisk.Result{ + "body": "Hello Archie!", + }, + }, + Logs: []string{ + "2022-09-30T11:53:50.567914279Z stdout: Hello Archie!", + }, + }, + { + Namespace: "my-namespace", + Name: "hello3", + Version: "0.0.3", + ActivationID: "activation-3", + Start: 1664538850000, + End: 1664538860000, + Response: whisk.Response{ + Result: &whisk.Result{ + "error": "Missing main/no code to execute.", + }, + Status: "developer error", + Success: false, + }, + }, +} + +// Timestamps in the activations are converted to dates using local time so, to make this test capable of running +// in any timezone, we need to abstract things a bit. Following the conventions in aio, the banner dates are computed +// from End and the activation record dates from Start. +var ( + timestamps = []int64{1664538810000, 1664538820000, 1664538830000, 1664538840000, 1664538850000, 1664538860000} + actvSymbols = []string{"%START1%", "%START2%", "%START3%"} + actvDates = []string{ + time.UnixMilli(timestamps[0]).Format("2006-01-02 03:04:05"), + time.UnixMilli(timestamps[2]).Format("2006-01-02 03:04:05"), + time.UnixMilli(timestamps[4]).Format("2006-01-02 03:04:05"), + } + bannerSymbols = []string{"%END1%", "%END2%", "%END3%"} + bannerDates = []string{ + time.UnixMilli(timestamps[1]).Format("01/02 03:04:05"), + time.UnixMilli(timestamps[3]).Format("01/02 03:04:05"), + time.UnixMilli(timestamps[5]).Format("01/02 03:04:05"), + } +) + +// convertDates operates on the expected output (containing symbols) to substitute actual dates +func convertDates(expected string) string { + for i, symbol := range actvSymbols { + expected = strings.Replace(expected, symbol, actvDates[i], 1) + } + for i, symbol := range bannerSymbols { + expected = strings.Replace(expected, symbol, bannerDates[i], 1) + } + return expected +} + +// findActivation finds the activation with a given id (in these tests, assumed to be present) +func findActivation(id string) whisk.Activation { + for _, activation := range theActivations { + if activation.ActivationID == id { + return activation + } + } + // Should not happen + panic("could not find " + id) +} + func TestActivationsGet(t *testing.T) { tests := []struct { - name string - doctlArgs string - doctlFlags map[string]string - expectedNimArgs []string + name string + doctlArgs string + doctlFlags map[string]string + listOptions whisk.ActivationListOptions + expectedOutput string }{ { - name: "no flags with ID", - doctlArgs: "activationid", - expectedNimArgs: []string{"activationid"}, + name: "no flags with ID", + doctlArgs: "activation-2", + expectedOutput: `{ + "namespace": "my-namespace", + "name": "hello2", + "version": "0.0.2", + "subject": "", + "activationId": "activation-2", + "start": 1664538830000, + "end": 1664538840000, + "duration": 0, + "statusCode": 0, + "response": { + "status": "success", + "statusCode": 0, + "success": true, + "result": { + "body": "Hello Archie!" + } + }, + "logs": [ + "2022-09-30T11:53:50.567914279Z stdout: Hello Archie!" + ], + "annotations": null, + "date": "%START2%" +} +`, }, { - name: "no flags or args", - expectedNimArgs: []string{}, + name: "no flags or args", + listOptions: whisk.ActivationListOptions{Limit: 1}, + expectedOutput: `{ + "namespace": "my-namespace", + "name": "hello1", + "version": "0.0.1", + "subject": "", + "activationId": "activation-1", + "start": 1664538810000, + "end": 1664538820000, + "duration": 0, + "statusCode": 0, + "response": { + "status": "success", + "statusCode": 0, + "success": true, + "result": { + "body": "Hello stranger!" + } + }, + "logs": [ + "2022-09-30T11:53:50.567914279Z stdout: Hello stranger!" + ], + "annotations": null, + "date": "%START1%" +} +`, }, { - name: "last flag", - doctlArgs: "activationid", - doctlFlags: map[string]string{"last": ""}, - expectedNimArgs: []string{"activationid", "--last"}, + name: "logs flag", + doctlFlags: map[string]string{"logs": ""}, + listOptions: whisk.ActivationListOptions{Limit: 1}, + expectedOutput: `=== activation-1 success %END1% hello1:0.0.1 +Hello stranger! +`, }, { - name: "logs flag", - doctlArgs: "activationid", - doctlFlags: map[string]string{"logs": ""}, - expectedNimArgs: []string{"activationid", "--logs"}, + name: "result flag", + doctlFlags: map[string]string{"result": ""}, + listOptions: whisk.ActivationListOptions{Limit: 1}, + expectedOutput: `=== activation-1 success %END1% hello1:0.0.1 +{ + "body": "Hello stranger!" +} +`, }, { - name: "skip flag", - doctlArgs: "activationid", - doctlFlags: map[string]string{"skip": "10"}, - expectedNimArgs: []string{"activationid", "--skip", "10"}, + name: "skip flag", + doctlFlags: map[string]string{"skip": "2"}, + listOptions: whisk.ActivationListOptions{Limit: 1, Skip: 2}, + expectedOutput: `{ + "namespace": "my-namespace", + "name": "hello3", + "version": "0.0.3", + "subject": "", + "activationId": "activation-3", + "start": 1664538850000, + "end": 1664538860000, + "duration": 0, + "statusCode": 0, + "response": { + "status": "developer error", + "statusCode": 0, + "success": false, + "result": { + "error": "Missing main/no code to execute." + } + }, + "logs": null, + "annotations": null, + "date": "%START3%" +} +`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { - fakeCmd := &exec.Cmd{ - Stdout: config.Out, - } + buf := &bytes.Buffer{} + config.Out = buf if tt.doctlArgs != "" { config.Args = append(config.Args, tt.doctlArgs) } + logs := false + result := false if tt.doctlFlags != nil { for k, v := range tt.doctlFlags { + if k == "logs" { + logs = true + } + if k == "result" { + result = true + } if v == "" { config.Doit.Set(config.NS, k, true) } else { @@ -95,12 +282,29 @@ func TestActivationsGet(t *testing.T) { } } - tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil) - tm.serverless.EXPECT().Cmd("activation/get", tt.expectedNimArgs).Return(fakeCmd, nil) - tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{}, nil) + id := tt.doctlArgs + var activation whisk.Activation + if id != "" { + activation = findActivation(id) + } + if tt.listOptions.Limit > 0 { + fst := tt.listOptions.Skip + lnth := tt.listOptions.Limit + fst + tm.serverless.EXPECT().ListActivations(tt.listOptions).Return(theActivations[fst:lnth], nil) + activation = theActivations[fst] + id = activation.ActivationID + } + if logs { + tm.serverless.EXPECT().GetActivationLogs(id).Return(activation, nil) + } else if result { + tm.serverless.EXPECT().GetActivationResult(id).Return(activation.Response, nil) + } else { + tm.serverless.EXPECT().GetActivation(id).Return(activation, nil) + } err := RunActivationsGet(config) require.NoError(t, err) + assert.Equal(t, convertDates(tt.expectedOutput), buf.String()) }) }) } @@ -276,48 +480,73 @@ func TestActivationsLogs(t *testing.T) { func TestActivationsResult(t *testing.T) { tests := []struct { - name string - doctlArgs string - doctlFlags map[string]string - expectedNimArgs []string + name string + doctlArgs string + doctlFlags map[string]string + listOptions whisk.ActivationListOptions + expectedOutput string }{ { - name: "no flags or args", - expectedNimArgs: []string{}, - }, - { - name: "no flags with ID", - doctlArgs: "activationid", - expectedNimArgs: []string{"activationid"}, + name: "no flags or args", + listOptions: whisk.ActivationListOptions{Limit: 1}, + expectedOutput: `=== activation-1 success %END1% hello1:0.0.1 +{ + "body": "Hello stranger!" +} +`, }, { - name: "last flag", - doctlFlags: map[string]string{"last": ""}, - expectedNimArgs: []string{"--last"}, + name: "no flags with ID", + doctlArgs: "activation-2", + expectedOutput: `{ + "body": "Hello Archie!" +} +`, }, { - name: "limit flag", - doctlFlags: map[string]string{"limit": "10"}, - expectedNimArgs: []string{"--limit", "10"}, + name: "limit flag", + doctlFlags: map[string]string{"limit": "10"}, + listOptions: whisk.ActivationListOptions{Limit: 10}, + expectedOutput: `=== activation-3 success %END3% hello3:0.0.3 +{ + "error": "Missing main/no code to execute." +} +=== activation-2 success %END2% hello2:0.0.2 +{ + "body": "Hello Archie!" +} +=== activation-1 success %END1% hello1:0.0.1 +{ + "body": "Hello stranger!" +} +`, }, { - name: "quiet flag", - doctlFlags: map[string]string{"quiet": ""}, - expectedNimArgs: []string{"--quiet"}, + name: "quiet flag", + doctlFlags: map[string]string{"quiet": ""}, + listOptions: whisk.ActivationListOptions{Limit: 1}, + expectedOutput: `{ + "body": "Hello stranger!" +} +`, }, { - name: "skip flag", - doctlFlags: map[string]string{"skip": "1"}, - expectedNimArgs: []string{"--skip", "1"}, + name: "skip flag", + doctlFlags: map[string]string{"skip": "1"}, + listOptions: whisk.ActivationListOptions{Limit: 1, Skip: 1}, + expectedOutput: `=== activation-2 success %END2% hello2:0.0.2 +{ + "body": "Hello Archie!" +} +`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { - fakeCmd := &exec.Cmd{ - Stdout: config.Out, - } + buf := &bytes.Buffer{} + config.Out = buf if tt.doctlArgs != "" { config.Args = append(config.Args, tt.doctlArgs) @@ -333,12 +562,35 @@ func TestActivationsResult(t *testing.T) { } } - tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil) - tm.serverless.EXPECT().Cmd("activation/result", tt.expectedNimArgs).Return(fakeCmd, nil) - tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{}, nil) - + var ids []string + var activations []whisk.Activation + if tt.doctlArgs != "" { + ids = []string{tt.doctlArgs} + activations = []whisk.Activation{findActivation(ids[0])} + } + limit := tt.listOptions.Limit + if limit > 0 { + if limit > len(theActivations) { + limit = len(theActivations) + } + fst := tt.listOptions.Skip + lnth := limit + fst + // The command reverses the returned list in asking for the responses + chosen := theActivations[fst:lnth] + ids = make([]string, len(chosen)) + activations = make([]whisk.Activation, len(chosen)) + for i, activation := range chosen { + activations[len(chosen)-i-1] = activation + ids[len(chosen)-i-1] = activation.ActivationID + } + tm.serverless.EXPECT().ListActivations(tt.listOptions).Return(chosen, nil) + } + for i, id := range ids { + tm.serverless.EXPECT().GetActivationResult(id).Return(activations[i].Response, nil) + } err := RunActivationsResult(config) require.NoError(t, err) + assert.Equal(t, convertDates(tt.expectedOutput), buf.String()) }) }) } diff --git a/commands/functions_test.go b/commands/functions_test.go index f356eb54f..eb86d05fd 100644 --- a/commands/functions_test.go +++ b/commands/functions_test.go @@ -253,7 +253,7 @@ func TestFunctionsInvoke(t *testing.T) { func TestFunctionsList(t *testing.T) { // The displayer for function list is time-zone sensitive so we need to pre-convert the timestamps using the local // time-zone to get exact matches. - timestamps := []int64{1662610000, 1662620000, 1662630000} + timestamps := []int64{1664538810000, 1664538820000, 1664538830000} symbols := []string{"%DATE1%", "%DATE2%", "%DATE3%"} dates := []string{ time.UnixMilli(timestamps[0]).Format("01/02 03:04:05"), diff --git a/do/mocks/ServerlessService.go b/do/mocks/ServerlessService.go index c6a498ccb..68a24429c 100644 --- a/do/mocks/ServerlessService.go +++ b/do/mocks/ServerlessService.go @@ -124,18 +124,49 @@ func (mr *MockServerlessServiceMockRecorder) Exec(arg0 interface{}) *gomock.Call return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exec", reflect.TypeOf((*MockServerlessService)(nil).Exec), arg0) } -// FireTrigger mocks base method. -func (m *MockServerlessService) FireTrigger(arg0 context.Context, arg1 string) error { +// GetActivation mocks base method. +func (m *MockServerlessService) GetActivation(arg0 string) (whisk.Activation, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "FireTrigger", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 + ret := m.ctrl.Call(m, "GetActivation", arg0) + ret0, _ := ret[0].(whisk.Activation) + ret1, _ := ret[1].(error) + return ret0, ret1 } -// FireTrigger indicates an expected call of FireTrigger. -func (mr *MockServerlessServiceMockRecorder) FireTrigger(arg0, arg1 interface{}) *gomock.Call { +// GetActivation indicates an expected call of GetActivation. +func (mr *MockServerlessServiceMockRecorder) GetActivation(arg0 interface{}) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FireTrigger", reflect.TypeOf((*MockServerlessService)(nil).FireTrigger), arg0, arg1) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActivation", reflect.TypeOf((*MockServerlessService)(nil).GetActivation), arg0) +} + +// GetActivationLogs mocks base method. +func (m *MockServerlessService) GetActivationLogs(arg0 string) (whisk.Activation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActivationLogs", arg0) + ret0, _ := ret[0].(whisk.Activation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActivationLogs indicates an expected call of GetActivationLogs. +func (mr *MockServerlessServiceMockRecorder) GetActivationLogs(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActivationLogs", reflect.TypeOf((*MockServerlessService)(nil).GetActivationLogs), arg0) +} + +// GetActivationResult mocks base method. +func (m *MockServerlessService) GetActivationResult(arg0 string) (whisk.Response, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActivationResult", arg0) + ret0, _ := ret[0].(whisk.Response) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActivationResult indicates an expected call of GetActivationResult. +func (mr *MockServerlessServiceMockRecorder) GetActivationResult(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActivationResult", reflect.TypeOf((*MockServerlessService)(nil).GetActivationResult), arg0) } // GetConnectedAPIHost mocks base method. @@ -287,6 +318,21 @@ func (mr *MockServerlessServiceMockRecorder) InvokeFunctionViaWeb(arg0, arg1 int return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InvokeFunctionViaWeb", reflect.TypeOf((*MockServerlessService)(nil).InvokeFunctionViaWeb), arg0, arg1) } +// ListActivations mocks base method. +func (m *MockServerlessService) ListActivations(arg0 whisk.ActivationListOptions) ([]whisk.Activation, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListActivations", arg0) + ret0, _ := ret[0].([]whisk.Activation) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ListActivations indicates an expected call of ListActivations. +func (mr *MockServerlessServiceMockRecorder) ListActivations(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListActivations", reflect.TypeOf((*MockServerlessService)(nil).ListActivations), arg0) +} + // ListFunctions mocks base method. func (m *MockServerlessService) ListFunctions(arg0 string, arg1, arg2 int) ([]whisk.Action, error) { m.ctrl.T.Helper() @@ -362,21 +408,6 @@ func (mr *MockServerlessServiceMockRecorder) ReadProject(arg0, arg1 interface{}) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadProject", reflect.TypeOf((*MockServerlessService)(nil).ReadProject), arg0, arg1) } -// SetTriggerEnablement mocks base method. -func (m *MockServerlessService) SetTriggerEnablement(arg0 context.Context, arg1 string, arg2 bool) (do.ServerlessTrigger, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "SetTriggerEnablement", arg0, arg1, arg2) - ret0, _ := ret[0].(do.ServerlessTrigger) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// SetTriggerEnablement indicates an expected call of SetTriggerEnablement. -func (mr *MockServerlessServiceMockRecorder) SetTriggerEnablement(arg0, arg1, arg2 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetTriggerEnablement", reflect.TypeOf((*MockServerlessService)(nil).SetTriggerEnablement), arg0, arg1, arg2) -} - // Stream mocks base method. func (m *MockServerlessService) Stream(arg0 *exec.Cmd) error { m.ctrl.T.Helper() diff --git a/do/serverless.go b/do/serverless.go index 9601b227e..6e2c07d91 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -221,6 +221,10 @@ type ServerlessService interface { ListFunctions(string, int, int) ([]whisk.Action, error) InvokeFunction(string, interface{}, bool, bool) (map[string]interface{}, error) InvokeFunctionViaWeb(string, map[string]interface{}) error + ListActivations(whisk.ActivationListOptions) ([]whisk.Activation, error) + GetActivation(string) (whisk.Activation, error) + GetActivationLogs(string) (whisk.Activation, error) + GetActivationResult(string) (whisk.Response, error) GetConnectedAPIHost() (string, error) ReadProject(*ServerlessProject, []string) (ServerlessOutput, error) WriteProject(ServerlessProject) (string, error) @@ -345,6 +349,10 @@ func initWhisk(s *serverlessService) error { if s.owClient != nil { return nil } + err := s.CheckServerlessStatus(HashAccessToken(s.accessToken)) + if err != nil { + return err + } creds, err := s.ReadCredentials() if err != nil { return err @@ -793,6 +801,50 @@ func (s *serverlessService) InvokeFunctionViaWeb(name string, params map[string] return browser.OpenURL(theURL) } +// ListActivations drives the OpenWhisk API for listing activations +func (s *serverlessService) ListActivations(options whisk.ActivationListOptions) ([]whisk.Activation, error) { + empty := []whisk.Activation{} + err := initWhisk(s) + if err != nil { + return empty, err + } + resp, _, err := s.owClient.Activations.List(&options) + return resp, err +} + +// GetActivation drives the OpenWhisk API getting an activation +func (s *serverlessService) GetActivation(id string) (whisk.Activation, error) { + empty := whisk.Activation{} + err := initWhisk(s) + if err != nil { + return empty, err + } + resp, _, err := s.owClient.Activations.Get(id) + return *resp, err +} + +// GetActivationLogs drives the OpenWhisk API getting the logs of an activation +func (s *serverlessService) GetActivationLogs(id string) (whisk.Activation, error) { + empty := whisk.Activation{} + err := initWhisk(s) + if err != nil { + return empty, err + } + resp, _, err := s.owClient.Activations.Logs(id) + return *resp, err +} + +// GetActivationResult drives the OpenWhisk API getting the result of an activation +func (s *serverlessService) GetActivationResult(id string) (whisk.Response, error) { + empty := whisk.Response{} + err := initWhisk(s) + if err != nil { + return empty, err + } + resp, _, err := s.owClient.Activations.Result(id) + return *resp, err +} + // GetConnectedAPIHost retrieves the API host to which the service is currently connected func (s *serverlessService) GetConnectedAPIHost() (string, error) { err := initWhisk(s) @@ -1041,8 +1093,8 @@ func (s *serverlessService) ReadCredentials() (ServerlessCredentials, error) { return creds, err } -// Determines whether the serverlessUptodate appears to be connected. The purpose is -// to fail fast (when feasible) on sandboxes that are clearly not connected. +// Determines whether the serverless support appears to be connected. The purpose is +// to fail fast (when feasible) when it clearly is not connected. // However, it is important not to add excessive overhead on each call (e.g. // asking the plugin to validate credentials), so the test is not foolproof. // It merely tests whether a credentials directory has been created for the @@ -1054,15 +1106,15 @@ func isServerlessConnected(leafCredsDir string, serverlessDir string) bool { return !os.IsNotExist(err) } -// serverlessUptodate answers whether the installed version of the serverlessUptodate is at least +// serverlessUptodate answers whether the installed version of the serverless support is at least // what is required by doctl func serverlessUptodate(serverlessDir string) bool { return GetCurrentServerlessVersion(serverlessDir) >= GetMinServerlessVersion() } -// GetCurrentServerlessVersion gets the version of the current serverless. -// To be called only when serverless is known to exist. -// Returns "0" if the installed serverless pre-dates the versioning system +// GetCurrentServerlessVersion gets the version of the current plugin. +// To be called only when the plugin is known to exist. +// Returns "0" if the installed plugin pre-dates the versioning system // Otherwise, returns the version string stored in the serverless directory. func GetCurrentServerlessVersion(serverlessDir string) string { versionFile := filepath.Join(serverlessDir, "version") From 5b226849b09fba45fdb10a45533bfa7fa0e7340c Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Tue, 11 Oct 2022 12:05:25 -0400 Subject: [PATCH 12/28] Updates activation list command to use the whisk client instead of the nim plugin --- commands/activations.go | 39 +++++- commands/displayers/activations.go | 115 ++++++++++++++++++ do/serverless.go | 12 ++ .../openwhisk-client-go/whisk/activation.go | 50 +++++++- 4 files changed, 209 insertions(+), 7 deletions(-) create mode 100644 commands/displayers/activations.go diff --git a/commands/activations.go b/commands/activations.go index d621fd87e..c2089ce51 100644 --- a/commands/activations.go +++ b/commands/activations.go @@ -23,6 +23,7 @@ import ( "github.com/apache/openwhisk-client-go/whisk" "github.com/digitalocean/doctl" "github.com/digitalocean/doctl/commands/charm/text" + "github.com/digitalocean/doctl/commands/displayers" "github.com/spf13/cobra" ) @@ -61,8 +62,10 @@ logs.`, list := CmdBuilder(cmd, RunActivationsList, "list []", "Lists Activations for which records exist", `Use `+"`"+`doctl serverless activations list`+"`"+` to list the activation records that are present in the cloud for previously invoked functions.`, - Writer) - AddStringFlag(list, "limit", "l", "", "only return LIMIT number of activations (default 30, max 200)") + Writer, + displayerType(&displayers.Activation{}), + ) + AddIntFlag(list, "limit", "l", 30, "only return LIMIT number of activations (default 30, max 200)") AddStringFlag(list, "skip", "s", "", "exclude the first SKIP number of activations from the result") AddStringFlag(list, "since", "", "", "return activations with timestamps later than SINCE; measured in milliseconds since Th, 01, Jan 1970") AddStringFlag(list, "upto", "", "", "return activations with timestamps earlier than UPTO; measured in milliseconds since Th, 01, Jan 1970") @@ -248,11 +251,39 @@ func RunActivationsList(c *CmdConfig) error { if argCount > 1 { return doctl.NewTooManyArgsErr(c.NS) } - output, err := RunServerlessExec(activationList, c, []string{flagCount, flagFull}, []string{flagLimit, flagSkip, flagSince, flagUpto}) + sls := c.Serverless() + + countFlags, _ := c.Doit.GetBool(c.NS, flagCount) + fullFlag, _ := c.Doit.GetBool(c.NS, flagFull) + skipFlag, _ := c.Doit.GetInt(c.NS, flagSkip) + sinceFlag, _ := c.Doit.GetInt(c.NS, flagSince) + upToFlag, _ := c.Doit.GetInt(c.NS, flagUpto) + limitFlag, _ := c.Doit.GetInt(c.NS, flagLimit) + + limit := limitFlag + if limitFlag > 200 { + limit = 200 + } else if limitFlag > 0 { + limit = limitFlag + } + + if countFlags { + options := whisk.ActivationListOptions{Count: true, Since: int64(sinceFlag), Upto: int64(upToFlag)} + count, err := sls.GetActivationCount(options) + if err != nil { + return err + } + fmt.Printf("You have %d activations in this namespace \n", count.Activations) + return nil + } + + options := whisk.ActivationListOptions{Limit: limit, Skip: skipFlag, Since: int64(sinceFlag), Upto: int64(upToFlag), Docs: fullFlag} + + actv, err := sls.ListActivations(options) if err != nil { return err } - return c.PrintServerlessTextOutput(output) + return c.Display(&displayers.Activation{Activations: actv}) } // RunActivationsLogs supports the 'activations logs' command diff --git a/commands/displayers/activations.go b/commands/displayers/activations.go new file mode 100644 index 000000000..cad07ef56 --- /dev/null +++ b/commands/displayers/activations.go @@ -0,0 +1,115 @@ +package displayers + +import ( + "fmt" + "io" + "strings" + "time" + + "github.com/apache/openwhisk-client-go/whisk" +) + +type Activation struct { + Activations []whisk.Activation +} + +var _ Displayable = &Activation{} + +// ColMap implements Displayable +func (a *Activation) ColMap() map[string]string { + return map[string]string{ + "Datetime": "Datetime", + "Status": "Status", + "Kind": "Kind", + "Version": "Version", + "ActivationId": "Activation ID", + "Start": "Start", + "Wait": "Wait", + "Duration": "Duration", + "Function": "Function", + } +} + +// Cols implements Displayable +func (a *Activation) Cols() []string { + return []string{ + "Datetime", + "Status", + "Kind", + "Version", + "ActivationId", + "Start", + "Wait", + "Duration", + "Function", + } +} + +// JSON implements Displayable +func (a *Activation) JSON(out io.Writer) error { + return writeJSON(a, out) +} + +// KV implements Displayable +func (a *Activation) KV() []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(a.Activations)) + + for _, actv := range a.Activations { + o := map[string]interface{}{ + "Datetime": time.UnixMilli(actv.Start).Format("01/02 03:04:05"), + "Status": getActivationStatus(actv), + "Kind": getActivationAnnotationValue(actv, "kind"), + "Version": actv.Version, + "ActivationId": actv.ActivationID, + "Start": getActivationStartType(actv), + "Wait": getActivationAnnotationValue(actv, "waitTime"), + "Duration": fmt.Sprintf("%dms", actv.Duration), + "Function": getActivationFunctionName(actv), + } + out = append(out, o) + } + return out +} + +func getActivationStartType(a whisk.Activation) string { + if getActivationAnnotationValue(a, "init") == "" { + return "cold" + } + return "warm" +} + +func getActivationFunctionName(a whisk.Activation) string { + name := a.Name + path := getActivationAnnotationValue(a, "path").(string) + + if path == "" { + return name + } + + parts := strings.Split(path, "/") + + if len(parts) == 3 { + return parts[1] + "/" + name + } + + return name +} + +func getActivationAnnotationValue(a whisk.Activation, key string) interface{} { + return a.Annotations.GetValue(key) +} + +func getActivationStatus(a whisk.Activation) string { + switch a.StatusCode { + case 0: + return "success" + case 1: + return "application error" + case 2: + return "developer error" + case 3: + return "system error" + default: + return "unknown" + } +} diff --git a/do/serverless.go b/do/serverless.go index 6e2c07d91..3086f5454 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -222,6 +222,7 @@ type ServerlessService interface { InvokeFunction(string, interface{}, bool, bool) (map[string]interface{}, error) InvokeFunctionViaWeb(string, map[string]interface{}) error ListActivations(whisk.ActivationListOptions) ([]whisk.Activation, error) + GetActivationCount(whisk.ActivationListOptions) (whisk.ActivationCount, error) GetActivation(string) (whisk.Activation, error) GetActivationLogs(string) (whisk.Activation, error) GetActivationResult(string) (whisk.Response, error) @@ -812,6 +813,17 @@ func (s *serverlessService) ListActivations(options whisk.ActivationListOptions) return resp, err } +// GetActivationCount drives the OpenWhisk API for getting the total number of activations in namespace +func (s *serverlessService) GetActivationCount(options whisk.ActivationListOptions) (whisk.ActivationCount, error) { + err := initWhisk(s) + empty := whisk.ActivationCount{} + if err != nil { + return empty, err + } + resp, _, err := s.owClient.Activations.Count(&options) + return *resp, err +} + // GetActivation drives the OpenWhisk API getting an activation func (s *serverlessService) GetActivation(id string) (whisk.Activation, error) { empty := whisk.Activation{} diff --git a/vendor/github.com/apache/openwhisk-client-go/whisk/activation.go b/vendor/github.com/apache/openwhisk-client-go/whisk/activation.go index fdbe40cdd..ecade1f05 100644 --- a/vendor/github.com/apache/openwhisk-client-go/whisk/activation.go +++ b/vendor/github.com/apache/openwhisk-client-go/whisk/activation.go @@ -20,11 +20,12 @@ package whisk import ( "errors" "fmt" - "github.com/apache/openwhisk-client-go/wski18n" "net/http" "net/url" "strconv" "time" + + "github.com/apache/openwhisk-client-go/wski18n" ) type ActivationService struct { @@ -48,6 +49,10 @@ type Activation struct { Publish *bool `json:"publish,omitempty"` } +type ActivationCount struct { + Activations int64 `json:"activations"` +} + type ActivationFilteredRow struct { Row Activation HeaderFmt string @@ -70,9 +75,10 @@ type ActivationListOptions struct { Since int64 `url:"since,omitempty"` Upto int64 `url:"upto,omitempty"` Docs bool `url:"docs,omitempty"` + Count bool `url:"count,omitempty"` } -//MWD - This structure may no longer be needed as the log format is now a string and not JSON +// MWD - This structure may no longer be needed as the log format is now a string and not JSON type Log struct { Log string `json:"log,omitempty"` Stream string `json:"stream,omitempty"` @@ -118,7 +124,9 @@ func TruncateStr(str string, maxlen int) string { } // ToSummaryRowString() returns a compound string of required parameters for printing -// from CLI command `wsk activation list`. +// +// from CLI command `wsk activation list`. +// // ***Method of type Sortable*** func (activation ActivationFilteredRow) ToSummaryRowString() string { s := time.Unix(0, activation.Row.Start*1000000) @@ -189,6 +197,42 @@ func (s *ActivationService) List(options *ActivationListOptions) ([]Activation, return activations, resp, nil } +func (s *ActivationService) Count(options *ActivationListOptions) (*ActivationCount, *http.Response, error) { + // TODO :: for some reason /activations only works with "_" as namespace + s.client.Namespace = "_" + route := "activations" + routeUrl, err := addRouteOptions(route, options) + + if err != nil { + Debug(DbgError, "addRouteOptions(%s, %#v) error: '%s'\n", route, options, err) + errStr := wski18n.T("Unable to append options '{{.options}}' to URL route '{{.route}}': {{.err}}", + map[string]interface{}{"options": fmt.Sprintf("%#v", options), "route": route, "err": err}) + werr := MakeWskErrorFromWskError(errors.New(errStr), err, EXIT_CODE_ERR_GENERAL, DISPLAY_MSG, NO_DISPLAY_USAGE) + return nil, nil, werr + } + + req, err := s.client.NewRequestUrl("GET", routeUrl, nil, IncludeNamespaceInUrl, AppendOpenWhiskPathPrefix, EncodeBodyAsJson, AuthRequired) + if err != nil { + Debug(DbgError, "http.NewRequestUrl(GET, %s, nil, IncludeNamespaceInUrl, AppendOpenWhiskPathPrefix, EncodeBodyAsJson, AuthRequired) error: '%s'\n", route, err) + errStr := wski18n.T("Unable to create HTTP request for GET '{{.route}}': {{.err}}", + map[string]interface{}{"route": route, "err": err}) + werr := MakeWskErrorFromWskError(errors.New(errStr), err, EXIT_CODE_ERR_GENERAL, DISPLAY_MSG, NO_DISPLAY_USAGE) + return nil, nil, werr + } + + Debug(DbgInfo, "Sending HTTP request - URL '%s'; req %#v\n", req.URL.String(), req) + + count := new(ActivationCount) + resp, err := s.client.Do(req, &count, ExitWithSuccessOnTimeout) + + if err != nil { + Debug(DbgError, "s.client.Do() error - HTTP req %s; error '%s'\n", req.URL.String(), err) + return nil, resp, err + } + + return count, resp, nil +} + func (s *ActivationService) Get(activationID string) (*Activation, *http.Response, error) { // TODO :: for some reason /activations/:id only works with "_" as namespace s.client.Namespace = "_" From 142fe8de96bf6cc7fef051dcf66a868cbfc3fd47 Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Tue, 11 Oct 2022 15:12:24 -0400 Subject: [PATCH 13/28] Adds name filter to activations list --- commands/activations.go | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/commands/activations.go b/commands/activations.go index c2089ce51..819f5e7b3 100644 --- a/commands/activations.go +++ b/commands/activations.go @@ -248,11 +248,17 @@ func printActivationRecord(writer io.Writer, activation whisk.Activation) { // RunActivationsList supports the 'activations list' command func RunActivationsList(c *CmdConfig) error { argCount := len(c.Args) + if argCount > 1 { return doctl.NewTooManyArgsErr(c.NS) } sls := c.Serverless() + var name string + if argCount > 0 { + name = c.Args[0] + } + countFlags, _ := c.Doit.GetBool(c.NS, flagCount) fullFlag, _ := c.Doit.GetBool(c.NS, flagFull) skipFlag, _ := c.Doit.GetInt(c.NS, flagSkip) @@ -268,16 +274,21 @@ func RunActivationsList(c *CmdConfig) error { } if countFlags { - options := whisk.ActivationListOptions{Count: true, Since: int64(sinceFlag), Upto: int64(upToFlag)} + options := whisk.ActivationListOptions{Count: true, Since: int64(sinceFlag), Upto: int64(upToFlag), Name: name} count, err := sls.GetActivationCount(options) if err != nil { return err } - fmt.Printf("You have %d activations in this namespace \n", count.Activations) + + if name != "" { + fmt.Fprintf(c.Out, "You have %d activations in this namespace for function %s \n", count.Activations, name) + } else { + fmt.Fprintf(c.Out, "You have %d activations in this namespace \n", count.Activations) + } return nil } - options := whisk.ActivationListOptions{Limit: limit, Skip: skipFlag, Since: int64(sinceFlag), Upto: int64(upToFlag), Docs: fullFlag} + options := whisk.ActivationListOptions{Limit: limit, Skip: skipFlag, Since: int64(sinceFlag), Upto: int64(upToFlag), Docs: fullFlag, Name: name} actv, err := sls.ListActivations(options) if err != nil { From b51dccc6799cd50ddb046f645f581da19a809997 Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Tue, 11 Oct 2022 15:52:25 -0400 Subject: [PATCH 14/28] moved getActivationStatus to utils --- commands/activations.go | 18 +----------------- commands/displayers/activations.go | 7 ++++--- commands/serverless_util.go | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 20 deletions(-) diff --git a/commands/activations.go b/commands/activations.go index 819f5e7b3..b0d320fb9 100644 --- a/commands/activations.go +++ b/commands/activations.go @@ -175,28 +175,12 @@ func RunActivationsGet(c *CmdConfig) error { func makeBanner(writer io.Writer, activation whisk.Activation) { end := time.UnixMilli(activation.End).Format("01/02 03:04:05") init := text.NewStyled("=== ").Muted() - body := fmt.Sprintf("%s %s %s %s:%s", activation.ActivationID, statusToString(activation.StatusCode), + body := fmt.Sprintf("%s %s %s %s:%s", activation.ActivationID, getActivationStatus(activation.StatusCode), end, activation.Name, activation.Version) msg := text.NewStyled(body).Highlight() fmt.Fprintln(writer, init.String()+msg.String()) } -// statusToString converts numeric status codes to typical string -func statusToString(statusCode int) string { - switch statusCode { - case 0: - return "success" - case 1: - return "application error" - case 2: - return "developer error" - case 3: - return "system error" - default: - return "??" - } -} - // printLog is a subroutine for printing just the logs of an activation func printLogs(writer io.Writer, strip bool, activation whisk.Activation) { for _, log := range activation.Logs { diff --git a/commands/displayers/activations.go b/commands/displayers/activations.go index cad07ef56..62f60b895 100644 --- a/commands/displayers/activations.go +++ b/commands/displayers/activations.go @@ -57,7 +57,7 @@ func (a *Activation) KV() []map[string]interface{} { for _, actv := range a.Activations { o := map[string]interface{}{ "Datetime": time.UnixMilli(actv.Start).Format("01/02 03:04:05"), - "Status": getActivationStatus(actv), + "Status": getActivationStatus(actv.StatusCode), "Kind": getActivationAnnotationValue(actv, "kind"), "Version": actv.Version, "ActivationId": actv.ActivationID, @@ -99,8 +99,9 @@ func getActivationAnnotationValue(a whisk.Activation, key string) interface{} { return a.Annotations.GetValue(key) } -func getActivationStatus(a whisk.Activation) string { - switch a.StatusCode { +// converts numeric status codes to typical string +func getActivationStatus(statusCode int) string { + switch statusCode { case 0: return "success" case 1: diff --git a/commands/serverless_util.go b/commands/serverless_util.go index b0869e5b8..51582580e 100644 --- a/commands/serverless_util.go +++ b/commands/serverless_util.go @@ -163,3 +163,19 @@ func getFlatArgsArray(c *CmdConfig, booleanFlags []string, stringFlags []string) func getServerlessDirectory() string { return filepath.Join(defaultConfigHome(), "sandbox") } + +// converts numeric status codes to typical string +func getActivationStatus(statusCode int) string { + switch statusCode { + case 0: + return "success" + case 1: + return "application error" + case 2: + return "developer error" + case 3: + return "system error" + default: + return "unknown" + } +} From f3e3ebd8e502d3206edff94a4a3d2a472942d4e9 Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Tue, 11 Oct 2022 16:18:01 -0400 Subject: [PATCH 15/28] re-generates mocks --- do/mocks/ServerlessService.go | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/do/mocks/ServerlessService.go b/do/mocks/ServerlessService.go index 68a24429c..5c658cbbd 100644 --- a/do/mocks/ServerlessService.go +++ b/do/mocks/ServerlessService.go @@ -139,6 +139,21 @@ func (mr *MockServerlessServiceMockRecorder) GetActivation(arg0 interface{}) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActivation", reflect.TypeOf((*MockServerlessService)(nil).GetActivation), arg0) } +// GetActivationCount mocks base method. +func (m *MockServerlessService) GetActivationCount(arg0 whisk.ActivationListOptions) (whisk.ActivationCount, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetActivationCount", arg0) + ret0, _ := ret[0].(whisk.ActivationCount) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetActivationCount indicates an expected call of GetActivationCount. +func (mr *MockServerlessServiceMockRecorder) GetActivationCount(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActivationCount", reflect.TypeOf((*MockServerlessService)(nil).GetActivationCount), arg0) +} + // GetActivationLogs mocks base method. func (m *MockServerlessService) GetActivationLogs(arg0 string) (whisk.Activation, error) { m.ctrl.T.Helper() From 046ee83ad4ea08f1daa1da0262d8b3edfcd74ad5 Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Wed, 12 Oct 2022 14:06:46 -0400 Subject: [PATCH 16/28] made changes from PR review and updated the unit tests --- commands/activations.go | 6 +- commands/activations_test.go | 132 ++++++++++++++++++++--------- commands/displayers/activations.go | 14 +-- commands/serverless_util.go | 16 ---- 4 files changed, 102 insertions(+), 66 deletions(-) diff --git a/commands/activations.go b/commands/activations.go index b0d320fb9..2902958da 100644 --- a/commands/activations.go +++ b/commands/activations.go @@ -66,7 +66,7 @@ invoked functions.`, displayerType(&displayers.Activation{}), ) AddIntFlag(list, "limit", "l", 30, "only return LIMIT number of activations (default 30, max 200)") - AddStringFlag(list, "skip", "s", "", "exclude the first SKIP number of activations from the result") + AddIntFlag(list, "skip", "s", 0, "exclude the first SKIP number of activations from the result") AddStringFlag(list, "since", "", "", "return activations with timestamps later than SINCE; measured in milliseconds since Th, 01, Jan 1970") AddStringFlag(list, "upto", "", "", "return activations with timestamps earlier than UPTO; measured in milliseconds since Th, 01, Jan 1970") AddBoolFlag(list, "count", "", false, "show only the total number of activations") @@ -175,7 +175,7 @@ func RunActivationsGet(c *CmdConfig) error { func makeBanner(writer io.Writer, activation whisk.Activation) { end := time.UnixMilli(activation.End).Format("01/02 03:04:05") init := text.NewStyled("=== ").Muted() - body := fmt.Sprintf("%s %s %s %s:%s", activation.ActivationID, getActivationStatus(activation.StatusCode), + body := fmt.Sprintf("%s %s %s %s:%s", activation.ActivationID, displayers.GetActivationStatus(activation.StatusCode), end, activation.Name, activation.Version) msg := text.NewStyled(body).Highlight() fmt.Fprintln(writer, init.String()+msg.String()) @@ -253,8 +253,6 @@ func RunActivationsList(c *CmdConfig) error { limit := limitFlag if limitFlag > 200 { limit = 200 - } else if limitFlag > 0 { - limit = limitFlag } if countFlags { diff --git a/commands/activations_test.go b/commands/activations_test.go index f463bdf5b..0bda49355 100644 --- a/commands/activations_test.go +++ b/commands/activations_test.go @@ -17,6 +17,7 @@ import ( "bytes" "os/exec" "sort" + "strconv" "strings" "testing" "time" @@ -97,6 +98,29 @@ var theActivations = []whisk.Activation{ Success: false, }, }, + { + Namespace: "my-namespace", + Name: "hello4", + Version: "0.0.3", + ActivationID: "activation-3", + Annotations: whisk.KeyValueArr{ + {Key: "kind", Value: "nodejs:default"}, + {Key: "path", Value: "my-namespace/my-package/hello4"}, + }, + Start: 1664538850000, + End: 1664538860000, + Response: whisk.Response{ + Result: &whisk.Result{ + "error": "Missing main/no code to execute.", + }, + Status: "developer error", + Success: false, + }, + }, +} + +var theActivationCount = whisk.ActivationCount{ + Activations: 1738, } // Timestamps in the activations are converted to dates using local time so, to make this test capable of running @@ -312,65 +336,68 @@ Hello stranger! func TestActivationsList(t *testing.T) { tests := []struct { - name string - doctlArgs string - doctlFlags map[string]string - expectedNimArgs []string + name string + doctlArgs string + doctlFlags map[string]string }{ { - name: "no flags or args", - expectedNimArgs: []string{}, - }, - { - name: "full flag", - doctlFlags: map[string]string{"full": ""}, - expectedNimArgs: []string{"--full"}, - }, - { - name: "count flag", - doctlFlags: map[string]string{"count": ""}, - expectedNimArgs: []string{"--count"}, - }, - { - name: "limit flag", - doctlFlags: map[string]string{"limit": "10"}, - expectedNimArgs: []string{"--limit", "10"}, - }, - { - name: "since flag", - doctlFlags: map[string]string{"since": "1644866670085"}, - expectedNimArgs: []string{"--since", "1644866670085"}, + name: "no flags or args", + doctlArgs: "", }, { - name: "skip flag", - doctlFlags: map[string]string{"skip": "1"}, - expectedNimArgs: []string{"--skip", "1"}, + name: "function name argument", + doctlArgs: "my-package/hello4", }, { - name: "upto flag", - doctlFlags: map[string]string{"upto": "1644866670085"}, - expectedNimArgs: []string{"--upto", "1644866670085"}, + name: "count flag", + doctlArgs: "", + doctlFlags: map[string]string{"count": "true", "limit": "10"}, }, { - name: "multiple flags", - doctlFlags: map[string]string{"limit": "10", "count": ""}, - expectedNimArgs: []string{"--count", "--limit", "10"}, + name: "multiple flags and arg", + doctlArgs: "", + doctlFlags: map[string]string{"limit": "10", "skip": "100", "since": "1664538750000", "upto": "1664538850000"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { withTestClient(t, func(config *CmdConfig, tm *tcMocks) { - fakeCmd := &exec.Cmd{ - Stdout: config.Out, - } + buf := &bytes.Buffer{} + config.Out = buf if tt.doctlArgs != "" { config.Args = append(config.Args, tt.doctlArgs) } + count := false + var limit interface{} + var since interface{} + var upto interface{} + var skip interface{} + if tt.doctlFlags != nil { for k, v := range tt.doctlFlags { + if k == "count" { + count = true + } + + if k == "limit" { + limit, _ = strconv.ParseInt(v, 0, 64) + } + + if k == "since" { + since, _ = strconv.ParseInt(v, 0, 64) + } + + if k == "upto" { + upto, _ = strconv.ParseInt(v, 0, 64) + } + + if k == "skip" { + skip, _ = strconv.ParseInt(v, 0, 64) + } + if v == "" { config.Doit.Set(config.NS, k, true) } else { @@ -379,9 +406,32 @@ func TestActivationsList(t *testing.T) { } } - tm.serverless.EXPECT().CheckServerlessStatus(hashAccessToken(config)).MinTimes(1).Return(nil) - tm.serverless.EXPECT().Cmd("activation/list", tt.expectedNimArgs).Return(fakeCmd, nil) - tm.serverless.EXPECT().Exec(fakeCmd).Return(do.ServerlessOutput{}, nil) + expectedListOptions := whisk.ActivationListOptions{} + if since != nil { + expectedListOptions.Since = since.(int64) + } + + if upto != nil { + expectedListOptions.Upto = upto.(int64) + } + + if len(config.Args) == 1 { + expectedListOptions.Name = config.Args[0] + } + + if count { + expectedListOptions.Count = true + tm.serverless.EXPECT().GetActivationCount(expectedListOptions).Return(theActivationCount, nil) + } else { + if limit != nil { + expectedListOptions.Limit = int(limit.(int64)) + } + + if skip != nil { + expectedListOptions.Skip = int(skip.(int64)) + } + tm.serverless.EXPECT().ListActivations(expectedListOptions).Return(theActivations, nil) + } err := RunActivationsList(config) require.NoError(t, err) diff --git a/commands/displayers/activations.go b/commands/displayers/activations.go index 62f60b895..143f0f354 100644 --- a/commands/displayers/activations.go +++ b/commands/displayers/activations.go @@ -57,7 +57,7 @@ func (a *Activation) KV() []map[string]interface{} { for _, actv := range a.Activations { o := map[string]interface{}{ "Datetime": time.UnixMilli(actv.Start).Format("01/02 03:04:05"), - "Status": getActivationStatus(actv.StatusCode), + "Status": GetActivationStatus(actv.StatusCode), "Kind": getActivationAnnotationValue(actv, "kind"), "Version": actv.Version, "ActivationId": actv.ActivationID, @@ -78,15 +78,16 @@ func getActivationStartType(a whisk.Activation) string { return "warm" } +// Gets the full function name for the activation. func getActivationFunctionName(a whisk.Activation) string { name := a.Name - path := getActivationAnnotationValue(a, "path").(string) + path := getActivationAnnotationValue(a, "path") - if path == "" { + if path == nil { return name } - parts := strings.Split(path, "/") + parts := strings.Split(path.(string), "/") if len(parts) == 3 { return parts[1] + "/" + name @@ -96,11 +97,14 @@ func getActivationFunctionName(a whisk.Activation) string { } func getActivationAnnotationValue(a whisk.Activation, key string) interface{} { + if a.Annotations == nil { + return nil + } return a.Annotations.GetValue(key) } // converts numeric status codes to typical string -func getActivationStatus(statusCode int) string { +func GetActivationStatus(statusCode int) string { switch statusCode { case 0: return "success" diff --git a/commands/serverless_util.go b/commands/serverless_util.go index 51582580e..b0869e5b8 100644 --- a/commands/serverless_util.go +++ b/commands/serverless_util.go @@ -163,19 +163,3 @@ func getFlatArgsArray(c *CmdConfig, booleanFlags []string, stringFlags []string) func getServerlessDirectory() string { return filepath.Join(defaultConfigHome(), "sandbox") } - -// converts numeric status codes to typical string -func getActivationStatus(statusCode int) string { - switch statusCode { - case 0: - return "success" - case 1: - return "application error" - case 2: - return "developer error" - case 3: - return "system error" - default: - return "unknown" - } -} From 72bfb84d0ef645bf9346453adf7072ab7eafcbaf Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Wed, 12 Oct 2022 14:17:44 -0400 Subject: [PATCH 17/28] Updates test --- commands/activations_test.go | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/commands/activations_test.go b/commands/activations_test.go index 0bda49355..da61d1e23 100644 --- a/commands/activations_test.go +++ b/commands/activations_test.go @@ -98,25 +98,6 @@ var theActivations = []whisk.Activation{ Success: false, }, }, - { - Namespace: "my-namespace", - Name: "hello4", - Version: "0.0.3", - ActivationID: "activation-3", - Annotations: whisk.KeyValueArr{ - {Key: "kind", Value: "nodejs:default"}, - {Key: "path", Value: "my-namespace/my-package/hello4"}, - }, - Start: 1664538850000, - End: 1664538860000, - Response: whisk.Response{ - Result: &whisk.Result{ - "error": "Missing main/no code to execute.", - }, - Status: "developer error", - Success: false, - }, - }, } var theActivationCount = whisk.ActivationCount{ From 37f36ad79ba5e64199bc5443a2508c450e89edec Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Wed, 12 Oct 2022 14:38:47 -0400 Subject: [PATCH 18/28] default to json output when the full flag is set --- commands/activations.go | 8 +++++++- commands/displayers/activations.go | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/commands/activations.go b/commands/activations.go index 2902958da..a2649add8 100644 --- a/commands/activations.go +++ b/commands/activations.go @@ -276,7 +276,13 @@ func RunActivationsList(c *CmdConfig) error { if err != nil { return err } - return c.Display(&displayers.Activation{Activations: actv}) + + items := &displayers.Activation{Activations: actv} + if fullFlag { + return items.JSON(c.Out) + } + + return c.Display(items) } // RunActivationsLogs supports the 'activations logs' command diff --git a/commands/displayers/activations.go b/commands/displayers/activations.go index 143f0f354..abe31c64d 100644 --- a/commands/displayers/activations.go +++ b/commands/displayers/activations.go @@ -47,7 +47,7 @@ func (a *Activation) Cols() []string { // JSON implements Displayable func (a *Activation) JSON(out io.Writer) error { - return writeJSON(a, out) + return writeJSON(a.Activations, out) } // KV implements Displayable From ecd13f1b968d4f08c14f25ec796505d5e5820bfd Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Wed, 12 Oct 2022 14:51:09 -0400 Subject: [PATCH 19/28] list command takes a function name not activation name --- commands/activations.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commands/activations.go b/commands/activations.go index a2649add8..e7af751fc 100644 --- a/commands/activations.go +++ b/commands/activations.go @@ -59,7 +59,7 @@ logs.`, AddStringFlag(get, "function", "f", "", "Fetch activations for a specific function") AddBoolFlag(get, "quiet", "q", false, "Suppress last activation information header") - list := CmdBuilder(cmd, RunActivationsList, "list []", "Lists Activations for which records exist", + list := CmdBuilder(cmd, RunActivationsList, "list []", "Lists Activations for which records exist", `Use `+"`"+`doctl serverless activations list`+"`"+` to list the activation records that are present in the cloud for previously invoked functions.`, Writer, From a04b853b1c5ff576c8b7d22846b6f803cc35e595 Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Fri, 14 Oct 2022 11:17:08 -0400 Subject: [PATCH 20/28] Updates the apache-go-client library and updates the tests to reflect the changes --- commands/activations.go | 2 +- commands/activations_test.go | 57 ++++++++++++------- do/mocks/ServerlessService.go | 8 +-- do/serverless.go | 14 ++--- go.mod | 4 +- go.sum | 4 +- .../openwhisk-client-go/whisk/action.go | 4 +- .../openwhisk-client-go/whisk/activation.go | 13 ++++- vendor/modules.txt | 2 +- 9 files changed, 65 insertions(+), 43 deletions(-) diff --git a/commands/activations.go b/commands/activations.go index e7af751fc..ea94fbe48 100644 --- a/commands/activations.go +++ b/commands/activations.go @@ -256,7 +256,7 @@ func RunActivationsList(c *CmdConfig) error { } if countFlags { - options := whisk.ActivationListOptions{Count: true, Since: int64(sinceFlag), Upto: int64(upToFlag), Name: name} + options := whisk.ActivationCountOptions{Since: int64(sinceFlag), Upto: int64(upToFlag), Name: name} count, err := sls.GetActivationCount(options) if err != nil { return err diff --git a/commands/activations_test.go b/commands/activations_test.go index da61d1e23..4301980c4 100644 --- a/commands/activations_test.go +++ b/commands/activations_test.go @@ -43,6 +43,18 @@ func TestActivationsCommand(t *testing.T) { assert.Equal(t, expected, names) } +var hello1Result = whisk.Result(map[string]interface{}{ + "body": "Hello stranger!", +}) + +var hello2Result = whisk.Result(map[string]interface{}{ + "body": "Hello Archie!", +}) + +var hello3Result = whisk.Result(map[string]interface{}{ + "error": "Missing main/no code to execute.", +}) + // theActivations is the set of activation assumed to be present, used to mock whisk API behavior var theActivations = []whisk.Activation{ { @@ -56,9 +68,7 @@ var theActivations = []whisk.Activation{ Status: "success", StatusCode: 0, Success: true, - Result: &whisk.Result{ - "body": "Hello stranger!", - }, + Result: &hello1Result, }, Logs: []string{ "2022-09-30T11:53:50.567914279Z stdout: Hello stranger!", @@ -75,9 +85,7 @@ var theActivations = []whisk.Activation{ Status: "success", StatusCode: 0, Success: true, - Result: &whisk.Result{ - "body": "Hello Archie!", - }, + Result: &hello2Result, }, Logs: []string{ "2022-09-30T11:53:50.567914279Z stdout: Hello Archie!", @@ -91,9 +99,7 @@ var theActivations = []whisk.Activation{ Start: 1664538850000, End: 1664538860000, Response: whisk.Response{ - Result: &whisk.Result{ - "error": "Missing main/no code to execute.", - }, + Result: &hello3Result, Status: "developer error", Success: false, }, @@ -387,23 +393,30 @@ func TestActivationsList(t *testing.T) { } } - expectedListOptions := whisk.ActivationListOptions{} - if since != nil { - expectedListOptions.Since = since.(int64) - } - - if upto != nil { - expectedListOptions.Upto = upto.(int64) - } + if count { + expectedListOptions := whisk.ActivationCountOptions{} + if since != nil { + expectedListOptions.Since = since.(int64) + } - if len(config.Args) == 1 { - expectedListOptions.Name = config.Args[0] - } + if upto != nil { + expectedListOptions.Upto = upto.(int64) + } - if count { - expectedListOptions.Count = true tm.serverless.EXPECT().GetActivationCount(expectedListOptions).Return(theActivationCount, nil) } else { + expectedListOptions := whisk.ActivationListOptions{} + if since != nil { + expectedListOptions.Since = since.(int64) + } + + if upto != nil { + expectedListOptions.Upto = upto.(int64) + } + + if len(config.Args) == 1 { + expectedListOptions.Name = config.Args[0] + } if limit != nil { expectedListOptions.Limit = int(limit.(int64)) } diff --git a/do/mocks/ServerlessService.go b/do/mocks/ServerlessService.go index 5c658cbbd..ca8c9014a 100644 --- a/do/mocks/ServerlessService.go +++ b/do/mocks/ServerlessService.go @@ -140,7 +140,7 @@ func (mr *MockServerlessServiceMockRecorder) GetActivation(arg0 interface{}) *go } // GetActivationCount mocks base method. -func (m *MockServerlessService) GetActivationCount(arg0 whisk.ActivationListOptions) (whisk.ActivationCount, error) { +func (m *MockServerlessService) GetActivationCount(arg0 whisk.ActivationCountOptions) (whisk.ActivationCount, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "GetActivationCount", arg0) ret0, _ := ret[0].(whisk.ActivationCount) @@ -305,10 +305,10 @@ func (mr *MockServerlessServiceMockRecorder) InstallServerless(arg0, arg1 interf } // InvokeFunction mocks base method. -func (m *MockServerlessService) InvokeFunction(arg0 string, arg1 interface{}, arg2, arg3 bool) (map[string]interface{}, error) { +func (m *MockServerlessService) InvokeFunction(arg0 string, arg1 interface{}, arg2, arg3 bool) (interface{}, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InvokeFunction", arg0, arg1, arg2, arg3) - ret0, _ := ret[0].(map[string]interface{}) + ret0, _ := ret[0].(interface{}) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -320,7 +320,7 @@ func (mr *MockServerlessServiceMockRecorder) InvokeFunction(arg0, arg1, arg2, ar } // InvokeFunctionViaWeb mocks base method. -func (m *MockServerlessService) InvokeFunctionViaWeb(arg0 string, arg1 map[string]interface{}) error { +func (m *MockServerlessService) InvokeFunctionViaWeb(arg0 string, arg1 interface{}) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "InvokeFunctionViaWeb", arg0, arg1) ret0, _ := ret[0].(error) diff --git a/do/serverless.go b/do/serverless.go index 3086f5454..f1c921249 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -219,10 +219,10 @@ type ServerlessService interface { InstallServerless(string, bool) error GetFunction(string, bool) (whisk.Action, []FunctionParameter, error) ListFunctions(string, int, int) ([]whisk.Action, error) - InvokeFunction(string, interface{}, bool, bool) (map[string]interface{}, error) - InvokeFunctionViaWeb(string, map[string]interface{}) error + InvokeFunction(string, interface{}, bool, bool) (interface{}, error) + InvokeFunctionViaWeb(string, interface{}) error ListActivations(whisk.ActivationListOptions) ([]whisk.Activation, error) - GetActivationCount(whisk.ActivationListOptions) (whisk.ActivationCount, error) + GetActivationCount(whisk.ActivationCountOptions) (whisk.ActivationCount, error) GetActivation(string) (whisk.Activation, error) GetActivationLogs(string) (whisk.Activation, error) GetActivationResult(string) (whisk.Response, error) @@ -747,7 +747,7 @@ func (s *serverlessService) ListFunctions(pkg string, skip int, limit int) ([]wh } // InvokeFunction invokes a function via POST with authentication -func (s *serverlessService) InvokeFunction(name string, params interface{}, blocking bool, result bool) (map[string]interface{}, error) { +func (s *serverlessService) InvokeFunction(name string, params interface{}, blocking bool, result bool) (interface{}, error) { var empty map[string]interface{} err := initWhisk(s) if err != nil { @@ -758,7 +758,7 @@ func (s *serverlessService) InvokeFunction(name string, params interface{}, bloc } // InvokeFunctionViaWeb invokes a function via GET using its web URL (or error if not a web function) -func (s *serverlessService) InvokeFunctionViaWeb(name string, params map[string]interface{}) error { +func (s *serverlessService) InvokeFunctionViaWeb(name string, params interface{}) error { // Get the function so we can use its metadata in formulating the request theFunction, _, err := s.GetFunction(name, false) if err != nil { @@ -790,7 +790,7 @@ func (s *serverlessService) InvokeFunctionViaWeb(name string, params map[string] // Add params, if any if params != nil { encoded := url.Values{} - for key, val := range params { + for key, val := range params.(map[string]interface{}) { stringVal, ok := val.(string) if !ok { return fmt.Errorf("the value of '%s' is not a string; web invocation is not possible", key) @@ -814,7 +814,7 @@ func (s *serverlessService) ListActivations(options whisk.ActivationListOptions) } // GetActivationCount drives the OpenWhisk API for getting the total number of activations in namespace -func (s *serverlessService) GetActivationCount(options whisk.ActivationListOptions) (whisk.ActivationCount, error) { +func (s *serverlessService) GetActivationCount(options whisk.ActivationCountOptions) (whisk.ActivationCount, error) { err := initWhisk(s) empty := whisk.ActivationCount{} if err != nil { diff --git a/go.mod b/go.mod index 6779c0454..f04b41dfa 100644 --- a/go.mod +++ b/go.mod @@ -46,8 +46,7 @@ require ( require ( github.com/MakeNowJust/heredoc v1.0.0 - github.com/apache/openwhisk-client-go v0.0.0-20211007130743-38709899040b - github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 + github.com/apache/openwhisk-client-go v0.0.0-20221014112704-1ca897633f2d github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/charmbracelet/bubbles v0.13.1-0.20220731172002-8f6516082803 github.com/charmbracelet/bubbletea v0.22.0 @@ -57,6 +56,7 @@ require ( github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/muesli/reflow v0.3.0 github.com/muesli/termenv v0.12.0 + github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index c86db254f..37dea2e3e 100644 --- a/go.sum +++ b/go.sum @@ -92,8 +92,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alexflint/go-filemutex v0.0.0-20171022225611-72bdc8eae2ae/go.mod h1:CgnQgUtFrFz9mxFNtED3jI5tLDjKlOM+oUF/sTk6ps0= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/apache/openwhisk-client-go v0.0.0-20211007130743-38709899040b h1:E0IprB8sveEHNP3WiZB/N/3nWzljyQCIDYFg0N3geGU= -github.com/apache/openwhisk-client-go v0.0.0-20211007130743-38709899040b/go.mod h1:SAQU4bHGJ0sg6c1vQ8ojmQKXgGaneVnexWX4+2/KMr8= +github.com/apache/openwhisk-client-go v0.0.0-20221014112704-1ca897633f2d h1:8sh89OGDm1tx/D/nsFwunhX90NjEPTn2k/DDLhOjexs= +github.com/apache/openwhisk-client-go v0.0.0-20221014112704-1ca897633f2d/go.mod h1:SAQU4bHGJ0sg6c1vQ8ojmQKXgGaneVnexWX4+2/KMr8= github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs= github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= diff --git a/vendor/github.com/apache/openwhisk-client-go/whisk/action.go b/vendor/github.com/apache/openwhisk-client-go/whisk/action.go index 539656837..367323600 100644 --- a/vendor/github.com/apache/openwhisk-client-go/whisk/action.go +++ b/vendor/github.com/apache/openwhisk-client-go/whisk/action.go @@ -287,8 +287,8 @@ func (s *ActionService) Delete(actionName string) (*http.Response, error) { return resp, nil } -func (s *ActionService) Invoke(actionName string, payload interface{}, blocking bool, result bool) (map[string]interface{}, *http.Response, error) { - var res map[string]interface{} +func (s *ActionService) Invoke(actionName string, payload interface{}, blocking bool, result bool) (interface{}, *http.Response, error) { + var res interface{} // Encode resource name as a path (with no query params) before inserting it into the URI // This way any '?' chars in the name won't be treated as the beginning of the query params diff --git a/vendor/github.com/apache/openwhisk-client-go/whisk/activation.go b/vendor/github.com/apache/openwhisk-client-go/whisk/activation.go index ecade1f05..2082bfa17 100644 --- a/vendor/github.com/apache/openwhisk-client-go/whisk/activation.go +++ b/vendor/github.com/apache/openwhisk-client-go/whisk/activation.go @@ -66,7 +66,7 @@ type Response struct { Result *Result `json:"result,omitempty"` } -type Result map[string]interface{} +type Result interface{} type ActivationListOptions struct { Name string `url:"name,omitempty"` @@ -75,6 +75,13 @@ type ActivationListOptions struct { Since int64 `url:"since,omitempty"` Upto int64 `url:"upto,omitempty"` Docs bool `url:"docs,omitempty"` +} + +type ActivationCountOptions struct { + Name string `url:"name,omitempty"` + Skip int `url:"skip"` + Since int64 `url:"since,omitempty"` + Upto int64 `url:"upto,omitempty"` Count bool `url:"count,omitempty"` } @@ -197,10 +204,12 @@ func (s *ActivationService) List(options *ActivationListOptions) ([]Activation, return activations, resp, nil } -func (s *ActivationService) Count(options *ActivationListOptions) (*ActivationCount, *http.Response, error) { +func (s *ActivationService) Count(options *ActivationCountOptions) (*ActivationCount, *http.Response, error) { // TODO :: for some reason /activations only works with "_" as namespace s.client.Namespace = "_" route := "activations" + + options.Count = true routeUrl, err := addRouteOptions(route, options) if err != nil { diff --git a/vendor/modules.txt b/vendor/modules.txt index cdb6c89da..3ae965339 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -33,7 +33,7 @@ github.com/Microsoft/hcsshim/internal/vmcompute github.com/Microsoft/hcsshim/internal/wclayer github.com/Microsoft/hcsshim/internal/winapi github.com/Microsoft/hcsshim/osversion -# github.com/apache/openwhisk-client-go v0.0.0-20211007130743-38709899040b +# github.com/apache/openwhisk-client-go v0.0.0-20221014112704-1ca897633f2d ## explicit; go 1.15 github.com/apache/openwhisk-client-go/whisk github.com/apache/openwhisk-client-go/wski18n From d4abffca53654476c7f6aa7c8dd845cc4966f029 Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Fri, 14 Oct 2022 14:49:21 -0400 Subject: [PATCH 21/28] Makes updates from PR review --- commands/activations.go | 4 ++-- commands/displayers/activations.go | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/commands/activations.go b/commands/activations.go index ea94fbe48..042eec2b5 100644 --- a/commands/activations.go +++ b/commands/activations.go @@ -67,8 +67,8 @@ invoked functions.`, ) AddIntFlag(list, "limit", "l", 30, "only return LIMIT number of activations (default 30, max 200)") AddIntFlag(list, "skip", "s", 0, "exclude the first SKIP number of activations from the result") - AddStringFlag(list, "since", "", "", "return activations with timestamps later than SINCE; measured in milliseconds since Th, 01, Jan 1970") - AddStringFlag(list, "upto", "", "", "return activations with timestamps earlier than UPTO; measured in milliseconds since Th, 01, Jan 1970") + AddIntFlag(list, "since", "", 0, "return activations with timestamps later than SINCE; measured in milliseconds since Th, 01, Jan 1970") + AddIntFlag(list, "upto", "", 0, "return activations with timestamps earlier than UPTO; measured in milliseconds since Th, 01, Jan 1970") AddBoolFlag(list, "count", "", false, "show only the total number of activations") AddBoolFlag(list, "full", "f", false, "include full activation description") diff --git a/commands/displayers/activations.go b/commands/displayers/activations.go index abe31c64d..f7d30fbf8 100644 --- a/commands/displayers/activations.go +++ b/commands/displayers/activations.go @@ -22,7 +22,7 @@ func (a *Activation) ColMap() map[string]string { "Status": "Status", "Kind": "Kind", "Version": "Version", - "ActivationId": "Activation ID", + "ActivationID": "Activation ID", "Start": "Start", "Wait": "Wait", "Duration": "Duration", @@ -37,7 +37,7 @@ func (a *Activation) Cols() []string { "Status", "Kind", "Version", - "ActivationId", + "ActivationID", "Start", "Wait", "Duration", @@ -60,7 +60,7 @@ func (a *Activation) KV() []map[string]interface{} { "Status": GetActivationStatus(actv.StatusCode), "Kind": getActivationAnnotationValue(actv, "kind"), "Version": actv.Version, - "ActivationId": actv.ActivationID, + "ActivationID": actv.ActivationID, "Start": getActivationStartType(actv), "Wait": getActivationAnnotationValue(actv, "waitTime"), "Duration": fmt.Sprintf("%dms", actv.Duration), From caa607b3cc24c0c13f15f18a6689068883ca3d95 Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Mon, 17 Oct 2022 09:09:45 -0400 Subject: [PATCH 22/28] Uses bubbles list to display an interactive namespace selection list --- commands/serverless.go | 65 +++++++++++++++++------------------------- 1 file changed, 26 insertions(+), 39 deletions(-) diff --git a/commands/serverless.go b/commands/serverless.go index 44fb43a7b..63a1143a5 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -14,16 +14,15 @@ limitations under the License. package commands import ( - "bufio" "context" "errors" "fmt" "io" "os" - "strconv" "strings" "github.com/digitalocean/doctl" + "github.com/digitalocean/doctl/commands/charm/list" "github.com/digitalocean/doctl/do" "github.com/spf13/cobra" ) @@ -243,8 +242,11 @@ func RunServerlessConnect(c *CmdConfig) error { if err != nil { return err } + if len(list) == 0 { return fmt.Errorf("you have no namespaces matching '%s'", c.Args[0]) + } else if len(list) == 1 { + } return connectFromList(ctx, sls, list, c.Out) } @@ -261,49 +263,34 @@ func RunServerlessConnect(c *CmdConfig) error { // connectFromList connects a namespace based on a non-empty list of namespaces. If the list is // singular that determines the namespace that will be connected. Otherwise, this is determined // via a prompt. -func connectFromList(ctx context.Context, sls do.ServerlessService, list []do.OutputNamespace, out io.Writer) error { - var ns do.OutputNamespace - if len(list) == 1 { - ns = list[0] - } else { - ns = chooseFromList(list, out) - if ns.Namespace == "" { - return nil - } - } - creds, err := sls.GetNamespace(ctx, ns.Namespace) - if err != nil { - return err +func connectFromList(ctx context.Context, sls do.ServerlessService, l []do.OutputNamespace, out io.Writer) error { + var nsItems []list.Item + + for _, ns := range l { + nsItems = append(nsItems, nsListItem{ns: ns}) } - return finishConnecting(sls, creds, ns.Label, out) -} -// connectChoiceReader is the bufio.Reader for reading the user's response to the prompt to choose -// a namespace. It can be replaced for testing. -var connectChoiceReader *bufio.Reader = bufio.NewReader(os.Stdin) + listItems := list.New(nsItems) + listItems.Model().Title = "select a namespace" + listItems.Model().SetStatusBarItemName("namespace", "namespaces") -// chooseFromList displays a list of namespaces (label, region, id) assigning each one a number. -// The user can than respond to a prompt that chooses from the list by number. The response 'x' is -// also accepted and exits the command. -func chooseFromList(list []do.OutputNamespace, out io.Writer) do.OutputNamespace { - for i, ns := range list { - fmt.Fprintf(out, "%d: %s in %s, label=%s\n", i, ns.Namespace, ns.Region, ns.Label) - } - for { - fmt.Fprintln(out, "Choose a namespace by number or 'x' to exit") - choice, err := connectChoiceReader.ReadString('\n') + var selected list.Item + if len(nsItems) == 1 { + selected = nsItems[0] + } else { + var err error + selected, err = listItems.Select() if err != nil { - continue - } - choice = strings.TrimSpace(choice) - if choice == "x" { - return do.OutputNamespace{} - } - i, err := strconv.Atoi(choice) - if err == nil && i >= 0 && i < len(list) { - return list[i] + return err } } + + selectedNs := selected.(nsListItem).ns + creds, err := sls.GetNamespace(ctx, selectedNs.Namespace) + if err != nil { + return err + } + return finishConnecting(sls, creds, selectedNs.Label, out) } // finishConnecting performs the final steps of 'doctl serverless connect'. From 2b761a82cbd5adf5e376890b881e58a5d584d9fc Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Mon, 17 Oct 2022 09:09:52 -0400 Subject: [PATCH 23/28] Uses bubbles list to display an interactive namespace selection list --- commands/serverless_charm.go | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 commands/serverless_charm.go diff --git a/commands/serverless_charm.go b/commands/serverless_charm.go new file mode 100644 index 000000000..c86f11638 --- /dev/null +++ b/commands/serverless_charm.go @@ -0,0 +1,21 @@ +package commands + +import ( + "github.com/digitalocean/doctl/do" +) + +type nsListItem struct { + ns do.OutputNamespace +} + +func (i nsListItem) Title() string { + return i.ns.Label + " (" + i.ns.Region + ")" +} + +func (i nsListItem) Description() string { + return i.ns.Namespace +} + +func (i nsListItem) FilterValue() string { + return i.ns.Label +} From 88f1753ace1043a71841cbd46d5a18b7d5ffa1f4 Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Tue, 18 Oct 2022 11:28:49 -0400 Subject: [PATCH 24/28] Adds a new spinner re-usable component --- commands/charm/list/list.go | 2 +- commands/charm/spinner/spinner.go | 90 +++++++++++++++++++++++++++++++ commands/namespaces.go | 10 ++++ 3 files changed, 101 insertions(+), 1 deletion(-) create mode 100644 commands/charm/spinner/spinner.go diff --git a/commands/charm/list/list.go b/commands/charm/list/list.go index 40fbd8f7d..4c3ff02f1 100644 --- a/commands/charm/list/list.go +++ b/commands/charm/list/list.go @@ -131,7 +131,7 @@ func (l *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return l, cmd } -// Update implements bubbletea.Model. +// View implements bubbletea.Model. func (l *listModel) View() string { return l.style.Lipgloss().Render(l.model.View()) } diff --git a/commands/charm/spinner/spinner.go b/commands/charm/spinner/spinner.go new file mode 100644 index 000000000..7866aab49 --- /dev/null +++ b/commands/charm/spinner/spinner.go @@ -0,0 +1,90 @@ +package spinner + +import ( + "fmt" + "os" + + s "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" +) + +type SpinningLoader struct { + model s.Model + prog *tea.Program + cancel bool + Message string +} + +type Option func(*SpinningLoader) + +// New creates a new spinning loader. +func New(opts ...Option) SpinningLoader { + sm := s.New() + sm.Spinner = s.Dot + + l := SpinningLoader{ + model: sm, + Message: "", + } + + for _, opt := range opts { + opt(&l) + } + return l +} + +// New creates a new list. +func (sl *SpinningLoader) Start() error { + p := tea.NewProgram((*SpinningLoader)(sl)) + sl.prog = p + p.Start() + + if sl.cancel { + os.Exit(1) + } + return nil +} + +func (sl *SpinningLoader) Stop() { + sl.prog.Kill() +} + +// Init implements bubbletea.Model. +func (sl *SpinningLoader) Init() tea.Cmd { + return sl.model.Tick +} + +// Update implements bubbletea.Model. +func (sl *SpinningLoader) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch keypress := msg.String(); keypress { + case tea.KeyCtrlC.String(): + sl.cancel = true + return sl, tea.Quit + } + + case s.TickMsg: + var cmd tea.Cmd + sl.model, cmd = sl.model.Update(msg) + return sl, cmd + } + + return sl, nil +} + +// View implements bubbletea.Model. +func (sl *SpinningLoader) View() string { + return fmt.Sprintf("%s %s", sl.model.View(), sl.Message) +} + +// Model returns the underlying SpinningLoader.model +func (sl SpinningLoader) Model() *s.Model { + return &sl.model +} + +func WithSpinner(s s.Spinner) Option { + return func(sl *SpinningLoader) { + sl.model.Spinner = s + } +} diff --git a/commands/namespaces.go b/commands/namespaces.go index 2f3646631..21e5882a2 100644 --- a/commands/namespaces.go +++ b/commands/namespaces.go @@ -19,7 +19,10 @@ import ( "sort" "strings" + s "github.com/charmbracelet/bubbles/spinner" "github.com/digitalocean/doctl" + "github.com/digitalocean/doctl/commands/charm/spinner" + "github.com/digitalocean/doctl/commands/displayers" "github.com/digitalocean/doctl/do" "github.com/spf13/cobra" @@ -226,8 +229,15 @@ func getValidRegion(value string) string { // get the Namespaces that match a pattern, where the "pattern" has no wildcards but can be a // prefix, infix, or suffix match to a namespace ID or label. func getMatchingNamespaces(ctx context.Context, ss do.ServerlessService, pattern string) ([]do.OutputNamespace, error) { + loader := spinner.New(spinner.WithSpinner(s.Dot)) + loader.Message = "Loading namespaces ..." + go loader.Start() + ans := []do.OutputNamespace{} list, err := ss.ListNamespaces(ctx) + + loader.Stop() + if err != nil { return ans, err } From 5acfef5c571e8a66bd552efe1130a0e09e3ff1ef Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Tue, 18 Oct 2022 14:45:02 -0400 Subject: [PATCH 25/28] Updates tests --- commands/charm/spinner/spinner.go | 9 +++++++-- commands/serverless_test.go | 3 --- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/commands/charm/spinner/spinner.go b/commands/charm/spinner/spinner.go index 7866aab49..f6138457e 100644 --- a/commands/charm/spinner/spinner.go +++ b/commands/charm/spinner/spinner.go @@ -37,7 +37,10 @@ func New(opts ...Option) SpinningLoader { func (sl *SpinningLoader) Start() error { p := tea.NewProgram((*SpinningLoader)(sl)) sl.prog = p - p.Start() + + if err := p.Start(); err != nil { + return err + } if sl.cancel { os.Exit(1) @@ -46,7 +49,9 @@ func (sl *SpinningLoader) Start() error { } func (sl *SpinningLoader) Stop() { - sl.prog.Kill() + if sl.prog != nil { + sl.prog.Kill() + } } // Init implements bubbletea.Model. diff --git a/commands/serverless_test.go b/commands/serverless_test.go index bcdc39e74..2f020ac6b 100644 --- a/commands/serverless_test.go +++ b/commands/serverless_test.go @@ -14,7 +14,6 @@ limitations under the License. package commands import ( - "bufio" "bytes" "context" "errors" @@ -22,7 +21,6 @@ import ( "os" "os/exec" "path/filepath" - "strings" "testing" "github.com/digitalocean/doctl/do" @@ -96,7 +94,6 @@ func TestServerlessConnect(t *testing.T) { if tt.doctlArg != "" { config.Args = append(config.Args, tt.doctlArg) } - connectChoiceReader = bufio.NewReader(strings.NewReader("0\n")) nsResponse := do.NamespaceListResponse{Namespaces: tt.namespaceList} creds := do.ServerlessCredentials{Namespace: "ns1", APIHost: "https://api.example.com"} From 056c4801f6cae1326823cebf3fce0d76eb7eb361 Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Thu, 20 Oct 2022 10:44:48 -0400 Subject: [PATCH 26/28] makes spinner message private --- commands/charm/spinner/spinner.go | 8 ++++---- commands/namespaces.go | 4 +--- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/commands/charm/spinner/spinner.go b/commands/charm/spinner/spinner.go index f6138457e..53dfe952c 100644 --- a/commands/charm/spinner/spinner.go +++ b/commands/charm/spinner/spinner.go @@ -12,19 +12,19 @@ type SpinningLoader struct { model s.Model prog *tea.Program cancel bool - Message string + message string } type Option func(*SpinningLoader) // New creates a new spinning loader. -func New(opts ...Option) SpinningLoader { +func New(message string, opts ...Option) SpinningLoader { sm := s.New() sm.Spinner = s.Dot l := SpinningLoader{ model: sm, - Message: "", + message: "", } for _, opt := range opts { @@ -80,7 +80,7 @@ func (sl *SpinningLoader) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // View implements bubbletea.Model. func (sl *SpinningLoader) View() string { - return fmt.Sprintf("%s %s", sl.model.View(), sl.Message) + return fmt.Sprintf("%s %s", sl.model.View(), sl.message) } // Model returns the underlying SpinningLoader.model diff --git a/commands/namespaces.go b/commands/namespaces.go index 21e5882a2..5b441306b 100644 --- a/commands/namespaces.go +++ b/commands/namespaces.go @@ -19,7 +19,6 @@ import ( "sort" "strings" - s "github.com/charmbracelet/bubbles/spinner" "github.com/digitalocean/doctl" "github.com/digitalocean/doctl/commands/charm/spinner" @@ -229,8 +228,7 @@ func getValidRegion(value string) string { // get the Namespaces that match a pattern, where the "pattern" has no wildcards but can be a // prefix, infix, or suffix match to a namespace ID or label. func getMatchingNamespaces(ctx context.Context, ss do.ServerlessService, pattern string) ([]do.OutputNamespace, error) { - loader := spinner.New(spinner.WithSpinner(s.Dot)) - loader.Message = "Loading namespaces ..." + loader := spinner.New("Loading namespaces ...") go loader.Start() ans := []do.OutputNamespace{} From e98354d428444259e551cf474473141358527e15 Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Thu, 20 Oct 2022 18:52:00 -0400 Subject: [PATCH 27/28] Updates command to respect the interactive flag --- commands/charm/spinner/spinner.go | 4 ++-- commands/namespaces.go | 15 +++++++++++---- commands/serverless.go | 27 +++++++++++++++------------ commands/serverless_test.go | 2 +- 4 files changed, 29 insertions(+), 19 deletions(-) diff --git a/commands/charm/spinner/spinner.go b/commands/charm/spinner/spinner.go index 53dfe952c..42679b5f4 100644 --- a/commands/charm/spinner/spinner.go +++ b/commands/charm/spinner/spinner.go @@ -24,7 +24,7 @@ func New(message string, opts ...Option) SpinningLoader { l := SpinningLoader{ model: sm, - message: "", + message: message, } for _, opt := range opts { @@ -33,7 +33,7 @@ func New(message string, opts ...Option) SpinningLoader { return l } -// New creates a new list. +// New creates a new spinner. func (sl *SpinningLoader) Start() error { p := tea.NewProgram((*SpinningLoader)(sl)) sl.prog = p diff --git a/commands/namespaces.go b/commands/namespaces.go index 5b441306b..df171353a 100644 --- a/commands/namespaces.go +++ b/commands/namespaces.go @@ -20,8 +20,8 @@ import ( "strings" "github.com/digitalocean/doctl" - "github.com/digitalocean/doctl/commands/charm/spinner" + "github.com/digitalocean/doctl/commands/charm/spinner" "github.com/digitalocean/doctl/commands/displayers" "github.com/digitalocean/doctl/do" "github.com/spf13/cobra" @@ -228,20 +228,27 @@ func getValidRegion(value string) string { // get the Namespaces that match a pattern, where the "pattern" has no wildcards but can be a // prefix, infix, or suffix match to a namespace ID or label. func getMatchingNamespaces(ctx context.Context, ss do.ServerlessService, pattern string) ([]do.OutputNamespace, error) { - loader := spinner.New("Loading namespaces ...") - go loader.Start() + var loader spinner.SpinningLoader + if Interactive { + loader = spinner.New("Loading namespaces ...") + go loader.Start() + } ans := []do.OutputNamespace{} list, err := ss.ListNamespaces(ctx) - loader.Stop() + if Interactive { + loader.Stop() + } if err != nil { return ans, err } + if pattern == "" { return list.Namespaces, nil } + for _, ns := range list.Namespaces { if strings.Contains(ns.Namespace, pattern) || strings.Contains(ns.Label, pattern) { ans = append(ans, ns) diff --git a/commands/serverless.go b/commands/serverless.go index 63a1143a5..4acea3609 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -242,11 +242,8 @@ func RunServerlessConnect(c *CmdConfig) error { if err != nil { return err } - if len(list) == 0 { return fmt.Errorf("you have no namespaces matching '%s'", c.Args[0]) - } else if len(list) == 1 { - } return connectFromList(ctx, sls, list, c.Out) } @@ -264,6 +261,18 @@ func RunServerlessConnect(c *CmdConfig) error { // singular that determines the namespace that will be connected. Otherwise, this is determined // via a prompt. func connectFromList(ctx context.Context, sls do.ServerlessService, l []do.OutputNamespace, out io.Writer) error { + if len(l) == 1 { + creds, err := sls.GetNamespace(ctx, l[0].Namespace) + if err != nil { + return err + } + return finishConnecting(sls, creds, l[0].Label, out) + } + + if !Interactive { + return errors.New("Namespace is required when running non-interactively") + } + var nsItems []list.Item for _, ns := range l { @@ -274,15 +283,9 @@ func connectFromList(ctx context.Context, sls do.ServerlessService, l []do.Outpu listItems.Model().Title = "select a namespace" listItems.Model().SetStatusBarItemName("namespace", "namespaces") - var selected list.Item - if len(nsItems) == 1 { - selected = nsItems[0] - } else { - var err error - selected, err = listItems.Select() - if err != nil { - return err - } + selected, err := listItems.Select() + if err != nil { + return err } selectedNs := selected.(nsListItem).ns diff --git a/commands/serverless_test.go b/commands/serverless_test.go index 2f020ac6b..9d5f83754 100644 --- a/commands/serverless_test.go +++ b/commands/serverless_test.go @@ -66,7 +66,7 @@ func TestServerlessConnect(t *testing.T) { Label: "another", }, }, - expectedOutput: "0: ns1 in nyc1, label=something\n1: ns2 in lon1, label=another\nChoose a namespace by number or 'x' to exit\nConnected to functions namespace 'ns1' on API host 'https://api.example.com' (label=something)\n\n", + expectedError: errors.New("Namespace is required when running non-interactively"), }, { name: "use argument", From 9a9c47de49243b8ef06e2e6296669f6207438d46 Mon Sep 17 00:00:00 2001 From: Davi DeBarros Date: Tue, 1 Nov 2022 10:53:57 -0400 Subject: [PATCH 28/28] Updates interactive check --- commands/doit.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/commands/doit.go b/commands/doit.go index cfcdce787..36a742124 100644 --- a/commands/doit.go +++ b/commands/doit.go @@ -307,5 +307,8 @@ func cmdNS(cmd *cobra.Command) string { } func isTerminal(f *os.File) bool { + if os.Getenv("TERM") == "dumb" { + return false + } return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd()) }