From 46597347fff71a2cc9d155189c5ff9281f9c1c13 Mon Sep 17 00:00:00 2001
From: lijianguo <lijianguo@zhumengyuan.com>
Date: Wed, 9 Sep 2020 17:47:18 +0800
Subject: [PATCH] feat(controller):add drycc resource cmd

---
 cmd/cmd.go               |   7 ++
 cmd/resources.go         | 266 +++++++++++++++++++++++++++++++++++++++
 cmd/resources_test.go    | 246 ++++++++++++++++++++++++++++++++++++
 drycc.go                 |   3 +
 go.mod                   |   2 +-
 go.sum                   |   2 +
 parser/resources.go      | 260 ++++++++++++++++++++++++++++++++++++++
 parser/resources_test.go | 106 ++++++++++++++++
 8 files changed, 891 insertions(+), 1 deletion(-)
 create mode 100644 cmd/resources.go
 create mode 100644 cmd/resources_test.go
 create mode 100644 parser/resources.go
 create mode 100644 parser/resources_test.go

diff --git a/cmd/cmd.go b/cmd/cmd.go
index a400bee05..d82906d9d 100644
--- a/cmd/cmd.go
+++ b/cmd/cmd.go
@@ -104,6 +104,13 @@ type Commander interface {
 	VolumesList(string, int) error
 	VolumesMount(string, string, []string) error
 	VolumesUnmount(string, string, []string) error
+	ResourcesCreate(string, string, string, []string) error
+	ResourcesList(string, int) error
+	ResourceDelete(string, string) error
+	ResourceGet(string, string) error
+	ResourcePut(string, string, string, []string) error
+	ResourceBind(string, string) error
+	ResourceUnbind(string, string) error
 }
 
 // DryccCmd is an implementation of Commander.
diff --git a/cmd/resources.go b/cmd/resources.go
new file mode 100644
index 000000000..a03595053
--- /dev/null
+++ b/cmd/resources.go
@@ -0,0 +1,266 @@
+package cmd
+
+import (
+	"fmt"
+	"github.com/drycc/controller-sdk-go/api"
+	"github.com/drycc/controller-sdk-go/resources"
+	"github.com/drycc/pkg/prettyprint"
+	"io"
+	"regexp"
+	"strings"
+)
+
+// ResourcesCreate create a resource for the application
+func (d *DryccCmd) ResourcesCreate(appID, plan string, name string, params []string) error {
+	s, appID, err := load(d.ConfigFile, appID)
+
+	if err != nil {
+		return err
+	}
+
+	d.Printf("Creating %s to %s... ", name, appID)
+
+	paramsMap, err := parseParams(params)
+	if err != nil {
+		return err
+	}
+
+	quit := progress(d.WOut)
+	resource := api.Resource{
+		Name:    name,
+		Plan:    plan,
+		Options: paramsMap,
+	}
+	_, err = resources.Create(s.Client, appID, resource)
+	quit <- true
+	<-quit
+	if d.checkAPICompatibility(s.Client, err) != nil {
+		return err
+	}
+
+	d.Println("done")
+	return nil
+}
+
+// ResourcesList list resources in the application
+func (d *DryccCmd) ResourcesList(appID string, results int) error {
+	s, appID, err := load(d.ConfigFile, appID)
+
+	if err != nil {
+		return err
+	}
+
+	if results == defaultLimit {
+		results = s.Limit
+	}
+	resources, count, err := resources.List(s.Client, appID, results)
+	if d.checkAPICompatibility(s.Client, err) != nil {
+		return err
+	}
+
+	if count == 0 {
+		d.Println("Could not find any resources")
+	} else {
+		printResources(d, appID, resources, d.WOut)
+	}
+	return nil
+}
+
+// ResourceGet describe a resource from the application
+func (d *DryccCmd) ResourceGet(appID, name string) error {
+	s, appID, err := load(d.ConfigFile, appID)
+
+	if err != nil {
+		return err
+	}
+
+	//d.Printf(" %s from %s... ", name, appID)
+
+	resource, err := resources.Get(s.Client, appID, name)
+	if d.checkAPICompatibility(s.Client, err) != nil {
+		return err
+	}
+	// todo format data json to yaml
+	printResourceDetail(d, appID, resource, d.WOut)
+	//d.Println(resource)
+	return nil
+}
+
+// ResourceDelete delete a resource from the application
+func (d *DryccCmd) ResourceDelete(appID, name string) error {
+	s, appID, err := load(d.ConfigFile, appID)
+
+	if err != nil {
+		return err
+	}
+
+	d.Printf("Deleting %s from %s... ", name, appID)
+
+	quit := progress(d.WOut)
+	err = resources.Delete(s.Client, appID, name)
+	quit <- true
+	<-quit
+	if d.checkAPICompatibility(s.Client, err) != nil {
+		return err
+	}
+
+	d.Println("done")
+	return nil
+}
+
+// ResourcePut update a resource for the application
+func (d *DryccCmd) ResourcePut(appID, plan string, name string, params []string) error {
+	s, appID, err := load(d.ConfigFile, appID)
+
+	if err != nil {
+		return err
+	}
+
+	d.Printf("Updating %s to %s... ", name, appID)
+
+	paramsMap, err := parseParams(params)
+	if err != nil {
+		return err
+	}
+
+	quit := progress(d.WOut)
+	resource := api.Resource{
+		Plan:    plan,
+		Options: paramsMap,
+	}
+	_, err = resources.Put(s.Client, appID, name, resource)
+	quit <- true
+	<-quit
+	if d.checkAPICompatibility(s.Client, err) != nil {
+		return err
+	}
+
+	d.Println("done")
+	return nil
+}
+
+// ResourceBind mount a resource to process of the application
+func (d *DryccCmd) ResourceBind(appID string, name string) error {
+	s, appID, err := load(d.ConfigFile, appID)
+
+	if err != nil {
+		return err
+	}
+
+	d.Print("Binding resource... ")
+
+	quit := progress(d.WOut)
+	bindAction := api.Binding{BindAction: "bind"}
+	_, err = resources.Binding(s.Client, appID, name, bindAction)
+	quit <- true
+	<-quit
+	if d.checkAPICompatibility(s.Client, err) != nil {
+		return err
+	}
+
+	d.Print("done\n")
+
+	return nil
+}
+
+// ResourceUnbind resource a resource the application
+func (d *DryccCmd) ResourceUnbind(appID string, name string) error {
+	s, appID, err := load(d.ConfigFile, appID)
+
+	if err != nil {
+		return err
+	}
+
+	d.Print("Unbinding resource... ")
+
+	quit := progress(d.WOut)
+	bindAction := api.Binding{BindAction: "unbind"}
+	_, err = resources.Binding(s.Client, appID, name, bindAction)
+	quit <- true
+	<-quit
+	if d.checkAPICompatibility(s.Client, err) != nil {
+		return err
+	}
+
+	d.Print("done\n")
+
+	return nil
+}
+
+// printResources format Resources data
+func printResources(d *DryccCmd, appID string, resources api.Resources, wOut io.Writer) {
+
+	fmt.Fprintf(wOut, "=== %s resources\n", appID)
+
+	for _, resource := range resources {
+		fmt.Fprintf(wOut, "%s\t%s\n", resource.Name, resource.Plan)
+	}
+}
+
+func printResourceDetail(d *DryccCmd, appID string, resource api.Resource, wOut io.Writer) {
+	d.Printf("=== %s resource %s\n", appID, resource.Name)
+
+	dataMap := make(map[string]string)
+	for key, value := range resource.Data {
+		dataMap[key+":"] = fmt.Sprintf("%v", value)
+	}
+	optionsMap := make(map[string]string)
+	for key, value := range resource.Options {
+		optionsMap[key+":"] = fmt.Sprintf("%v", value)
+	}
+	lenDataMap := maxLen(dataMap)
+	lenOptionsMap := maxLen(optionsMap)
+	tempArray := []int{lenDataMap, lenOptionsMap, len("binding:")}
+	max := maxNum(tempArray...) + 5
+	d.Print(fmt.Sprintf("plan:%s%s\n", strings.Repeat(" ", max-len("plan:")), resource.Plan))
+	d.Print(fmt.Sprintf("status:%s%s\n", strings.Repeat(" ", max-len("status:")), resource.Status))
+	d.Print(fmt.Sprintf("binding:%s%s\n", strings.Repeat(" ", max-len("binding:")), resource.Binding))
+
+	if lenDataMap != 0 {
+		d.Println()
+		d.Print(prettyprint.PrettyTabs(dataMap, max-lenDataMap))
+	}
+	if lenOptionsMap != 0 {
+		d.Println()
+		d.Print(prettyprint.PrettyTabs(optionsMap, max-lenOptionsMap))
+	}
+}
+
+func maxLen(msg map[string]string) int {
+	// find the longest key so we know how much padding to use
+	max := 0
+	for key := range msg {
+		if len(key) > max {
+			max = len(key)
+		}
+	}
+	return max
+}
+
+func maxNum(tempArray ...int) int {
+	// find the longest num so we know how much padding to use
+	max := 0
+	for _, temp := range tempArray {
+		if max < temp {
+			max = temp
+		}
+	}
+	return max
+}
+
+// parseParams transfer params to map
+func parseParams(params []string) (map[string]interface{}, error) {
+	paramsMap := make(map[string]interface{})
+
+	regex := regexp.MustCompile(`^([A-z_]+[A-z0-9_]*)=([\s\S]*)$`)
+	for _, param := range params {
+		if regex.MatchString(param) {
+			captures := regex.FindStringSubmatch(param)
+			paramsMap[captures[1]] = captures[2]
+		} else {
+			return nil, fmt.Errorf("'%s' does not match the pattern 'key=var', ex: MODE=test", param)
+		}
+	}
+
+	return paramsMap, nil
+}
diff --git a/cmd/resources_test.go b/cmd/resources_test.go
new file mode 100644
index 000000000..7083a58f8
--- /dev/null
+++ b/cmd/resources_test.go
@@ -0,0 +1,246 @@
+package cmd
+
+import (
+	"bytes"
+	"fmt"
+	"github.com/drycc/controller-sdk-go/api"
+	"net/http"
+	"testing"
+
+	"github.com/arschles/assert"
+	"github.com/drycc/workflow-cli/pkg/testutil"
+)
+
+func TestResourcesCreate(t *testing.T) {
+	t.Parallel()
+	cf, server, err := testutil.NewTestServerAndClient()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer server.Close()
+	var b bytes.Buffer
+	cmdr := DryccCmd{WOut: &b, ConfigFile: cf}
+
+	server.Mux.HandleFunc("/v2/apps/example-go/resources/", func(w http.ResponseWriter, r *http.Request) {
+		testutil.AssertBody(t, api.Resource{Name: "mysql", Plan: "mysql:5.6"}, r)
+		testutil.SetHeaders(w)
+		w.WriteHeader(http.StatusCreated)
+		// Body isn't used by CLI, so it isn't set.
+		w.Write([]byte("{}"))
+	})
+
+	err = cmdr.ResourcesCreate("example-go", "mysql:5.6", "mysql", nil)
+	assert.NoErr(t, err)
+
+	assert.Equal(t, testutil.StripProgress(b.String()), "Creating mysql to example-go... done\n", "output")
+}
+
+func TestResourcesList(t *testing.T) {
+	t.Parallel()
+	cf, server, err := testutil.NewTestServerAndClient()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer server.Close()
+	var b bytes.Buffer
+	cmdr := DryccCmd{WOut: &b, ConfigFile: cf}
+
+	server.Mux.HandleFunc("/v2/apps/example-go/resources/", func(w http.ResponseWriter, r *http.Request) {
+		testutil.SetHeaders(w)
+		fmt.Fprintf(w, `{
+   "count": 1,
+   "next": null,
+   "previous": null,
+   "results": [
+		{
+			"uuid": "de1bf5b5-4a72-4f94-a10c-d2a3741cdf75",
+			"owner": "test",
+			"app": "example-go",
+			"name": "mysql",
+			"plan": "mysql:5.6",
+			"data": {},
+			"options": {},
+			"status": null,
+			"binding": null,
+			"created": "2020-09-08T00:00:00UTC",
+			"updated": "2020-09-08T00:00:00UTC"
+		}
+   ]
+}`)
+	})
+
+	err = cmdr.ResourcesList("example-go", -1)
+	assert.NoErr(t, err)
+
+	assert.Equal(t, b.String(), `=== example-go resources
+mysql	mysql:5.6
+`, "output")
+}
+
+func TestResourceGet(t *testing.T) {
+	t.Parallel()
+	cf, server, err := testutil.NewTestServerAndClient()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer server.Close()
+	var b bytes.Buffer
+	cmdr := DryccCmd{WOut: &b, ConfigFile: cf}
+
+	server.Mux.HandleFunc("/v2/apps/example-go/resources/mysql/", func(w http.ResponseWriter, r *http.Request) {
+		testutil.SetHeaders(w)
+		fmt.Fprintf(w, `{
+			"uuid": "de1bf5b5-4a72-4f94-a10c-d2a3741cdf75",
+			"owner": "test",
+			"app": "example-go",
+			"name": "mysql",
+			"plan": "mysql:5.6",
+			"data": {"data12":"value1","data3":"value1"},
+			"options": {"para13451":"value2","para122":"value1"},
+			"status": "Ready",
+			"binding": "Ready",
+			"created": "2020-09-08T00:00:00UTC",
+			"updated": "2020-09-08T00:00:00UTC"
+}`)
+	})
+
+	err = cmdr.ResourceGet("example-go", "mysql")
+	assert.NoErr(t, err)
+	// todo format data json to yaml
+	assert.Equal(t, b.String(), `=== example-go resource mysql
+plan:          mysql:5.6
+status:        Ready
+binding:       Ready
+
+data12:        value1
+data3:         value1
+
+para122:       value1
+para13451:     value2
+`, "output")
+}
+
+func TestResourceDelete(t *testing.T) {
+	t.Parallel()
+	cf, server, err := testutil.NewTestServerAndClient()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer server.Close()
+	var b bytes.Buffer
+	cmdr := DryccCmd{WOut: &b, ConfigFile: cf}
+
+	server.Mux.HandleFunc("/v2/apps/example-go/resources/mysql/", func(w http.ResponseWriter, r *http.Request) {
+		testutil.SetHeaders(w)
+		w.WriteHeader(http.StatusNoContent)
+	})
+
+	err = cmdr.ResourceDelete("example-go", "mysql")
+	assert.NoErr(t, err)
+
+	assert.Equal(t, testutil.StripProgress(b.String()), "Deleting mysql from example-go... done\n", "output")
+}
+
+func TestResourcePut(t *testing.T) {
+	t.Parallel()
+	cf, server, err := testutil.NewTestServerAndClient()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer server.Close()
+	var b bytes.Buffer
+	cmdr := DryccCmd{WOut: &b, ConfigFile: cf}
+
+	server.Mux.HandleFunc("/v2/apps/example-go/resources/mysql/", func(w http.ResponseWriter, r *http.Request) {
+		testutil.AssertBody(t, api.Resource{Plan: "mysql:5.7"}, r)
+		testutil.SetHeaders(w)
+		w.WriteHeader(http.StatusCreated)
+		// Body isn't used by CLI, so it isn't set.
+		w.Write([]byte("{}"))
+	})
+
+	err = cmdr.ResourcePut("example-go", "mysql:5.7", "mysql", nil)
+	assert.NoErr(t, err)
+
+	assert.Equal(t, testutil.StripProgress(b.String()), "Updating mysql to example-go... done\n", "output")
+}
+
+func TestResourceBind(t *testing.T) {
+	t.Parallel()
+	cf, server, err := testutil.NewTestServerAndClient()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer server.Close()
+
+	server.Mux.HandleFunc("/v2/apps/example-go/resources/mysql/binding/", func(w http.ResponseWriter, r *http.Request) {
+		testutil.SetHeaders(w)
+		if r.Method == "PATCH" {
+			testutil.AssertBody(t, api.Binding{
+				BindAction: "bind",
+			}, r)
+		}
+		fmt.Fprintf(w, `{
+			"uuid": "de1bf5b5-4a72-4f94-a10c-d2a3741cdf75",
+			"owner": "test",
+			"app": "example-go",
+			"name": "mysql",
+			"plan": "mysql:5.7",
+			"data": {},
+			"options": {},
+			"status": null,
+			"binding": null,
+			"created": "2020-09-08T00:00:00UTC",
+			"updated": "2020-09-08T00:00:00UTC"
+}`)
+	})
+
+	var b bytes.Buffer
+	cmdr := DryccCmd{WOut: &b, ConfigFile: cf}
+
+	err = cmdr.ResourceBind("example-go", "mysql")
+	assert.NoErr(t, err)
+
+	assert.Equal(t, testutil.StripProgress(b.String()), `Binding resource... done
+`, "output")
+}
+
+func TestResourceUnbind(t *testing.T) {
+	t.Parallel()
+	cf, server, err := testutil.NewTestServerAndClient()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer server.Close()
+
+	server.Mux.HandleFunc("/v2/apps/example-go/resources/mysql/binding/", func(w http.ResponseWriter, r *http.Request) {
+		testutil.SetHeaders(w)
+		if r.Method == "PATCH" {
+			testutil.AssertBody(t, api.Binding{
+				BindAction: "unbind",
+			}, r)
+		}
+		fmt.Fprintf(w, `{
+			"uuid": "de1bf5b5-4a72-4f94-a10c-d2a3741cdf75",
+			"owner": "test",
+			"app": "example-go",
+			"name": "mysql",
+			"plan": "mysql:5.7",
+			"data": {},
+			"options": {},
+			"status": null,
+			"binding": null,
+			"created": "2020-09-08T00:00:00UTC",
+			"updated": "2020-09-08T00:00:00UTC"
+}`)
+	})
+
+	var b bytes.Buffer
+	cmdr := DryccCmd{WOut: &b, ConfigFile: cf}
+
+	err = cmdr.ResourceUnbind("example-go", "mysql")
+	assert.NoErr(t, err)
+
+	assert.Equal(t, testutil.StripProgress(b.String()), `Unbinding resource... done
+`, "output")
+}
diff --git a/drycc.go b/drycc.go
index daec9ffdc..4ee522163 100644
--- a/drycc.go
+++ b/drycc.go
@@ -71,6 +71,7 @@ Subcommands, use 'drycc help [subcommand]' to learn more::
   services      manage services for your applications
   timeouts      manage pods termination grace period
   volumes       manage volumes for your applications
+  resources     manage resources for your applications
 
 Shortcut commands, use 'drycc shortcuts' to see all::
 
@@ -163,6 +164,8 @@ Use 'git push drycc master' to deploy to an application.
 		err = parser.Whitelist(argv, &cmdr)
 	case "volumes":
 		err = parser.Volumes(argv, &cmdr)
+	case "resources":
+		err = parser.Resources(argv, &cmdr)
 	default:
 		env := os.Environ()
 
diff --git a/go.mod b/go.mod
index 60fbf350a..5f226d611 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.13
 require (
 	github.com/arschles/assert v1.0.1-0.20191213221312-71f210f9375a
 	github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815
-	github.com/drycc/controller-sdk-go v0.0.0-20200901021526-471d3aa9862b
+	github.com/drycc/controller-sdk-go v0.0.0-20200910051502-0b84631ba397
 	github.com/drycc/pkg v0.0.0-20200811173146-1f2b2781a852
 	github.com/olekukonko/tablewriter v0.0.4
 	golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de
diff --git a/go.sum b/go.sum
index b79c13b96..ae6684bac 100644
--- a/go.sum
+++ b/go.sum
@@ -36,6 +36,8 @@ github.com/drycc/controller-sdk-go v0.0.0-20200813035713-e73ce53533e7 h1:8tlFlmR
 github.com/drycc/controller-sdk-go v0.0.0-20200813035713-e73ce53533e7/go.mod h1:mF8/kZ/E4oDHCtfUVS790Frm8oVzZkoCGCKYT2eWQro=
 github.com/drycc/controller-sdk-go v0.0.0-20200901021526-471d3aa9862b h1:scZpGVSBX32HcCQcqwGmH1TKuy1xEczyF0fDH0XP1xA=
 github.com/drycc/controller-sdk-go v0.0.0-20200901021526-471d3aa9862b/go.mod h1:mF8/kZ/E4oDHCtfUVS790Frm8oVzZkoCGCKYT2eWQro=
+github.com/drycc/controller-sdk-go v0.0.0-20200910051502-0b84631ba397 h1:MbpNwDOmS0+YxR0S8AsmygAj2qsOy9PjmLUzI76OhZs=
+github.com/drycc/controller-sdk-go v0.0.0-20200910051502-0b84631ba397/go.mod h1:mF8/kZ/E4oDHCtfUVS790Frm8oVzZkoCGCKYT2eWQro=
 github.com/drycc/pkg v0.0.0-20190129033019-bcdffff0fcb3 h1:QRAbL97/hJecXQzaSONMCiwt1JjBKAAMKK0qJW5sgIM=
 github.com/drycc/pkg v0.0.0-20190129033019-bcdffff0fcb3/go.mod h1:zNgTT6kuGZOKMIdSrjskIw5fctdbbszqiTPEt3ffxHw=
 github.com/drycc/pkg v0.0.0-20200811173146-1f2b2781a852 h1:OsWH7jrv2i32rkvHFuj3yVpGMR+A6heRge9tm01XR/U=
diff --git a/parser/resources.go b/parser/resources.go
new file mode 100644
index 000000000..9432315af
--- /dev/null
+++ b/parser/resources.go
@@ -0,0 +1,260 @@
+package parser
+
+import (
+	docopt "github.com/docopt/docopt-go"
+	"github.com/drycc/workflow-cli/cmd"
+)
+
+// Resources commands to their specific function.
+func Resources(argv []string, cmdr cmd.Commander) error {
+	usage := `
+Valid commands for resources:
+
+resources:create           create a resource for the application
+resources:list             list resources in the application
+resources:describe         get a resource detail info in the application
+resources:update           update a resource from the application
+resources:destroy          delete a resource from the applicationa
+resources:bind             bind a resource to servicebroker
+resources:unbind           unbind a resource from servicebroker
+
+Use 'drycc help [command]' to learn more.
+`
+
+	switch argv[0] {
+	case "resources:create":
+		return resourcesCreate(argv, cmdr)
+	case "resources:list":
+		return resourcesList(argv, cmdr)
+	case "resources:describe":
+		return resourceGet(argv, cmdr)
+	case "resources:update":
+		return resourcePut(argv, cmdr)
+	case "resources:destroy":
+		return resourceDelete(argv, cmdr)
+	case "resources:bind":
+		return resourceBind(argv, cmdr)
+	case "resources:unbind":
+		return resourceUnbind(argv, cmdr)
+	default:
+		if printHelp(argv, usage) {
+			return nil
+		}
+
+		if argv[0] == "resources" {
+			argv[0] = "resources:list"
+			return resourcesList(argv, cmdr)
+		}
+
+		PrintUsage(cmdr)
+		return nil
+	}
+}
+
+func resourcesCreate(argv []string, cmdr cmd.Commander) error {
+	usage := `
+Create a resource for the application.
+
+Usage: drycc resources:create <plan> <name> [<param>=<value>...] [options]
+
+Arguments:
+  <plan>
+    the resource's plan.
+  <name>
+    this resource instance alias.
+  <param>
+    the resource instance parameters key.
+  <value>
+    the resource instance parameters value.
+
+Options:
+  -a --app=<app>
+    the uniquely identifiable name for the application.
+`
+
+	args, err := docopt.Parse(usage, argv, true, "", false, true)
+
+	if err != nil {
+		return err
+	}
+
+	app := safeGetValue(args, "--app")
+	plan := safeGetValue(args, "<plan>")
+	name := safeGetValue(args, "<name>")
+
+	return cmdr.ResourcesCreate(app, plan, name, args["<param>=<value>"].([]string))
+}
+
+func resourcesList(argv []string, cmdr cmd.Commander) error {
+	usage := `
+List resources in the application.
+
+Usage: drycc resources:list [options]
+
+Options:
+  -a --app=<app>
+    the uniquely identifiable name for the application.
+  -l --limit=<num>
+    the maximum number of results to display, defaults to config setting
+`
+
+	args, err := docopt.Parse(usage, argv, true, "", false, true)
+
+	if err != nil {
+		return err
+	}
+
+	results, err := responseLimit(safeGetValue(args, "--limit"))
+
+	if err != nil {
+		return err
+	}
+	app := safeGetValue(args, "--app")
+
+	return cmdr.ResourcesList(app, results)
+}
+
+func resourceGet(argv []string, cmdr cmd.Commander) error {
+	usage := `
+get a resource's detail in the application.
+
+Usage: drycc resources:describe <name> [options]
+
+Arguments:
+  <name>
+    this resource instance alias.
+
+Options:
+  -a --app=<app>
+    the uniquely identifiable name for the application.
+  -l --limit=<num>
+    the maximum number of results to display, defaults to config setting
+`
+
+	args, err := docopt.Parse(usage, argv, true, "", false, true)
+
+	if err != nil {
+		return err
+	}
+
+	app := safeGetValue(args, "--app")
+	name := safeGetValue(args, "<name>")
+
+	return cmdr.ResourceGet(app, name)
+}
+
+func resourcePut(argv []string, cmdr cmd.Commander) error {
+
+	usage := `
+update a resource from the application
+
+Usage: drycc resources:update <plan> <name> [<param>=<value>...] [options]
+
+Arguments:
+  <plan>
+    the resource's plan.
+  <name>
+    this resource instance alias.
+  <param>
+    the resource instance parameters key.
+  <value>
+    the resource instance parameters value.
+
+Options:
+  -a --app=<app>
+    the uniquely identifiable name for the application.
+`
+
+	args, err := docopt.Parse(usage, argv, true, "", false, true)
+
+	if err != nil {
+		return err
+	}
+
+	app := safeGetValue(args, "--app")
+	plan := safeGetValue(args, "<plan>")
+	name := safeGetValue(args, "<name>")
+
+	return cmdr.ResourcePut(app, plan, name, args["<param>=<value>"].([]string))
+}
+
+func resourceDelete(argv []string, cmdr cmd.Commander) error {
+
+	usage := `
+Delete a resource from the application.
+
+Usage: drycc resources:destroy <name> [options]
+
+Arguments:
+  <name>
+    the resource instance alias name to be removed.
+
+Options:
+  -a --app=<app>
+    the uniquely identifiable name for the application.
+`
+
+	args, err := docopt.Parse(usage, argv, true, "", false, true)
+
+	if err != nil {
+		return err
+	}
+
+	app := safeGetValue(args, "--app")
+	name := safeGetValue(args, "<name>")
+
+	return cmdr.ResourceDelete(app, name)
+}
+
+func resourceBind(argv []string, cmdr cmd.Commander) error {
+	usage := `
+bind a resource for an application.
+
+Usage: drycc resources:bind <name> [options]
+
+Arguments:
+  <name>
+    the resource instance alias name.
+
+Options:
+  -a --app=<app>
+    the uniquely identifiable name for the application.
+`
+
+	args, err := docopt.Parse(usage, argv, true, "", false, true)
+
+	if err != nil {
+		return err
+	}
+
+	app := safeGetValue(args, "--app")
+	name := safeGetValue(args, "<name>")
+
+	return cmdr.ResourceBind(app, name)
+}
+
+func resourceUnbind(argv []string, cmdr cmd.Commander) error {
+	usage := `
+unbind a resources for an application.
+
+Usage: drycc resources:unbind <name> [options]
+
+Arguments:
+  <name>
+    the resource instance alias name.
+
+Options:
+  -a --app=<app>
+    the uniquely identifiable name for the application.
+`
+	args, err := docopt.Parse(usage, argv, true, "", false, true)
+
+	if err != nil {
+		return err
+	}
+
+	app := safeGetValue(args, "--app")
+	name := safeGetValue(args, "<name>")
+
+	return cmdr.ResourceUnbind(app, name)
+}
diff --git a/parser/resources_test.go b/parser/resources_test.go
new file mode 100644
index 000000000..06bd77c23
--- /dev/null
+++ b/parser/resources_test.go
@@ -0,0 +1,106 @@
+package parser
+
+import (
+	"bytes"
+	"errors"
+	"testing"
+
+	"github.com/arschles/assert"
+	"github.com/drycc/workflow-cli/pkg/testutil"
+)
+
+// Create fake implementations of each method that return the argument
+// we expect to have called the function (as an error to satisfy the interface).
+
+func (d FakeDryccCmd) ResourcesCreate(string, string, string, []string) error {
+	return errors.New("resources:create")
+}
+
+func (d FakeDryccCmd) ResourcesList(string, int) error {
+	return errors.New("resources:list")
+}
+
+func (d FakeDryccCmd) ResourceGet(string, string) error {
+	return errors.New("resources:describe")
+}
+
+func (d FakeDryccCmd) ResourcePut(string, string, string, []string) error {
+	return errors.New("resources:update")
+}
+
+func (d FakeDryccCmd) ResourceDelete(string, string) error {
+	return errors.New("resources:destroy")
+}
+
+func (d FakeDryccCmd) ResourceBind(string, string) error {
+	return errors.New("resources:bind")
+}
+
+func (d FakeDryccCmd) ResourceUnbind(string, string) error {
+	return errors.New("resources:unbind")
+}
+
+func TestResources(t *testing.T) {
+	t.Parallel()
+
+	cf, server, err := testutil.NewTestServerAndClient()
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer server.Close()
+	var b bytes.Buffer
+	cmdr := FakeDryccCmd{WOut: &b, ConfigFile: cf}
+
+	// cases defines the arguments and expected return of the call.
+	// if expected is "", it defaults to args[0].
+	cases := []struct {
+		args     []string
+		expected string
+	}{
+		{
+			args:     []string{"resources:create", "mysql:5.6", "mysql", "key1=value1"},
+			expected: "",
+		},
+		{
+			args:     []string{"resources:list"},
+			expected: "",
+		},
+		{
+			args:     []string{"resources:describe", "mysql"},
+			expected: "",
+		},
+		{
+			args:     []string{"resources:update", "mysql:5.7", "mysql", "key1=value2"},
+			expected: "",
+		},
+		{
+			args:     []string{"resources:destroy", "mysql"},
+			expected: "",
+		},
+		{
+			args:     []string{"resources:bind", "mysql"},
+			expected: "",
+		},
+		{
+			args:     []string{"resources:unbind", "mysql"},
+			expected: "",
+		},
+		{
+			args:     []string{"resources"},
+			expected: "resources:list",
+		},
+	}
+
+	// For each case, check that calling the route with the arguments
+	// returns the expected error, which is args[0] if not provided.
+	for _, c := range cases {
+		var expected string
+		if c.expected == "" {
+			expected = c.args[0]
+		} else {
+			expected = c.expected
+		}
+		err = Resources(c.args, cmdr)
+		assert.Err(t, errors.New(expected), err)
+	}
+}
