Skip to content

Commit aa7bb5a

Browse files
authored
[main] Add "--strategy" parameter to "bind-service" command (#3655)
* Add "--strategy" parameter to "bind-service" command * see https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0040-service-binding-rotation.md#cf-cli * Add API version check for bind-service "--strategy" parameter * and complete "help" docu integration tests
1 parent b180ff0 commit aa7bb5a

File tree

10 files changed

+184
-8
lines changed

10 files changed

+184
-8
lines changed

actor/v7action/service_app_binding.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ type CreateServiceAppBindingParams struct {
1515
AppName string
1616
BindingName string
1717
Parameters types.OptionalObject
18+
Strategy resources.BindingStrategyType
1819
}
1920

2021
type DeleteServiceAppBindingParams struct {
@@ -41,7 +42,7 @@ func (actor Actor) CreateServiceAppBinding(params CreateServiceAppBindingParams)
4142
return
4243
},
4344
func() (warnings ccv3.Warnings, err error) {
44-
jobURL, warnings, err = actor.createServiceAppBinding(serviceInstance.GUID, app.GUID, params.BindingName, params.Parameters)
45+
jobURL, warnings, err = actor.createServiceAppBinding(serviceInstance.GUID, app.GUID, params.BindingName, params.Parameters, params.Strategy)
4546
return
4647
},
4748
func() (warnings ccv3.Warnings, err error) {
@@ -102,13 +103,14 @@ func (actor Actor) DeleteServiceAppBinding(params DeleteServiceAppBindingParams)
102103
}
103104
}
104105

105-
func (actor Actor) createServiceAppBinding(serviceInstanceGUID, appGUID, bindingName string, parameters types.OptionalObject) (ccv3.JobURL, ccv3.Warnings, error) {
106+
func (actor Actor) createServiceAppBinding(serviceInstanceGUID, appGUID, bindingName string, parameters types.OptionalObject, strategy resources.BindingStrategyType) (ccv3.JobURL, ccv3.Warnings, error) {
106107
jobURL, warnings, err := actor.CloudControllerClient.CreateServiceCredentialBinding(resources.ServiceCredentialBinding{
107108
Type: resources.AppBinding,
108109
Name: bindingName,
109110
ServiceInstanceGUID: serviceInstanceGUID,
110111
AppGUID: appGUID,
111112
Parameters: parameters,
113+
Strategy: strategy,
112114
})
113115
switch err.(type) {
114116
case nil:

actor/v7action/service_app_binding_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ var _ = Describe("Service App Binding Action", func() {
3535
bindingName = "fake-binding-name"
3636
spaceGUID = "fake-space-guid"
3737
fakeJobURL = ccv3.JobURL("fake-job-url")
38+
strategy = "single"
3839
)
3940

4041
var (
@@ -87,6 +88,7 @@ var _ = Describe("Service App Binding Action", func() {
8788
Parameters: types.NewOptionalObject(map[string]interface{}{
8889
"foo": "bar",
8990
}),
91+
Strategy: resources.SingleBindingStrategy,
9092
}
9193
})
9294

@@ -202,6 +204,7 @@ var _ = Describe("Service App Binding Action", func() {
202204
Parameters: types.NewOptionalObject(map[string]interface{}{
203205
"foo": "bar",
204206
}),
207+
Strategy: strategy,
205208
}))
206209
})
207210

api/cloudcontroller/ccversion/minimum_version.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,6 @@ const (
1616
MinVersionBuildpackLifecycleQuery = "3.194.0"
1717

1818
MinVersionCanarySteps = "3.189.0"
19+
20+
MinVersionServiceBindingStrategy = "3.205.0"
1921
)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package flag
2+
3+
import (
4+
"strings"
5+
6+
"code.cloudfoundry.org/cli/v9/resources"
7+
flags "github.com/jessevdk/go-flags"
8+
)
9+
10+
type ServiceBindingStrategy struct {
11+
Strategy resources.BindingStrategyType
12+
IsSet bool
13+
}
14+
15+
func (ServiceBindingStrategy) Complete(prefix string) []flags.Completion {
16+
return completions([]string{"single", "multiple"}, prefix, false)
17+
}
18+
19+
func (h *ServiceBindingStrategy) UnmarshalFlag(val string) error {
20+
valLower := strings.ToLower(val)
21+
switch valLower {
22+
case "single", "multiple":
23+
h.Strategy = resources.BindingStrategyType(valLower)
24+
h.IsSet = true
25+
default:
26+
return &flags.Error{
27+
Type: flags.ErrRequired,
28+
Message: `STRATEGY must be "single" or "multiple"`,
29+
}
30+
}
31+
return nil
32+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
package flag_test
2+
3+
import (
4+
. "code.cloudfoundry.org/cli/v9/command/flag"
5+
"code.cloudfoundry.org/cli/v9/resources"
6+
flags "github.com/jessevdk/go-flags"
7+
. "github.com/onsi/ginkgo/v2"
8+
. "github.com/onsi/gomega"
9+
)
10+
11+
var _ = Describe("ServiceBindingStrategy", func() {
12+
var sbs ServiceBindingStrategy
13+
14+
Describe("Complete", func() {
15+
DescribeTable("returns list of completions",
16+
func(prefix string, matches []flags.Completion) {
17+
completions := sbs.Complete(prefix)
18+
Expect(completions).To(Equal(matches))
19+
},
20+
Entry("returns 'single' when passed 's'", "s",
21+
[]flags.Completion{{Item: "single"}}),
22+
Entry("returns 'single' when passed 'S'", "S",
23+
[]flags.Completion{{Item: "single"}}),
24+
Entry("returns 'multiple' when passed 'm'", "m",
25+
[]flags.Completion{{Item: "multiple"}}),
26+
Entry("returns 'multiple' when passed 'M'", "M",
27+
[]flags.Completion{{Item: "multiple"}}),
28+
Entry("returns 'single' and 'multiple' when passed ''", "",
29+
[]flags.Completion{{Item: "single"}, {Item: "multiple"}}),
30+
)
31+
})
32+
33+
Describe("UnmarshalFlag", func() {
34+
BeforeEach(func() {
35+
sbs = ServiceBindingStrategy{}
36+
})
37+
38+
When("unmarshal has not been called", func() {
39+
It("is marked as not set", func() {
40+
Expect(sbs.IsSet).To(BeFalse())
41+
})
42+
})
43+
44+
DescribeTable("downcases and sets strategy",
45+
func(input string, expected resources.BindingStrategyType) {
46+
err := sbs.UnmarshalFlag(input)
47+
Expect(err).ToNot(HaveOccurred())
48+
Expect(sbs.Strategy).To(Equal(expected))
49+
Expect(sbs.IsSet).To(BeTrue())
50+
},
51+
Entry("sets 'single' when passed 'single'", "single", resources.SingleBindingStrategy),
52+
Entry("sets 'single' when passed 'sInGlE'", "sInGlE", resources.SingleBindingStrategy),
53+
Entry("sets 'multiple' when passed 'multiple'", "multiple", resources.MultipleBindingStrategy),
54+
Entry("sets 'multiple' when passed 'MuLtIpLe'", "MuLtIpLe", resources.MultipleBindingStrategy),
55+
)
56+
57+
When("passed anything else", func() {
58+
It("returns an error", func() {
59+
err := sbs.UnmarshalFlag("banana")
60+
Expect(err).To(MatchError(&flags.Error{
61+
Type: flags.ErrRequired,
62+
Message: `STRATEGY must be "single" or "multiple"`,
63+
}))
64+
Expect(sbs.Strategy).To(BeEmpty())
65+
})
66+
})
67+
})
68+
})

command/v7/bind_service_command.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package v7
33
import (
44
"code.cloudfoundry.org/cli/v9/actor/actionerror"
55
"code.cloudfoundry.org/cli/v9/actor/v7action"
6+
"code.cloudfoundry.org/cli/v9/api/cloudcontroller/ccversion"
7+
"code.cloudfoundry.org/cli/v9/command"
68
"code.cloudfoundry.org/cli/v9/command/flag"
79
"code.cloudfoundry.org/cli/v9/command/v7/shared"
810
"code.cloudfoundry.org/cli/v9/types"
@@ -11,18 +13,26 @@ import (
1113
type BindServiceCommand struct {
1214
BaseCommand
1315

14-
RequiredArgs flag.BindServiceArgs `positional-args:"yes"`
15-
BindingName flag.BindingName `long:"binding-name" description:"Name to expose service instance to app process with (Default: service instance name)"`
16-
ParametersAsJSON flag.JSONOrFileWithValidation `short:"c" description:"Valid JSON object containing service-specific configuration parameters, provided either in-line or in a file. For a list of supported configuration parameters, see documentation for the particular service offering."`
17-
Wait bool `short:"w" long:"wait" description:"Wait for the operation to complete"`
18-
relatedCommands interface{} `related_commands:"services"`
16+
RequiredArgs flag.BindServiceArgs `positional-args:"yes"`
17+
BindingName flag.BindingName `long:"binding-name" description:"Name to expose service instance to app process with (Default: service instance name)"`
18+
ParametersAsJSON flag.JSONOrFileWithValidation `short:"c" description:"Valid JSON object containing service-specific configuration parameters, provided either in-line or in a file. For a list of supported configuration parameters, see documentation for the particular service offering."`
19+
ServiceBindingStrategy flag.ServiceBindingStrategy `long:"strategy" description:"Service binding strategy. Valid values are 'single' (default) and 'multiple'."`
20+
Wait bool `short:"w" long:"wait" description:"Wait for the operation to complete"`
21+
relatedCommands interface{} `related_commands:"services"`
1922
}
2023

2124
func (cmd BindServiceCommand) Execute(args []string) error {
2225
if err := cmd.SharedActor.CheckTarget(true, true); err != nil {
2326
return err
2427
}
2528

29+
if cmd.ServiceBindingStrategy.IsSet {
30+
err := command.MinimumCCAPIVersionCheck(cmd.Config.APIVersion(), ccversion.MinVersionServiceBindingStrategy, "--strategy")
31+
if err != nil {
32+
return err
33+
}
34+
}
35+
2636
if err := cmd.displayIntro(); err != nil {
2737
return err
2838
}
@@ -33,6 +43,7 @@ func (cmd BindServiceCommand) Execute(args []string) error {
3343
AppName: cmd.RequiredArgs.AppName,
3444
BindingName: cmd.BindingName.Value,
3545
Parameters: types.OptionalObject(cmd.ParametersAsJSON),
46+
Strategy: cmd.ServiceBindingStrategy.Strategy,
3647
})
3748
cmd.UI.DisplayWarnings(warnings)
3849

@@ -82,7 +93,12 @@ Example of valid JSON object:
8293
8394
Optionally provide a binding name for the association between an app and a service instance:
8495
85-
CF_NAME bind-service APP_NAME SERVICE_INSTANCE --binding-name BINDING_NAME`
96+
CF_NAME bind-service APP_NAME SERVICE_INSTANCE --binding-name BINDING_NAME
97+
98+
Optionally provide the binding strategy type. Valid options are 'single' (default) and 'multiple'. The 'multiple' strategy allows multiple bindings between the same app and service instance.
99+
This is useful for credential rotation scenarios.
100+
101+
CF_NAME bind-service APP_NAME SERVICE_INSTANCE --strategy multiple`
86102
}
87103

88104
func (cmd BindServiceCommand) Examples() string {

command/v7/bind_service_command_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ import (
66
"code.cloudfoundry.org/cli/v9/actor/actionerror"
77
"code.cloudfoundry.org/cli/v9/actor/v7action"
88
"code.cloudfoundry.org/cli/v9/command/commandfakes"
9+
"code.cloudfoundry.org/cli/v9/command/translatableerror"
910
v7 "code.cloudfoundry.org/cli/v9/command/v7"
1011
"code.cloudfoundry.org/cli/v9/command/v7/v7fakes"
12+
"code.cloudfoundry.org/cli/v9/resources"
1113
"code.cloudfoundry.org/cli/v9/types"
1214
"code.cloudfoundry.org/cli/v9/util/configv3"
1315
"code.cloudfoundry.org/cli/v9/util/ui"
16+
1417
. "github.com/onsi/ginkgo/v2"
1518
. "github.com/onsi/gomega"
1619
. "github.com/onsi/gomega/gbytes"
@@ -39,6 +42,7 @@ var _ = Describe("bind-service Command", func() {
3942
BeforeEach(func() {
4043
testUI = ui.NewTestUI(NewBuffer(), NewBuffer(), NewBuffer())
4144
fakeConfig = new(commandfakes.FakeConfig)
45+
fakeConfig.APIVersionReturns("3.205.0")
4246
fakeSharedActor = new(commandfakes.FakeSharedActor)
4347
fakeActor = new(v7fakes.FakeActor)
4448

@@ -110,6 +114,30 @@ var _ = Describe("bind-service Command", func() {
110114
})
111115
})
112116

117+
When("strategy flag is set", func() {
118+
BeforeEach(func() {
119+
setFlag(&cmd, "--strategy", "multiple")
120+
})
121+
122+
It("passes the strategy to the actor", func() {
123+
Expect(executeErr).NotTo(HaveOccurred())
124+
Expect(fakeActor.CreateServiceAppBindingCallCount()).To(Equal(1))
125+
Expect(fakeActor.CreateServiceAppBindingArgsForCall(0).Strategy).
126+
To(Equal(resources.MultipleBindingStrategy))
127+
})
128+
129+
It("fails when the cc version is below the minimum", func() {
130+
fakeConfig.APIVersionReturns("3.204.0")
131+
executeErr = cmd.Execute(nil)
132+
133+
Expect(executeErr).To(MatchError(translatableerror.MinimumCFAPIVersionNotMetError{
134+
Command: "--strategy",
135+
CurrentVersion: "3.204.0",
136+
MinimumVersion: "3.205.0",
137+
}))
138+
})
139+
})
140+
113141
When("binding already exists", func() {
114142
BeforeEach(func() {
115143
fakeActor.CreateServiceAppBindingReturns(

integration/v7/isolated/bind_service_command_test.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ var _ = Describe("bind-service command", func() {
4141
Say(`\n`),
4242
Say(`\s+cf bind-service APP_NAME SERVICE_INSTANCE --binding-name BINDING_NAME\n`),
4343
Say(`\n`),
44+
Say(`\s+Optionally provide the binding strategy type. Valid options are 'single' \(default\) and 'multiple'. The 'multiple' strategy allows multiple bindings between the same app and service instance.\n`),
45+
Say(`\s+This is useful for credential rotation scenarios.\n`),
46+
Say(`\n`),
47+
Say(`\s+cf bind-service APP_NAME SERVICE_INSTANCE --strategy multiple\n`),
4448
Say(`EXAMPLES:\n`),
4549
Say(`\s+Linux/Mac:\n`),
4650
Say(`\s+cf bind-service myapp mydb -c '\{"permissions":"read-only"\}'\n`),
@@ -59,6 +63,7 @@ var _ = Describe("bind-service command", func() {
5963
Say(`OPTIONS:\n`),
6064
Say(`\s+--binding-name\s+Name to expose service instance to app process with \(Default: service instance name\)\n`),
6165
Say(`\s+-c\s+Valid JSON object containing service-specific configuration parameters, provided either in-line or in a file. For a list of supported configuration parameters, see documentation for the particular service offering.\n`),
66+
Say(`\s+--strategy\s+Service binding strategy. Valid values are 'single' \(default\) and 'multiple'.\n`),
6267
Say(`\s+--wait, -w\s+Wait for the operation to complete\n`),
6368
Say(`\n`),
6469
Say(`SEE ALSO:\n`),

resources/service_credential_binding_resource.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@ const (
1212
KeyBinding ServiceCredentialBindingType = "key"
1313
)
1414

15+
type BindingStrategyType string
16+
17+
const (
18+
SingleBindingStrategy BindingStrategyType = "single"
19+
MultipleBindingStrategy BindingStrategyType = "multiple"
20+
)
21+
1522
type ServiceCredentialBinding struct {
1623
// Type is either "app" or "key"
1724
Type ServiceCredentialBindingType `jsonry:"type,omitempty"`
@@ -31,6 +38,8 @@ type ServiceCredentialBinding struct {
3138
LastOperation LastOperation `jsonry:"last_operation"`
3239
// Parameters can be specified when creating a binding
3340
Parameters types.OptionalObject `jsonry:"parameters"`
41+
// Strategy can be "single" or "multiple" (if empty, "single" is set as default by backend)
42+
Strategy BindingStrategyType `jsonry:"strategy,omitempty"`
3443
// CreatedAt timestamp when the binding was created (useful for distinguishing multiple bindings)
3544
CreatedAt string `json:"created_at,omitempty"`
3645
}

resources/service_credential_binding_resource_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ var _ = Describe("service credential binding resource", func() {
6161
}`,
6262
),
6363
Entry("created_at", ServiceCredentialBinding{CreatedAt: "fake-created-at"}, `{"created_at": "fake-created-at"}`),
64+
Entry(
65+
"strategy",
66+
ServiceCredentialBinding{
67+
Strategy: SingleBindingStrategy,
68+
},
69+
`{
70+
"strategy": "single"
71+
}`,
72+
),
6473
Entry(
6574
"everything",
6675
ServiceCredentialBinding{
@@ -72,6 +81,7 @@ var _ = Describe("service credential binding resource", func() {
7281
Parameters: types.NewOptionalObject(map[string]interface{}{
7382
"foo": "bar",
7483
}),
84+
Strategy: MultipleBindingStrategy,
7585
CreatedAt: "fake-created-at",
7686
},
7787
`{
@@ -93,6 +103,7 @@ var _ = Describe("service credential binding resource", func() {
93103
"parameters": {
94104
"foo": "bar"
95105
},
106+
"strategy": "multiple",
96107
"created_at": "fake-created-at"
97108
}`,
98109
),

0 commit comments

Comments
 (0)