Skip to content

Implement PATCH /v3/service_instances for managed services#4167

Open
ybykov-a9s wants to merge 49 commits intocloudfoundry:mainfrom
ybykov-a9s:extend-update-service-instance-endpoint
Open

Implement PATCH /v3/service_instances for managed services#4167
ybykov-a9s wants to merge 49 commits intocloudfoundry:mainfrom
ybykov-a9s:extend-update-service-instance-endpoint

Conversation

@ybykov-a9s
Copy link
Copy Markdown
Contributor

Is there a related GitHub Issue?

The PR is aimed to solve this Issue 4163

What is this change about?

Implementing PATCH /v3/service_instances for managed services

Does this PR introduce a breaking change?

It shouldn't be a breaking change

Acceptance Steps

The PR is still WIP

Tag your pair, your PM, and/or team

danail-branekov

Copy link
Copy Markdown
Member

@danail-branekov danail-branekov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks promising. The most important bits of my comments are on the managed instance controller

type PatchManagedSIMessage struct {
GUID string
SpaceGUID string
PlanGUID string
Copy link
Copy Markdown
Member

@danail-branekov danail-branekov Sep 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder whether PlanGUID should not be a pointer? A patch may want to update the metadata, but not the plan

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated

Comment thread api/repositories/service_instance_repository.go
Comment thread api/repositories/service_instance_repository.go
log.Error(err, "failed to provision service instance")
return ctrl.Result{}, fmt.Errorf("failed to provision service instance: %w", err)
}
if serviceInstance.Status.ObservedGeneration == 0 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I get the intention of this check correctly, the idea here is to perform provision when the CFServiceInstance has been just created, otherwise proceed to update.

The generation might drift because of whatever updates (maybe a new label appeared) that may occur while provisioning is already running.

Instead of relying on the observed generation, I would suggest to add PlanGUID to CFServiceInstance.Status. Then the plan guid on the spec would have the semantics of "desired plan", while the plan guid on the status would mean "actual plan". Then if both differ, the controller should perform a plan update. Once the update succeed, the controller should set the new plan guid on the status, thus "writing down" that this is already the actual plan.

Also, it is worth mentioning that once the instance is ready (isReady function returns true), the controller would stop listening for updates. Therefore you should change the function to also check whether the "desired plan" matches the "actual plan"

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the logic here as you proposed to use Status. Please check it.

Comment thread controllers/controllers/services/instances/managed/controller.go Outdated
Comment thread controllers/controllers/services/instances/managed/controller.go

if lastOpResponse.State == "failed" {
meta.SetStatusCondition(&serviceInstance.Status.Conditions, metav1.Condition{
Type: korifiv1alpha1.ProvisioningFailedCondition,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you should set UpdateFailedCondition here

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the catch.

if err != nil {
return UpdateResponse{}, fmt.Errorf("update request failed: %w", err)
}
if statusCode == http.StatusBadRequest || statusCode == http.StatusConflict || statusCode == http.StatusUnprocessableEntity {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

According to the osbapi spec, an update operation cannot result into StatusConflict

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed.

@ybykov-a9s ybykov-a9s force-pushed the extend-update-service-instance-endpoint branch from bdb5833 to 6651e36 Compare January 23, 2026 12:53
@ybykov-a9s
Copy link
Copy Markdown
Contributor Author

As an updated summary - I applied all suggestions. Maybe used not ideal naming 😁
Updated PR also contains e2e tests with some adjustments.
@danail-branekov Please check if it looks better now.
I would like to mention that the version i showed initially was really far from being ready because I wanted to ask about the general approach. And it worked,

Copy link
Copy Markdown
Member

@danail-branekov danail-branekov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See my comments below, significant bits are on the controller. I think you are going in the right direction.

A general style comment - we usually put a blank line between ginkgo blocks - Describe, When, It, etc to visually separate them. If you could keep that convention in the PR, that would be great.

Comment thread api/handlers/service_instance_test.go Outdated

It("returns an error", func() {
expectUnprocessableEntityError("nope")
When("getting the service instance fails with not found", func() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style: By introducing a separate context for managed services, the describe has now the floowing structure:

	Describe("PATCH /v3/service_instances/:guid", func() {
		When("updating a user provided service instance", func() { // all existing tests go here
		When("updating a managed service instance", func() {

What I see as an issue here is that error cases (such as service instance repository returning an error) are checked in the upsi context, though being irrelevant to whether the service is upsi or not. As a matter of fact, the only difference between uspi and managed is the repository method being invoked.

Therefore, how about the following structure:

	Describe("PATCH /v3/service_instances/:guid", func() {
        // existing (upsi) tests still here, i.e. this is the "default" case
        When("the service instance is managed", func() {
			It("patches the managed service instance", func() {
			It("returns HTTP 202 Accepted response", func() {
			When("patching the managed service instances fails", func() {
				It("returns the error", func() {
         })

This should also reduce the diff as indentation white spaces would not be there

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I rearranged the test structure.

Comment thread api/payloads/service_instance.go Outdated

func (p ServiceInstancePatch) ToManagedSIPatchMessage(spaceGUID, appGUID string) repositories.PatchManagedSIMessage {
var planGUID *string
if p.Relationships.ServicePlan.Data.GUID != "" {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I get the intention correctly, you want to check whether the plan is being updated. In that case I think you should be checking on p.Relationships.ServicePlan != nil rather than p.Relationships.ServicePlan.Data.GUID != "". If p.Relationships.ServicePlan is set, then the validation should ensure that p.Relationships.ServicePlan.Data.GUID is not empty.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added check for nil first

return cfServiceInstanceToRecord(*cfServiceInstance), nil
}

func (r *ServiceInstanceRepo) PatchManagedServiceInstance(ctx context.Context, authInfo authorization.Info, message PatchManagedSIMessage) (ServiceInstanceRecord, error) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are no tests for this new method. They would be pretty much the same as the ones for UPSI but I cannot think a proper way to avoid the duplication

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added tests. Mostly from UPSI, to cover new functionality.

//+kubebuilder:validation:Optional
MaintenanceInfo MaintenanceInfo `json:"maintenanceInfo"`

// The service instance actual plan
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would add a note that this only makes sense for managed services (see MaintenanceInfo)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


if isFailed(serviceInstance) {
return ctrl.Result{}, k8s.NewNotReadyError().WithReason("ProvisioningFailed").WithNoRequeue()
return ctrl.Result{}, k8s.NewNotReadyError().WithReason("ReconciliationFailed").WithNoRequeue()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is not that reconciliation failed per se, it is just that the service instance is in a failed state. Maybe ServiceInstanceFailed would be a proper reason here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

))
})
})

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file should not be affected by the PR at all. While I agree that removing those new lines makes sense, the generated diff is a high price to pay for such a small improvement.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I bet my local automation did it when i touched the file. I wasn't sure if it should be included to the commit or not. No problem - removed it to decrease diff.

return serviceInstance.GUID
}

func createManagedServiceInstance(brokerGUID, spaceGUID string, name string) string {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any particular reason for the name parameter? All references supply an inline-generated guid, therefore they do not seem to care about it. Let's not overload the method signature if that is the case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Existing createUPServiceInstance (renamed from the original createServiceInstance) a bit above has it. So i preserved the logic.

Comment thread tests/e2e/service_instances_test.go Outdated
Patch("/v3/service_instances/" + serviceInstanceGUID)
})

When("updating a user-provided service instance", func() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test structure: The describe has two sibling contexts and there is no default case. I would recommend having just introducing the serviceInstanceGUID variable at the root of the context and default it to the upsiGUID in the BeforeEach.

Then, have a context When("the service instance is managed, where you create the managed instance and set serviceInstanceGUID to its guid. Such an approach would also minimise the diff.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I moved defining serviceInstanceGUID to BeforeEach section. Not sure it minimizes the diff, but rather makes the logic cleaner. What do you think?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better, but still - the describe only has two contexts and no default It. How about

Describe("Update", func(){
  BeforeEach(func(){
     // upsi update test setup
  })

  JustBeforeEach(func(){ /* invokes the method under test */})

  It("succeeds", func(){/* upsi update checks*/ })

  When("updating a managed service instance", func(){
    BeforeEach(func(){/*create+stub the broker, the managed SI, set updateRequestBody to managed SI update params })

    AfterEach(func() { */managed SI cleanup */})
    
    It("succeeds with a job redirect", func() { ... })

    It("updates a managed service", func() {...})
})

With this structure, the "default" case is an UPSI update, and updating a managed service is a "corner case" that requires more setup

Comment thread tests/e2e/service_instances_test.go Outdated
catalogResp, err := adminClient.R().SetResult(&plansResp).Get("/v3/service_plans?service_broker_guids=" + brokerGUID)
Expect(err).NotTo(HaveOccurred())
Expect(catalogResp).To(HaveRestyStatusCode(http.StatusOK))
Expect(plansResp.Resources).NotTo(BeEmpty())
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe assert that Resource has length at least 2 (plansResp.Resources[1] is used below)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true. thanks. changed the test.

Expect(httpErrorService).NotTo(HaveOccurred())
Expect(httpRespService).To(HaveRestyStatusCode(http.StatusOK))
Expect(
result.resource.Relationships["service_plan"].Data.GUID,
Copy link
Copy Markdown
Member

@danail-branekov danail-branekov Jan 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about checking the service_plan relationship in It("updates a managed service"? Every new It slows down the suite execution time so we have to strike a balance

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's always a dilemma - make more specific tests or combine them to minimize their number :D
In this case i would agree that your way makes more sense.

@ybykov-a9s
Copy link
Copy Markdown
Contributor Author

@danail-branekov Thank you for your comments. Please check updates. All besides one (name parameter in createManagedServiceInstance method) I changed as you proposed. Hope now the PR looks better.

P.S.
I'm really impressed with your patience. Thank you a lot for your time.

Copy link
Copy Markdown
Member

@danail-branekov danail-branekov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you are making a steady progress, there are just few things to iron out

Comment thread api/payloads/service_instance.go Outdated
Credentials *map[string]any `json:"credentials,omitempty"`
Metadata MetadataPatch `json:"metadata"`
Name *string `json:"name,omitempty"`
Type string `json:"type"`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PATCH /v3/service_instances/[guid] endpoint does not have Type parameter. Also the field is unused

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't even remember what i wanted to achieve months ago. Removed it.

Comment thread api/payloads/service_instance.go Outdated
func (p ServiceInstancePatch) ToManagedSIPatchMessage(spaceGUID, appGUID string) repositories.PatchManagedSIMessage {
var planGUID *string
if p.Relationships.ServicePlan != nil {
if p.Relationships.ServicePlan.Data.GUID != "" {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That if is not necessary. Provided that p.Relationships.ServicePlan is not nil, then payload validation should have ensured that it also has Data.GUID

Also, the statement following the if block is unnecessary.

TL;DR the code should be

func (p ServiceInstancePatch) ToManagedSIPatchMessage(spaceGUID, appGUID string) repositories.PatchManagedSIMessage {
....
var planGUID *string
if p.Relationships.ServicePlan != nil {
  planGUID = &p.Relationships.ServicePlan.Data.GUID
}

return repositories.PatchManagedSIMessage{
....
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤦
End of the Friday. Sorry for that. 😃
Fixed.

return serviceInstance
}

func createManagedServiceInstanceCR(ctx context.Context, k8sClient client.Client, serviceInstanceGUID, spaceGUID, name, plan string) *korifiv1alpha1.CFServiceInstance {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realise that you have introduced this method to be in par with the UPSI tests. We introduced that kind of utility methods a long time ago and eventually realised that they make the code unreadable instead of being helpful. Therefore we are gradually getting rid of those and replace their invocations with inlining the code making sure we are only initialising objects with data related to the concrete test.

Provided that createManagedServiceInstanceCR is used in a single place, could you please inline the code there?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved it as an inline block.

})
})

When("Patching a managed service instance", func() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This context is exercising the new method PatchManagedServiceInstance and alters the behaviour of the parent describe JustBeforeEach. Generally, we prefer a describe per method with a single JustBeforeEach.

Could you change the structure of those test to something like

Describe("PatchUserProvidedServiceInstance", // patch upsi tests
Describe("PatchManagedServiceInstance", //patch managed service tests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mmm. OK. done.

Comment thread api/repositories/service_instance_repository_test.go
@@ -148,7 +148,7 @@ func (r *Reconciler) ReconcileResource(ctx context.Context, serviceInstance *kor
}

if isFailed(serviceInstance) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The method only checks whether ProvisioningFailedCondition status condition is true. Therefore it makes sense to rename the method to isProvisioningFailed or similar

Also, the reason of the not ready error below probably makes sense to remain ProvisioningFailed rather than the generic ServiceInstanceFailed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isFailed was an original name of the method. But I agree that a new name is better. Updated.

log.Error(err, "failed to provision service instance")
return ctrl.Result{}, fmt.Errorf("failed to provision service instance: %w", err)
}
if !serviceInstance.Status.Provisioned {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to make the method more readable and concise, could you please extract the code into utility methods, for example

func (r *Reconciler) ReconcileResource(ctx context.Context, serviceInstance *korifiv1alpha1.CFServiceInstance) (ctrl.Result, error) {

  // getting the service, validation, assets, etc boilerplate here

   if !serviceInstance.Status.Provisioned {
      return r.reconcileProvisionedServiceInstance(...) // old provisioning code here, set the status.Provisioned field
   }

  return r.reconcileUpdatedServiceInstance(...) // new code here
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment thread controllers/controllers/services/instances/managed/controller.go
serviceBroker *korifiv1alpha1.CFServiceBroker
serviceOffering *korifiv1alpha1.CFServiceOffering
servicePlan *korifiv1alpha1.CFServicePlan
servicePlan2 *korifiv1alpha1.CFServicePlan
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

servicePlan2 could be local to When("the service instance is being updated" as it is only used there

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment thread tests/e2e/service_instances_test.go Outdated
Patch("/v3/service_instances/" + serviceInstanceGUID)
})

When("updating a user-provided service instance", func() {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better, but still - the describe only has two contexts and no default It. How about

Describe("Update", func(){
  BeforeEach(func(){
     // upsi update test setup
  })

  JustBeforeEach(func(){ /* invokes the method under test */})

  It("succeeds", func(){/* upsi update checks*/ })

  When("updating a managed service instance", func(){
    BeforeEach(func(){/*create+stub the broker, the managed SI, set updateRequestBody to managed SI update params })

    AfterEach(func() { */managed SI cleanup */})
    
    It("succeeds with a job redirect", func() { ... })

    It("updates a managed service", func() {...})
})

With this structure, the "default" case is an UPSI update, and updating a managed service is a "corner case" that requires more setup

@danail-branekov
Copy link
Copy Markdown
Member

P.S. I'm really impressed with your patience. Thank you a lot for your time.

You are most welcome. Guiding contributors is part of the maintainers' job description

@ybykov-a9s
Copy link
Copy Markdown
Contributor Author

@danail-branekov I made all the changes you've requested including the latest of your suggestions here, which Github doesn't allow me to comment for unknown reason.

Please check it.

@ybykov-a9s ybykov-a9s marked this pull request as ready for review March 3, 2026 15:14
@danail-branekov
Copy link
Copy Markdown
Member

danail-branekov commented Mar 4, 2026

@ybykov-a9s Tehre are a couple of CI failures with the current state of the PR:

  • A couple of linter errors on code duplication - you could suppress those via the nolint:dupl comment - example. You could run the make lint target to simulate what the CI is doing locally.
  • PatchManagedServiceInstance [BeforeEach] is failing - sounds like a metadata.name for the cfservice instance the before each creates is not set. I believe the reason is that serviceInstanceGUID variable is not initialised before creating the CFServiceInstance. You could run ./scripts/run-tests.sh api/repositories to run repositories tests only, or make -C api test to run all api tests the way CI would

}

Expect(k8sClient.Create(ctx, cfServiceInstance)).To(Succeed())
conditionAwaiter.AwaitConditionReturns(cfServiceInstance, nil)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The condition awaiter is not used when patching the managed service instance, so stubbing it here does not really make sense

@danail-branekov
Copy link
Copy Markdown
Member

In addition to the comments above, this PR should enable updating the service instance plan from the cli. Therefore, you could also introduce a smoke test that exercises cf update-service <service> -p <new-plan> here

We could have that as a follow-up PR though

@ybykov-a9s
Copy link
Copy Markdown
Contributor Author

@danail-branekov

  • I fixed linter issues as you suggested. TBH never ran lint tests to make it faster, but it's not a solution :-)
  • Also fixed PatchManagedServiceInstance [BeforeEach] . Donno how could i miss it because I always run tests.
  • Removed conditionAwaiter as you suggested after I checked what it does.

I agree to introduce a smoke tests for changing a plan as a follow-up PR. It would require for me some time to learn Korifi smoke tests in general. And it would delay merging this PR.

Copy link
Copy Markdown
Member

@danail-branekov danail-branekov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Today I spent some time checking out how the update works. I am afraid that I found out that when updating the service instance (via cf update-service managed-service -p sample-2), no communication with the broker is performed.

While investigating the issue I took the liberty to come up with a diff that addresses that (it also addresses the comments below):

diff --git a/controllers/controllers/services/instances/managed/controller.go b/controllers/controllers/services/instances/managed/controller.go
index bcf440a5..c2f52bcb 100644
--- a/controllers/controllers/services/instances/managed/controller.go
+++ b/controllers/controllers/services/instances/managed/controller.go
@@ -143,10 +143,6 @@ func (r *Reconciler) ReconcileResource(ctx context.Context, serviceInstance *kor
 
 	serviceInstance.Status.UpgradeAvailable = serviceInstance.Status.MaintenanceInfo.Version != serviceInstanceAssets.ServicePlan.Spec.MaintenanceInfo.Version
 
-	if isReady(serviceInstance) {
-		return ctrl.Result{}, nil
-	}
-
 	if isProvisioningFailed(serviceInstance) {
 		return ctrl.Result{}, k8s.NewNotReadyError().WithReason("ServiceInstanceFailed").WithNoRequeue()
 	}
@@ -220,7 +216,6 @@ func (r *Reconciler) reconcileUpdatedServiceInstance( //nolint:dupl
 		return r.processUpdateOperation(serviceInstance, lastOpResponse)
 	}
 
-	serviceInstance.Status.Provisioned = true
 	serviceInstance.Status.MaintenanceInfo = serviceInstanceAssets.ServicePlan.Spec.MaintenanceInfo
 	serviceInstance.Status.LastOperation.State = "succeeded"
 	return ctrl.Result{}, nil
@@ -584,7 +579,3 @@ func (r *Reconciler) getNamespace(ctx context.Context, namespaceName string) (*c
 func isProvisioningFailed(instance *korifiv1alpha1.CFServiceInstance) bool {
 	return meta.IsStatusConditionTrue(instance.Status.Conditions, korifiv1alpha1.ProvisioningFailedCondition)
 }
-
-func isReady(instance *korifiv1alpha1.CFServiceInstance) bool {
-	return meta.IsStatusConditionTrue(instance.Status.Conditions, korifiv1alpha1.StatusConditionReady)
-}
diff --git a/controllers/controllers/services/instances/managed/controller_test.go b/controllers/controllers/services/instances/managed/controller_test.go
index 256641fb..d1537a79 100644
--- a/controllers/controllers/services/instances/managed/controller_test.go
+++ b/controllers/controllers/services/instances/managed/controller_test.go
@@ -793,21 +793,11 @@ var _ = Describe("CFServiceInstance", func() {
 		JustBeforeEach(func() {
 			Eventually(func(g Gomega) {
 				g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed())
-				g.Expect(instance.Status.Conditions).To(ContainElement(SatisfyAll(
-					HasType(Equal(korifiv1alpha1.StatusConditionReady)),
-					HasStatus(Equal(metav1.ConditionTrue)),
-				)))
 				g.Expect(instance.Status.Provisioned).To(BeTrue())
 			}).Should(Succeed())
 
 			Expect(k8s.Patch(ctx, adminClient, instance, func() {
 				instance.Spec.PlanGUID = servicePlan2.Name
-				meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
-					Type:    korifiv1alpha1.StatusConditionReady,
-					Status:  metav1.ConditionFalse,
-					Reason:  "UpdateRequested",
-					Message: "managed service instance update is requested",
-				})
 			})).To(Succeed())
 		})
 
@@ -823,6 +813,20 @@ var _ = Describe("CFServiceInstance", func() {
 			}).Should(Succeed())
 		})
 
+		It("sends an update request to the broker", func() {
+			Eventually(func(g Gomega) {
+				g.Expect(brokerClient.UpdateCallCount()).To(BeNumerically(">=", 1))
+				_, actualUpdatePayload := brokerClient.UpdateArgsForCall(brokerClient.UpdateCallCount() - 1)
+				g.Expect(actualUpdatePayload).To(Equal(osbapi.UpdatePayload{
+					InstanceID: instance.Name,
+					UpdateRequest: osbapi.UpdateRequest{
+						ServiceId: "service-offering-id",
+						PlanID:    "service-plan-id-2",
+					},
+				}))
+			}).Should(Succeed())
+		})
+
 		When("service update fails with recoverable error", func() {
 			BeforeEach(func() {
 				brokerClient.UpdateReturns(osbapi.UpdateResponse{}, errors.New("update-failed"))
@@ -831,7 +835,7 @@ var _ = Describe("CFServiceInstance", func() {
 			It("keeps trying to update the instance", func() {
 				Eventually(func(g Gomega) {
 					g.Expect(brokerClient.UpdateCallCount()).To(BeNumerically(">=", 1))
-					_, updatePayload := brokerClient.UpdateArgsForCall(1)
+					_, updatePayload := brokerClient.UpdateArgsForCall(brokerClient.UpdateCallCount() - 1)
 					g.Expect(updatePayload).To(Equal(osbapi.UpdatePayload{
 						InstanceID: instance.Name,
 						UpdateRequest: osbapi.UpdateRequest{

While this diff ensures the broker's update method is invoked, it breaks existing tests. The reason is that when a k8s object is being created, it could be reconciled a couple of times, hence a provisioned service could yield a noop update with the broker. While this should not be an issue from OSBAPI spec point of view (still, no idea how real-life brokers react to noop-updates), this has the consequence that when a service instance is created, its last operation is reported as update rather than create.

If we are sure brokers do not mind noop-updates, we could probably ignore the effect above as an "usability issue" within this PR. Still, existing tests should be adapted.

If we want to solve the issue and avoid noop updates with the broker, I guess we need to replicate fields that can be updated to the status. Thus the values on the spec would have the semantic of "desired state", while the ones in the status would mean "actual state". The user sets the field on the spec, while the controller communicates the "desired state" with the broker and updates the status ("actual state") depending on the outcome.
Having those fields in the status would let the controller only perform broker updates that are noop. The criteria of an update with the broker would be desired-state != actual_state

Now, the question is what fields from the spec we should put on the status. According to the docs, those are plan, tags and parameters. I think that for the scope of this PR we could just add the plan guid

@@ -147,8 +147,8 @@ func (r *Reconciler) ReconcileResource(ctx context.Context, serviceInstance *kor
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the ready state is not terminal (i.e. the service instance can be updated), we should no longer return if the instance is ready.

As a matter of fact this check returns whenever the service instance has been provisioned. As a result, the reconciler sees the plan change, but as the service is ready, it does not talk to the broker.

The isReady check should be removed

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

return r.processUpdateOperation(serviceInstance, lastOpResponse)
}

serviceInstance.Status.Provisioned = true
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Status.Provisioned should be only set when the instance is provisioned. Setting it here as well does not make sense.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

JustBeforeEach(func() {
Eventually(func(g Gomega) {
g.Expect(adminClient.Get(ctx, client.ObjectKeyFromObject(instance), instance)).To(Succeed())
g.Expect(instance.Status.Conditions).To(ContainElement(SatisfyAll(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea of this eventually should be to wait for the service to become provisioned before updating it. Provided that the ready state is no longer the terminal one, this check is now flaky. Instead, here you could just wait for the Status.Provisioned field to become true

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done


Expect(k8s.Patch(ctx, adminClient, instance, func() {
instance.Spec.PlanGUID = servicePlan2.Name
meta.SetStatusCondition(&instance.Status.Conditions, metav1.Condition{
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting the status here jumps over the isReady check in the controller thus hiding the bug that the controller is not talking to the broker on update. When the service instance is being updated, the instance repository just sets Spec.PlanGUID to the new value and assumes that the controller kicks in. Setting the status from the test code in JustBeforeEach (the function that simulates the user updating the service instance) makes no sense here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed

@ybykov-a9s
Copy link
Copy Markdown
Contributor Author

ybykov-a9s commented Mar 30, 2026

@danail-branekov A huge thank you for your review. I applied changes you proposed. I also fixed broken tests you mentioned. Added a logic which prevents no-op broker requests. It shouldn't be an issue for the broker, but IHMO it's better to avoid such behaviour. I also added some logic which leaves last operation create for service instance creation. TBH it would be disappointing to see something like update successful after a successful creation operation.

I also added a smoke-test you mentioned.


// The generation at which the instance was last successfully reconciled with the broker.
//+kubebuilder:validation:Optional
BrokerReconciledGeneration int64 `json:"brokerReconciledGeneration,omitempty"`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a look at the usages of this field and from what I see, it is only used in the controller to decide whether to perform update.

From my point of view this pattern is quite imperative and does not convey the intention. Also, relying on generation to filter out noop broker updates would probably not work - a generation could change in case e.g. a label is added to the CFServiceInstance - that is not a change a broker is interested in.

Would it not be better if we had a needsUpdate function in the controller instead that would (for now) return true only if spec.PlanGUID != status.PlanGUID. Then, the controller proceeds with updating the instance with the broker only if needsUpdate returns true
Such an approach would also remove setting the field all over the place and would be easier to reason about.

Speaking of avoiding unnecessary updates with the broker - you could introduce a test in the controller tests that verify that no broker communication is performed when something irrelevant has changed, e.g. a label has been added to the CFServiceInstance

}

if !planVisible {
if !planVisible && !serviceInstance.Status.Provisioned {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess the intention here is not to set failed status if the service has been provisioned but the plan has been disabled after that. Sounds reasonable, I did not see a test about that case though.

Provided the check only makes sense for unprovisioned services, I would suggest moving it to reconcileProvisionedServiceInstance

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually, we need to check for plan visibility on update as well. If the new desired plan is not visible, then the update should fail

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants