Skip to content

Commit 48d79c7

Browse files
committed
Acceptance tests for service credential binding rotations
1 parent 5c3e82d commit 48d79c7

7 files changed

Lines changed: 271 additions & 30 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ include_app_syslog_tcp
135135
* `comma_delim_asgs_enabled`: Defaults to `false`. Set to true if comma delimited ASG destinations are enabled in the test environment.
136136
* `include_services`: Flag to include test for the services API.
137137
* `include_service_instance_sharing`: Flag to include tests for service instance sharing between spaces. `include_services` must be set for these tests to run. The `service_instance_sharing` feature flag must also be enabled for these tests to pass.
138+
* `include_service_credential_binding_rotation`: Execute tests for multiple service bindings. See [RFC-0040](https://github.com/cloudfoundry/community/blob/main/toc/rfc/rfc-0040-service-binding-rotation.md) for details. This test requires CF CLI v8.18.0 or later. The backend must support at least 2 service bindings per app and service instance.
138139
* `include_ssh`: Flag to include tests for Diego container ssh feature.
139140
* `include_sso`: Flag to include the services tests that integrate with Single Sign On.
140141
* `include_tasks`: Flag to include the v3 task tests. `include_v3` must also be set for tests to run. The CC API task_creation feature flag must be enabled for these tests to pass.

cats_suite_helpers/cats_suite_helpers.go

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ const (
117117
)
118118

119119
func FileBasedServiceBindingsDescribe(description string, lifecycle string, callback func()) bool {
120-
return Describe(fmt.Sprintf("[file-based service bindings]", lifecycle), func() {
120+
return Describe(fmt.Sprintf("[file-based service bindings %s]", lifecycle), func() {
121121
BeforeEach(func() {
122122
if lifecycle == BuildpackLifecycle && !Config.GetIncludeFileBasedServiceBindings() {
123123
Skip(skip_messages.SkipFileBasedServiceBindingsBuildpackApp)
@@ -283,6 +283,17 @@ func ServiceInstanceSharingDescribe(description string, callback func()) bool {
283283
})
284284
}
285285

286+
func ServiceCredentialBindingRotationDescribe(description string, callback func()) bool {
287+
return Describe("[service credential binding rotation]", func() {
288+
BeforeEach(func() {
289+
if !Config.GetIncludeServiceCredentialBindingRotation() {
290+
Skip(skip_messages.SkipServiceCredentialBindingRotationMessage)
291+
}
292+
})
293+
Describe(description, callback)
294+
})
295+
}
296+
286297
func ServicesSsoDescribe(description string, callback func()) bool {
287298
return Describe("[services sso]", func() {
288299
BeforeEach(func() {

cats_suite_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
_ "github.com/cloudfoundry/cf-acceptance-tests/routing"
2828
_ "github.com/cloudfoundry/cf-acceptance-tests/routing_isolation_segments"
2929
_ "github.com/cloudfoundry/cf-acceptance-tests/security_groups"
30+
_ "github.com/cloudfoundry/cf-acceptance-tests/service_credential_binding_rotation"
3031
_ "github.com/cloudfoundry/cf-acceptance-tests/service_discovery"
3132
_ "github.com/cloudfoundry/cf-acceptance-tests/services"
3233
_ "github.com/cloudfoundry/cf-acceptance-tests/ssh"

helpers/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type CatsConfig interface {
2525
GetIncludeServices() bool
2626
GetIncludeUserProvidedServices() bool
2727
GetIncludeServiceDiscovery() bool
28+
GetIncludeServiceCredentialBindingRotation() bool
2829
GetIncludeSsh() bool
2930
GetIncludeTasks() bool
3031
GetIncludeV3() bool

helpers/config/config_struct.go

Lines changed: 38 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -74,35 +74,36 @@ type config struct {
7474
VolumeServiceBindConfig *string `json:"volume_service_bind_config"`
7575
VolumeServiceBrokerName *string `json:"volume_service_broker_name"`
7676

77-
IncludeAppSyslogTCP *bool `json:"include_app_syslog_tcp"`
78-
IncludeApps *bool `json:"include_apps"`
79-
IncludeContainerNetworking *bool `json:"include_container_networking"`
80-
IncludeDeployments *bool `json:"include_deployments"`
81-
IncludeDetect *bool `json:"include_detect"`
82-
IncludeDocker *bool `json:"include_docker"`
83-
IncludeCNB *bool `json:"include_cnb"`
84-
IncludeFileBasedServiceBindings *bool `json:"include_file_based_service_bindings"`
85-
IncludeInternetDependent *bool `json:"include_internet_dependent"`
86-
IncludeIsolationSegments *bool `json:"include_isolation_segments"`
87-
IncludePrivateDockerRegistry *bool `json:"include_private_docker_registry"`
88-
IncludeRouteServices *bool `json:"include_route_services"`
89-
IncludeRouting *bool `json:"include_routing"`
90-
IncludeRoutingIsolationSegments *bool `json:"include_routing_isolation_segments"`
91-
IncludeSSO *bool `json:"include_sso"`
92-
IncludeSecurityGroups *bool `json:"include_security_groups"`
93-
IncludeServiceDiscovery *bool `json:"include_service_discovery"`
94-
IncludeServiceInstanceSharing *bool `json:"include_service_instance_sharing"`
95-
IncludeServices *bool `json:"include_services"`
96-
IncludeUserProvidedServices *bool `json:"include_user_provided_services"`
97-
IncludeSsh *bool `json:"include_ssh"`
98-
IncludeTCPIsolationSegments *bool `json:"include_tcp_isolation_segments"`
99-
IncludeHTTP2Routing *bool `json:"include_http2_routing"`
100-
IncludeTCPRouting *bool `json:"include_tcp_routing"`
101-
IncludeTasks *bool `json:"include_tasks"`
102-
IncludeV3 *bool `json:"include_v3"`
103-
IncludeVolumeServices *bool `json:"include_volume_services"`
104-
IncludeZipkin *bool `json:"include_zipkin"`
105-
IncludeIPv6 *bool `json:"include_ipv6"`
77+
IncludeAppSyslogTCP *bool `json:"include_app_syslog_tcp"`
78+
IncludeApps *bool `json:"include_apps"`
79+
IncludeContainerNetworking *bool `json:"include_container_networking"`
80+
IncludeDeployments *bool `json:"include_deployments"`
81+
IncludeDetect *bool `json:"include_detect"`
82+
IncludeDocker *bool `json:"include_docker"`
83+
IncludeCNB *bool `json:"include_cnb"`
84+
IncludeFileBasedServiceBindings *bool `json:"include_file_based_service_bindings"`
85+
IncludeInternetDependent *bool `json:"include_internet_dependent"`
86+
IncludeIsolationSegments *bool `json:"include_isolation_segments"`
87+
IncludePrivateDockerRegistry *bool `json:"include_private_docker_registry"`
88+
IncludeRouteServices *bool `json:"include_route_services"`
89+
IncludeRouting *bool `json:"include_routing"`
90+
IncludeRoutingIsolationSegments *bool `json:"include_routing_isolation_segments"`
91+
IncludeSSO *bool `json:"include_sso"`
92+
IncludeSecurityGroups *bool `json:"include_security_groups"`
93+
IncludeServiceDiscovery *bool `json:"include_service_discovery"`
94+
IncludeServiceInstanceSharing *bool `json:"include_service_instance_sharing"`
95+
IncludeServiceCredentialBindingRotation *bool `json:"include_service_credential_binding_rotation"`
96+
IncludeServices *bool `json:"include_services"`
97+
IncludeUserProvidedServices *bool `json:"include_user_provided_services"`
98+
IncludeSsh *bool `json:"include_ssh"`
99+
IncludeTCPIsolationSegments *bool `json:"include_tcp_isolation_segments"`
100+
IncludeHTTP2Routing *bool `json:"include_http2_routing"`
101+
IncludeTCPRouting *bool `json:"include_tcp_routing"`
102+
IncludeTasks *bool `json:"include_tasks"`
103+
IncludeV3 *bool `json:"include_v3"`
104+
IncludeVolumeServices *bool `json:"include_volume_services"`
105+
IncludeZipkin *bool `json:"include_zipkin"`
106+
IncludeIPv6 *bool `json:"include_ipv6"`
106107

107108
CredhubMode *string `json:"credhub_mode"`
108109
CredhubLocation *string `json:"credhub_location"`
@@ -206,6 +207,7 @@ func getDefaults() config {
206207
defaults.IncludeTasks = ptrToBool(false)
207208
defaults.IncludeZipkin = ptrToBool(false)
208209
defaults.IncludeServiceInstanceSharing = ptrToBool(false)
210+
defaults.IncludeServiceCredentialBindingRotation = ptrToBool(false)
209211
defaults.IncludeHTTP2Routing = ptrToBool(false)
210212
defaults.IncludeTCPRouting = ptrToBool(false)
211213
defaults.IncludeVolumeServices = ptrToBool(false)
@@ -482,6 +484,9 @@ func validateConfig(config *config) error {
482484
if config.IncludeServiceDiscovery == nil {
483485
errs = errors.Join(errs, fmt.Errorf("* 'include_service_discovery' must not be null"))
484486
}
487+
if config.IncludeServiceCredentialBindingRotation == nil {
488+
errs = errors.Join(errs, fmt.Errorf("* 'include_service_credential_binding_rotation' must not be null"))
489+
}
485490
if config.IncludeServices == nil {
486491
errs = errors.Join(errs, fmt.Errorf("* 'include_services' must not be null"))
487492
}
@@ -1111,6 +1116,10 @@ func (c *config) GetIncludeServiceInstanceSharing() bool {
11111116
return *c.IncludeServiceInstanceSharing
11121117
}
11131118

1119+
func (c *config) GetIncludeServiceCredentialBindingRotation() bool {
1120+
return *c.IncludeServiceCredentialBindingRotation
1121+
}
1122+
11141123
func (c *config) GetIncludeWindows() bool {
11151124
return *c.IncludeWindows
11161125
}

helpers/skip_messages/skip_messages.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const SkipRoutingIsolationSegmentsMessage = `Skipping this test because Config.I
5656
const SkipZipkinMessage = `Skipping this test because config.IncludeZipkin is set to 'false'`
5757
const SkipServiceDiscoveryMessage = `Skipping this test because config.IncludeServiceDiscovery is set to 'false'.`
5858
const SkipServiceInstanceSharingMessage = `Skipping this test because config.IncludeServiceInstanceSharing is set to 'false'.`
59+
const SkipServiceCredentialBindingRotationMessage = `Skipping this test because config.IncludeServiceCredentialBindingRotation is set to 'false'.`
5960
const SkipCapiExperimentalMessage = `Skipping this test because config.IncludeCapiExperimental is set to 'false'.`
6061
const SkipWindowsTasksMessage = `Skipping Windows tasks tests (requires diego-release v1.20.0 and above)`
6162
const SkipNoAlternateStacksMessage = `Skipping this test because config.Stacks is empty.`
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
package service_credential_binding_rotation
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"regexp"
7+
"time"
8+
9+
. "github.com/cloudfoundry/cf-acceptance-tests/cats_suite_helpers"
10+
"github.com/cloudfoundry/cf-acceptance-tests/helpers/app_helpers"
11+
"github.com/cloudfoundry/cf-acceptance-tests/helpers/assets"
12+
"github.com/cloudfoundry/cf-acceptance-tests/helpers/random_name"
13+
svchelper "github.com/cloudfoundry/cf-acceptance-tests/helpers/services"
14+
"github.com/cloudfoundry/cf-test-helpers/v2/cf"
15+
"github.com/cloudfoundry/cf-test-helpers/v2/helpers"
16+
17+
. "github.com/onsi/ginkgo/v2"
18+
. "github.com/onsi/gomega"
19+
. "github.com/onsi/gomega/gexec"
20+
)
21+
22+
type bindingResource struct {
23+
GUID string `json:"guid"`
24+
Name string `json:"name"`
25+
CreatedAt time.Time `json:"created_at"`
26+
}
27+
28+
type bindingListResponse struct {
29+
Resources []bindingResource `json:"resources"`
30+
}
31+
32+
type vcapServiceEntry struct {
33+
BindingName string `json:"binding_name"`
34+
BindingGUID string `json:"binding_guid"`
35+
}
36+
37+
var _ = ServiceCredentialBindingRotationDescribe("Service Credential Binding Rotation", func() {
38+
var broker svchelper.ServiceBroker
39+
var appName string
40+
var serviceName string
41+
var bindingName string
42+
43+
extractBindingGUIDFromVCAPServices := func(vcapServicesStr, serviceBindingName string) string {
44+
vcapServices := map[string][]vcapServiceEntry{}
45+
Expect(json.Unmarshal([]byte(vcapServicesStr), &vcapServices)).NotTo(HaveOccurred(), "failed to parse VCAP_SERVICES")
46+
47+
serviceEntries := vcapServices[broker.Service.Name]
48+
for _, serviceEntry := range serviceEntries {
49+
if serviceEntry.BindingName == serviceBindingName {
50+
return serviceEntry.BindingGUID
51+
}
52+
}
53+
54+
Fail(fmt.Sprintf("expected VCAP_SERVICES to contain binding_guid for service binding %q under label %q", serviceBindingName, broker.Service.Name))
55+
return ""
56+
}
57+
58+
listBindingsForAppAndService := func(appName, serviceName string) []bindingResource {
59+
appGUID := app_helpers.GetAppGuid(appName)
60+
serviceGUID := svchelper.GetServiceInstanceGuid(serviceName)
61+
62+
bindingEndpoint := fmt.Sprintf("/v3/service_credential_bindings?app_guids=%s&service_instance_guids=%s", appGUID, serviceGUID)
63+
session := cf.Cf("curl", bindingEndpoint).Wait()
64+
Expect(session).To(Exit(0), "failed to list service credential bindings")
65+
66+
var response bindingListResponse
67+
Expect(json.Unmarshal(session.Out.Contents(), &response)).NotTo(HaveOccurred())
68+
return response.Resources
69+
}
70+
71+
oldestBindingGUID := func(bindings []bindingResource) string {
72+
oldest := bindings[0]
73+
for _, binding := range bindings[1:] {
74+
if binding.CreatedAt.Before(oldest.CreatedAt) {
75+
oldest = binding
76+
}
77+
}
78+
return oldest.GUID
79+
}
80+
81+
BeforeEach(func() {
82+
appName = random_name.CATSRandomName("APP")
83+
serviceName = random_name.CATSRandomName("SVIN")
84+
85+
broker = svchelper.NewServiceBroker(
86+
random_name.CATSRandomName("BRKR"),
87+
assets.NewAssets().ServiceBroker,
88+
TestSetup,
89+
)
90+
broker.Push(Config)
91+
broker.Configure()
92+
broker.Create()
93+
broker.PublicizePlans()
94+
95+
Expect(cf.Cf(app_helpers.CatnipWithArgs(
96+
appName,
97+
"-m", DEFAULT_MEMORY_LIMIT)...,
98+
).Wait(Config.CfPushTimeoutDuration())).To(Exit(0), "failed pushing app")
99+
100+
Expect(cf.Cf("create-service", broker.Service.Name, broker.SyncPlans[0].Name, serviceName).Wait()).To(Exit(0))
101+
102+
bindingName = random_name.CATSRandomName("BIND")
103+
Expect(cf.Cf("bind-service", appName, serviceName, "--binding-name", bindingName, "--strategy", "multiple").Wait()).To(
104+
Exit(0),
105+
fmt.Sprintf("failed binding app %s to service %s with binding name %s", appName, serviceName, bindingName),
106+
)
107+
108+
Expect(cf.Cf("restage", appName, "--strategy", "rolling").Wait(Config.CfPushTimeoutDuration())).To(Exit(0), "failed rolling restage")
109+
})
110+
111+
AfterEach(func() {
112+
app_helpers.AppReport(appName)
113+
app_helpers.AppReport(broker.Name)
114+
115+
Expect(cf.Cf("delete-service", serviceName, "-f").Wait()).To(Exit(0))
116+
Expect(cf.Cf("delete", appName, "-f", "-r").Wait(Config.CfPushTimeoutDuration())).To(Exit(0))
117+
118+
broker.Destroy()
119+
})
120+
121+
Context("one binding exists for the test application and test service instance", func() {
122+
123+
It("rotates credentials when creating the second binding", func() {
124+
vcapServices := helpers.CurlApp(Config, appName, "/env/VCAP_SERVICES")
125+
// test service broker supports only static credentials for service bindings,
126+
// so compare binding_guids to verify that the second bind-service call caused credential rotation (instead of being a no-op)
127+
initialBindingGUID := extractBindingGUIDFromVCAPServices(vcapServices, bindingName)
128+
129+
secondBindSession := cf.Cf("bind-service", appName, serviceName, "--binding-name", bindingName, "--strategy", "multiple").Wait()
130+
Expect(secondBindSession).To(
131+
Exit(0),
132+
fmt.Sprintf("failed binding app %s to service %s with binding name %s", appName, serviceName, bindingName),
133+
)
134+
Expect(string(secondBindSession.Out.Contents())).ToNot(
135+
ContainSubstring(fmt.Sprintf("App %s is already bound to service instance %s.", appName, serviceName)),
136+
"Make sure to enable the multi-service-binding feature in your test backend.",
137+
)
138+
139+
Expect(cf.Cf("restage", appName, "--strategy", "rolling").Wait(Config.CfPushTimeoutDuration())).To(Exit(0), "failed rolling restage")
140+
141+
vcapServices = helpers.CurlApp(Config, appName, "/env/VCAP_SERVICES")
142+
rotatedBindingGUID := extractBindingGUIDFromVCAPServices(vcapServices, bindingName)
143+
144+
Expect(rotatedBindingGUID).ToNot(Equal(initialBindingGUID), fmt.Sprintf("expected new service binding guid after completing credential rotation"))
145+
})
146+
})
147+
148+
Context("two service bindings exist for the test application and test service instance", func() {
149+
150+
BeforeEach(func() {
151+
secondBindSession := cf.Cf("bind-service", appName, serviceName, "--binding-name", bindingName, "--strategy", "multiple").Wait()
152+
Expect(secondBindSession).To(
153+
Exit(0),
154+
fmt.Sprintf("failed binding app %s to service %s with binding name %s", appName, serviceName, bindingName),
155+
)
156+
Expect(string(secondBindSession.Out.Contents())).ToNot(
157+
ContainSubstring(fmt.Sprintf("App %s is already bound to service instance %s.", appName, serviceName)),
158+
"Make sure to enable the multi-service-binding feature in your test backend.",
159+
)
160+
161+
Expect(cf.Cf("restage", appName, "--strategy", "rolling").Wait(Config.CfPushTimeoutDuration())).To(Exit(0), "failed rolling restage")
162+
163+
bindings := listBindingsForAppAndService(appName, serviceName)
164+
Expect(len(bindings)).To(Equal(2), fmt.Sprintf("expected two bindings for app %s and service %s", appName, serviceName))
165+
})
166+
167+
Describe("show service instance information", func() {
168+
It("shows all bindings", func() {
169+
serviceSession := cf.Cf("service", serviceName).Wait()
170+
Expect(serviceSession).To(Exit(0))
171+
172+
serviceOutput := string(serviceSession.Out.Contents())
173+
174+
bindings := listBindingsForAppAndService(appName, serviceName)
175+
for _, binding := range bindings {
176+
/* "cf service SERVICE_INSTANCE" output has a table of bindings with columns like:
177+
name binding name status message guid created_at
178+
testapp create succeeded 9ec888c4-547c-4c7b-bc51-6f15a7821e5d 2026-03-16T12:37:31Z
179+
testapp create succeeded ccee98fb-8146-4a4f-8b72-a8f96d44f525 2026-03-16T12:16:27Z
180+
*/
181+
linePattern := fmt.Sprintf(
182+
`(?m)^\s+%s\s+%s\s+create succeeded\s+%s\s+\S+$`,
183+
regexp.QuoteMeta(appName),
184+
regexp.QuoteMeta(binding.Name),
185+
regexp.QuoteMeta(binding.GUID),
186+
)
187+
Expect(serviceOutput).To(
188+
MatchRegexp(linePattern),
189+
fmt.Sprintf("expected cf service output to contain row matching app=%s binding=%s guid=%s", appName, binding.Name, binding.GUID),
190+
)
191+
}
192+
})
193+
})
194+
195+
Describe("unbind-service", func() {
196+
It("deletes both service bindings", func() {
197+
Expect(cf.Cf("unbind-service", appName, serviceName).Wait()).To(Exit(0))
198+
199+
bindings := listBindingsForAppAndService(appName, serviceName)
200+
Expect(len(bindings)).To(Equal(0), fmt.Sprintf("expected no bindings for app %s and service %s after unbind-service", appName, serviceName))
201+
})
202+
})
203+
204+
Describe("cleanup-outdated-service-bindings", func() {
205+
It("deletes the oldest binding", func() {
206+
bindings := listBindingsForAppAndService(appName, serviceName)
207+
oldestBindingGUID := oldestBindingGUID(bindings)
208+
209+
Expect(cf.Cf("cleanup-outdated-service-bindings", appName, "--force").Wait()).To(Exit(0))
210+
211+
bindings = listBindingsForAppAndService(appName, serviceName)
212+
Expect(len(bindings)).To(Equal(1), fmt.Sprintf("expected one binding for app %s and service %s after cleanup", appName, serviceName))
213+
Expect(bindings[0].GUID).ToNot(Equal(oldestBindingGUID), fmt.Sprintf("expected oldest binding for app %s / service %s to be deleted", appName, serviceName))
214+
})
215+
})
216+
})
217+
})

0 commit comments

Comments
 (0)