feat(cluster): expose broker autoscaling on streamnative_pulsar_cluster - #159
feat(cluster): expose broker autoscaling on streamnative_pulsar_cluster#159david-streamlio wants to merge 2 commits into
Conversation
### Motivation The PulsarCluster API has carried Spec.Broker.AutoScalingPolicy for as long as the pinned cloud-api-server has, and the control plane acts on it: broker.go translates it onto the backend cluster and, once set, stops passing a replica count so the HPA owns the broker count. Terraform could not reach any of it. The resource exposed only broker_replicas, a fixed count, so a cluster managed through this provider could not be autoscaled at all. ### Modifications Add an optional broker_auto_scaling_policy block with min_replicas and max_replicas, expanded onto Spec.Broker.AutoScalingPolicy on create and update and read back on refresh. min_replicas is Computed. The control plane picks a floor when one is not given, and an omitted value arrives from the SDK as 0; sending that literally would ask for a floor of no brokers. It is left nil instead, and a server-chosen value does not read as drift. Two failure modes are refused rather than applied silently: - **Serverless instances.** The admission plugin pins the policy for serverless clusters and rejects later changes to it, so a configured block could never be honoured. Create and update refuse it outright. - **Organizations without the feature.** Admission returns Forbidden when the autoscaling feature gate is off. Following the maintenance_window precedent, create and update send a dry-run first and fail when the policy that comes back is not the one that went in, so the mismatch surfaces as an error instead of as brokers that never scale. broker_replicas gains no diff suppression on purpose: the resource never sets it during read, so there is no drift to suppress. That it stops taking effect once a policy is set is documented on the field instead. Bookkeeper autoscaling is deliberately out of scope, and broker.scheduledScaling is newer than the pinned cloud-api-server and would need a dependency bump. ### Verifying this change Ten unit tests covering expand, flatten, equality, and the accepted/dropped/ overridden validation paths, including the server-defaulted min_replicas case and the serverless override. go build, go vet, gofmt and the full unit suite pass; docs regenerated with tfplugindocs.
|
@freeznet — tagging you directly rather than leaving this on the team request, since Worth separating from the No urgency if you are mid-something — just making sure it is in front of someone who can merge it rather than sitting on a team-level request. |
freeznet
left a comment
There was a problem hiding this comment.
Requesting changes for three correctness issues: an omitted minimum disables the downstream HPA, refresh introduces destructive/perpetual drift for unmanaged and serverless policies, and the bounds are not validated as a pair. Details are inline.
| "min_replicas": { | ||
| Type: schema.TypeInt, | ||
| Optional: true, | ||
| Computed: true, |
There was a problem hiding this comment.
[P1] Do not allow min_replicas to remain unset. At the pinned cloud-api-server commit, broker.go copies a nil minimum into the downstream PulsarBroker; sn-operator v0.18.0-rc.16 then returns a nil policy from GetAutoScalingPolicyWithDefault whenever MinReplicas is nil, so no HPA is built. A configuration containing only max_replicas therefore passes this provider and the cloud API but does not enable autoscaling. Please make min_replicas required, or materialize an explicit and documented minimum before sending the request, and cover the omitted case through the downstream conversion.
There was a problem hiding this comment.
Agreed, fixed in 90d417b. min_replicas is now Required — I dropped Optional/Computed rather than materializing a default, since inventing a floor on the practitioner's behalf seemed worse than making them state one.
I confirmed the downstream behaviour in the pinned operator before changing it:
// sn-operator@v0.18.0-rc.16/pkg/hpa/builder.go:74
func GetAutoScalingPolicyWithDefault(policy *v1alpha1.AutoScalingPolicy, ...) {
if policy == nil || policy.MinReplicas == nil {
return nil, nil
}So a nil minimum builds no HPA at all, exactly as you said.
Two follow-on changes fell out of it. expandBrokerAutoScalingPolicy no longer drops a zero minimum on the floor — the minReplicas > 0 condition is gone, since the validator already bounds the field at 1..15. And brokerAutoScalingPolicyEqual no longer treats a nil expected minimum as "whatever the control plane filled in": that leniency is what let a request with no minimum pass the dry-run acceptance check. There was an existing test asserting that leniency, which I replaced — it was encoding the bug.
| _ = d.Set("maintenance_window", []interface{}{}) | ||
| } | ||
| if pulsarCluster.Spec.Broker.AutoScalingPolicy != nil { | ||
| err = d.Set("broker_auto_scaling_policy", flattenBrokerAutoScalingPolicy(pulsarCluster.Spec.Broker.AutoScalingPolicy)) |
There was a problem hiding this comment.
[P1] Avoid importing an unmanaged/serverless policy into an Optional-only block. An API value stored here becomes Terraform state, and when the block is absent from configuration the SDK plans broker_auto_scaling_policy.# from 1 to 0 (reproduced with Resource.SimpleDiff). For a serverless cluster, admission injects the 2..3 policy, this read creates that removal diff, and the update guard below rejects every attempt to apply it. For a dedicated cluster whose policy was set out of band, the same plan silently disables autoscaling, contradicting the preservation described in this PR. Please preserve unmanaged state (for example, only flatten a policy Terraform was already managing and skip the serverless-injected policy), or redesign the schema semantics, and add refresh/plan regression tests.
There was a problem hiding this comment.
Agreed, fixed in 90d417b. Refresh now surfaces a policy only when Terraform is already managing one and the instance is not serverless:
func brokerAutoScalingPolicyForState(
policy *cloudv1alpha1.AutoScalingPolicy, managed bool, serverless bool,
) []interface{} {
if policy == nil || !managed || serverless {
return []interface{}{}
}
return flattenBrokerAutoScalingPolicy(policy)
}A nil policy still clears state, so genuine removal drift on a managed policy is detected — it is only the unmanaged and serverless-injected cases that no longer enter state.
Covered by TestBrokerAutoScalingPolicyForState for all four combinations, plus TestBrokerAutoScalingPolicyUnmanagedProducesNoPlan, which uses Resource.SimpleDiff the way you did to assert no broker_auto_scaling_policy.# change is planned.
| Type: schema.TypeInt, | ||
| Required: true, | ||
| Description: "The maximum number of brokers to scale up to.", | ||
| ValidateFunc: validateAutoScalingReplicas, |
There was a problem hiding this comment.
[P2] Validate the relationship between the bounds. The two scalar validators independently accept values such as min_replicas = 10 and max_replicas = 5. The pinned cloud-api admission has no cross-field validation and passes both values downstream; Kubernetes rejects the resulting HPA because maxReplicas must be at least minReplicas, leaving this as a late reconciliation failure instead of a Terraform diagnostic. Please add resource-level/CustomizeDiff validation for min_replicas <= max_replicas and a regression test.
There was a problem hiding this comment.
Agreed, fixed in 90d417b. validateBrokerAutoScalingPolicyBounds runs at the top of CustomizeDiff, deliberately ahead of the three early returns already in that closure so the check applies on create and update alike rather than only on the paths that fall through.
It is a plain function over the policy list rather than taking the ResourceDiff, which keeps it directly unit-testable. Covered by TestValidateBrokerAutoScalingPolicyBounds for the inverted, equal, ascending, absent and nil-entry cases, and by TestBrokerAutoScalingPolicyBoundsRejectedAtPlan, which drives it through the resource's own SimpleDiff so the wiring is pinned too — not just the helper.
…esh, and bounds Motivation: Review of streamnative#159 surfaced three correctness issues in broker_auto_scaling_policy. Modifications: - Make min_replicas Required. sn-operator's GetAutoScalingPolicyWithDefault returns a nil policy whenever MinReplicas is nil, so it builds no HorizontalPodAutoscaler at all; a configuration carrying only max_replicas previously passed both this provider and the cloud API while leaving autoscaling off. expandBrokerAutoScalingPolicy now always sends the minimum, and the dry-run comparison no longer treats an absent minimum as accepted. - Stop adopting an unmanaged policy during refresh. The block is Optional with no Computed, so a policy Terraform never wrote made an absent configuration plan broker_auto_scaling_policy.# from 1 to 0: on serverless, admission injects a policy and the update guard then rejected every apply of that removal; on a dedicated cluster whose policy was set out of band, the same plan silently disabled autoscaling. brokerAutoScalingPolicyForState now surfaces only a policy already under management and skips serverless. - Validate the bounds as a pair in CustomizeDiff. The scalar validators bound each field independently and cloud-api admission has no cross-field check, so min_replicas > max_replicas surfaced as a Kubernetes reconcile failure well after apply reported success; it is now a plan-time diagnostic. Verification: - go build ./... , go vet ./cloud/ , go test ./cloud/ all pass - Added unit coverage for the schema requirement, the refresh decision, the bounds helper, and plan-level regressions via Resource.SimpleDiff for both the unmanaged-policy and inverted-range cases - Regenerated docs with tfplugindocs v0.19.3
|
@freeznet all three addressed in Summary:
One caveat on the verification: this repo does not build here as-is, because |
freeznet
left a comment
There was a problem hiding this comment.
Re-reviewed 90d417b. The three prior correctness findings are addressed: min_replicas is required, refresh preserves unmanaged/serverless policy semantics while retaining managed drift detection, and inverted bounds are rejected during planning. I also re-ran go test ./..., go vet ./..., go build ./..., gofmt/diff checks; all passed. No new blocking findings.
|
@freeznet gentle nudge on this one — it has been approved and Nothing is outstanding that I am aware of: the three threads you raised were addressed in Context on why it matters: this is the |
Fixes #158
Motivation
streamnative_pulsar_clusterexposes static capacity only —broker_replicas,compute_unit_per_broker,bookie_replicas,storage_unit_per_bookie. There is no way to declare broker autoscaling, even though the platform has supported it for as long as the pinnedcloud-api-serverhas, and it is settable through the Cloud Console, the cloud API, andsnctl.Because the provider does a read-modify-write on update, a policy set out of band is preserved across a normal
terraform apply, so this is not a drift-clobbering bug. The gap is reproducibility: a cluster destroyed and recreated from Terraform comes back without autoscaling, silently, and there is no way to declare it. That matters for teams whose stated workflow is rebuilding clusters cleanly from IaC.Modifications
Add an optional
broker_auto_scaling_policyblock withmin_replicasandmax_replicas, expanded ontoSpec.Broker.AutoScalingPolicyon create and update, and read back on refresh.min_replicasisComputed. The control plane picks a floor when one is not given, and an omitted value arrives from the SDK as0; sending that literally would ask for a floor of no brokers. It is leftnilinstead, and a server-chosen value does not read as drift.Two failure modes are refused rather than applied silently:
compute_unitandbroker_replicas.maintenance_windowprecedent from feat(cloud): add check for maintenance window org-level feature #151, create and update send a dry run first and fail when the policy that comes back is not the one that went in — turning "brokers silently never scale" into a clear error.broker_replicasgains no diff suppression, deliberately. #158 originally proposed aDiffSuppressFuncor treating the two as mutually exclusive; neither is needed. The controller stops passing a replica count once a policy is set and lets the HPA own it, and the provider never callsd.Set("broker_replicas", ...)during read — it is write-only from configuration, so there is no drift to suppress. That it stops taking effect is documented on the field instead.Out of scope: bookkeeper autoscaling, per the cloud team.
spec.broker.scheduledScalinglikewise — it does not exist in the pinnedcloud-api-serverand would need a dependency bump.Verifying this change
go build,go vet,gofmt, and the full unit suite pass.min_replicascase and the serverless override.tfplugindocs(make docs); the diff is limited to this attribute, with no generator churn elsewhere.No acceptance test is included. The
maintenance_windowacceptance tests needTF_ACCand a live organization, and autoscaling additionally needs the org feature gate enabled, so one cannot be verified outside CI. Happy to add one guarded the same way if you would like it in this PR.Note for reviewers
maindoes not currently build from a clean Go module cache — a transitive dependency resolves to a revision that no longer exists. It reproduces on unmodifiedmainand is unrelated to this change; it is tracked internally, andcloud-api-servermasterhas already moved past it. I worked around it locally to type-check;go.modandgo.sumare unchanged in this PR.Worth knowing because the
Testsworkflow last ran on 2026-06-18 and has not run since, so this PR is likely the first to hit it in CI.