Skip to content
Permalink
Browse files
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 <[email protected]>
  • Loading branch information
joshuaauerbachwatson and andrewsomething committed Sep 27, 2022
1 parent 1d61603 commit ac179f5a6a718188425db24ac54fd44be6981439
Show file tree
Hide file tree
Showing 25 changed files with 1,008 additions and 101 deletions.
@@ -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
@@ -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)
@@ -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
}
@@ -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,31 +272,38 @@ 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
}
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
}
@@ -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!"
}
@@ -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,21 +370,36 @@ 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
}
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)

0 comments on commit ac179f5

Please sign in to comment.