From ac179f5a6a718188425db24ac54fd44be6981439 Mon Sep 17 00:00:00 2001 From: Joshua Auerbach Date: Tue, 27 Sep 2022 13:57:19 -0400 Subject: [PATCH] Latest improvements to serverless (#1255) * Add support for typescript in the nodejs runtime (#1225) * Eliminate plugin usage for sls fn invoke (#1226) * 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. Co-authored-by: Andrew Starr-Bochicchio --- commands/command_config.go | 2 +- commands/displayers/functions.go | 6 +- commands/displayers/triggers.go | 71 ++++++ commands/functions.go | 60 +++-- commands/functions_test.go | 65 ++--- commands/serverless.go | 44 +++- commands/serverless_test.go | 48 +++- commands/serverless_util.go | 8 +- commands/triggers.go | 98 ++++++++ commands/triggers_test.go | 147 +++++++++++ do/mocks/ServerlessService.go | 102 ++++++++ do/serverless.go | 229 ++++++++++++++++-- go.mod | 1 + 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 + 25 files changed, 1008 insertions(+), 101 deletions(-) create mode 100644 commands/displayers/triggers.go create mode 100644 commands/triggers.go create mode 100644 commands/triggers_test.go 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/command_config.go b/commands/command_config.go index 9698ff1c8..ae93807ff 100644 --- a/commands/command_config.go +++ b/commands/command_config.go @@ -120,7 +120,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/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/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/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/commands/serverless.go b/commands/serverless.go index 56c698494..134e5acc4 100644 --- a/commands/serverless.go +++ b/commands/serverless.go @@ -35,21 +35,23 @@ 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. 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"}, } ) @@ -106,11 +108,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 } @@ -365,6 +370,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 @@ -372,14 +378,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 f650e00f9..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() @@ -200,6 +243,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() @@ -215,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() @@ -245,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 9ff9ad65b..3d45b2890 100644 --- a/do/serverless.go +++ b/do/serverless.go @@ -15,12 +15,15 @@ package do import ( "context" + "crypto/sha1" + "encoding/hex" "encoding/json" "errors" "fmt" "io" "io/ioutil" "net/http" + "net/url" "os" "os/exec" "path/filepath" @@ -31,6 +34,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 +116,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"` @@ -191,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 { @@ -202,12 +208,17 @@ 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) 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) @@ -219,6 +230,7 @@ type serverlessService struct { credsDir string node string userAgent string + accessToken string client *godo.Client owClient *whisk.Client } @@ -227,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.1.0-1.3.1" + minServerlessVersion = "4.2.6-1.3.1" // The version of nodejs to download alongsize the plugin download. nodeVersion = "v16.13.0" @@ -291,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" @@ -303,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, @@ -311,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 { @@ -495,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") != "" { @@ -674,6 +697,62 @@ func (s *serverlessService) GetFunction(name string, fetchCode bool) (whisk.Acti return *action, parameters, nil } +// 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) @@ -704,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" diff --git a/go.mod b/go.mod index 09ed802ae..6779c0454 100644 --- a/go.mod +++ b/go.mod @@ -47,6 +47,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/armon/circbuf v0.0.0-20190214190532-5111143e8da2 github.com/charmbracelet/bubbles v0.13.1-0.20220731172002-8f6516082803 github.com/charmbracelet/bubbletea v0.22.0 diff --git a/go.sum b/go.sum index 21abb93a3..c86db254f 100644 --- a/go.sum +++ b/go.sum @@ -699,6 +699,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= @@ -1074,6 +1076,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 734895cb8..cdb6c89da 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -332,6 +332,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