Skip to content

[Gateway Operator 1.2.x] externalRefId and in-place plan/token updates for Subscription CRs - #3920

Open
thivindu wants to merge 3 commits into
wso2:kubernetes/helm/operator-helm-chart/v1.2.xfrom
thivindu:operator-helm-chart-1.2.x-subscription-fixes
Open

thivindu wants to merge 3 commits into
wso2:kubernetes/helm/operator-helm-chart/v1.2.xfrom
thivindu:operator-helm-chart-1.2.x-subscription-fixes

Conversation

@thivindu

@thivindu thivindu commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Purpose

The gateway management API now supports caller-set subscription IDs and in-place plan and token updates (#3804), and the ap CLI supports them too (#3820). This PR brings the same support to the gateway operator's Subscription CR on the 1.2.x operator Helm chart branch.

It backports #3918. The subscription changes are identical; only the generated zz_generated.deepcopy.go files differ, because main has API types this branch doesn't.

Until now, a deployed Subscription CR could only change spec.status:

  • Changing spec.subscriptionPlanId or spec.subscriptionToken was rejected as immutable.
  • Rotating the token's valueFrom Secret failed with "the gateway management API cannot update an existing subscription token".

In every case the only option was to delete and recreate the CR. That left the subscription missing in between, and gave it a new gateway ID.

Related To:

Related PRs:

Changes

Subscription CR (v1 and v1alpha1)

  • New optional spec.externalRefId: used as the gateway subscription ID instead of a generated UUID.
    • Validated in the CRD: 1–64 characters, matching ^[A-Za-z0-9][A-Za-z0-9._-]*$.
    • Must be unique on the gateway, across all APIs.
    • Immutable once deployed.
  • Updated in place: subscriptionPlanId, subscriptionToken and status.
  • Still immutable: externalRefId, apiId, applicationId, billingCustomerId and billingSubscriptionId.
  • Regenerated files: deepcopy code, config/crd/bases, and the Helm chart's CRD copy.

Controller

  • Every reconcile of a deployed CR sends one PUT with the status, the plan and the token. This keeps the CR the source of truth:
    • Removing subscriptionPlanId sends "subscriptionPlanId": null, which clears the plan on the gateway.
    • Sending the token on every update applies a rotated valueFrom Secret. The existing Secret drift detection triggers the redeploy.
  • The status is applied even when the plan or token can't be:
    • If the token Secret is missing, or the gateway rejects the plan or token with 400 or 409, the operator sends the status on its own, and the CR still reports the original error.
    • So a deleted SubscriptionPlan CR or a missing Secret can't block revoking or deactivating a subscription.
    • Failures that clear on their own, such as a SubscriptionPlan that isn't deployed yet, are retried as before, and everything is then sent in one PUT. A 404 is not followed by a status-only update.
  • Immutability check: it now uses a fingerprint of only the immutable fields.
    • A CR may have no fingerprint yet: it was created by an earlier operator version, or its annotation write was lost. For such a CR, the operator first checks that the gateway subscription in status.id is still listed under spec.apiId, belongs to spec.applicationId, and, on gateways that report it, matches spec.externalRefId.
    • Only then is the spec recorded as the baseline, and the old create-fields annotation (subscription-last-applied-create-fingerprint) removed. That annotation also covered the plan and token.
    • If the check fails, the CR gets the immutable-field error and nothing is sent. An edit the earlier operator rejected, such as a changed apiId, therefore stays rejected after the upgrade.
  • Create conflict recovery: when a create gets 409, the existing subscription is adopted only if:
    • with externalRefId, it has that ID, is listed under the CR's own API, and belongs to the CR's application;
    • without externalRefId, it's the only subscription on the API for the CR's application. Application IDs are compared after trimming; unset, empty and blank all mean no application;
    • and, either way, no other Subscription CR on a shared gateway already holds that ID in status.id. Otherwise both CRs would manage, and on deletion remove, the same subscription.
  • When nothing can be adopted:
    • With externalRefId, the CR gets a generic, non-retryable conflict that doesn't say whether the ID or the token collided. This stops a CR from taking over another subscription and overwriting its plan and token.
    • Without externalRefId, the create is retried as before.
  • Gateways without Allow caller-set subscription IDs, in-place plan/token updates, and fix subscription plan conflict errors #3804 ignore externalRefId, assign their own IDs, and leave externalRefId out of their responses. On those, recovery uses the API and application lookup, as it did before.

Gateway client

  • SubscriptionCreatePayload and SubscriptionResponse carry externalRefId.
  • SubscriptionUpdatePayload adds subscriptionPlanId (always sent; nil is an explicit null) and subscriptionToken.
  • New UpdateSubscriptionStatus, which sends only the status and leaves the plan and token unchanged.
  • New ListSubscriptions and SameApplication. FindSubscriptionIDByAPIAndApplication now uses them: it matches normalized application IDs, and returns an ID only when exactly one subscription matches.

Example

gateway/examples/subscription.yaml documents externalRefId and which fields can change in place.

Compatibility

  • Existing CRs keep working: they need no changes. Their annotations migrate on the next reconcile, once the gateway subscription is confirmed to match the CR. A CR holding an immutable-field edit that the earlier operator rejected stays rejected.
  • Gateway version: in-place plan and token updates and externalRefId need a gateway with the subscription API changes from Allow caller-set subscription IDs, in-place plan/token updates, and fix subscription plan conflict errors #3804. Older gateways accept the PUT but ignore the plan and token, so such changes don't take effect there. Status updates and create conflict recovery work as before.

Known limitations

  • If a subscription is removed from the gateway directly, for example by a manual delete or a database reset, the CR still ends up failed rather than recreating it. The same happened before this PR for CRs that set spec.status.
  • Two CRs that create the same subscription at the same moment can both pass the ownership check, since neither has a status.id yet.

Testing

  • Unit tests:
    • ListSubscriptions, SameApplication, and the update payload, including the explicit null plan;
    • FindSubscriptionIDByAPIAndApplication, including matching only the normalized application and returning nothing when several subscriptions match;
    • conflict recovery:
      • adopting an externalRefId subscription on the same API and application;
      • refusing one on another API or for another application with a generic 409;
      • refusing when the API has no subscriptions;
      • the fallback on gateways without externalRefId, and when externalRefId is unset or empty;
    • ownership: refusing an ID that another CR on the same gateway holds, while allowing the CR's own ID and the same ID on another gateway;
    • updates:
      • status, plan and token in one PUT;
      • a status-only PUT after a 400 or 409, or when the token Secret is missing;
      • no status-only PUT after a 404, or while waiting for a SubscriptionPlan to deploy;
    • the upgrade baseline:
      • recorded when the gateway subscription matches;
      • rejected when apiId, applicationId or externalRefId no longer match;
      • accepted under a gateway-assigned ID on older gateways;
    • the existing Secret drift test.
  • go build, go vet and all operator unit tests pass.
  • make manifests generate with the pinned controller-gen v0.16.5 produces no further changes, and the Helm chart's CRD copy matches the generated base.

🤖 Generated with Claude Code

…operator

Add an optional spec.externalRefId to the Subscription CR, used as the
gateway subscription ID. Update subscriptionPlanId, subscriptionToken
(including a rotated valueFrom Secret) and status in place through the
management API instead of rejecting them as immutable. Removing the plan
from the CR clears it on the gateway.

Only externalRefId, apiId, applicationId and the billing IDs stay
immutable. The immutable-fields fingerprint replaces the old
create-fields fingerprint, and the legacy annotation is removed on the
next reconcile.

After a create conflict, an existing subscription is adopted only when it
belongs to the CR's API and application:
- With externalRefId, the subscription with that ID must be listed under
  the CR's API and application; otherwise a generic, non-retryable 409 is
  reported that doesn't say whether the ID or the token collided.
- Without it, an ID is returned only when exactly one subscription on the
  API matches the CR's application (unset, empty and blank application
  IDs mean no application); otherwise the conflict is retried.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@thivindu thivindu changed the title [Gateway Operator 1.2.x] externalRefId and in-place plan/token updates for Subscription CRs- #3918 [Gateway Operator 1.2.x] externalRefId and in-place plan/token updates for Subscription CRs Oct 30, 2026
- Retry the reconcile when the status-only fallback fails transiently, keeping the
  original error in the message; a successful or permanent failure still reports
  the original error.
- Apply the status-only fallback to a subscription adopted after a create conflict
  too, through one applySubscriptionUpdate helper for both paths.
- Fail the update when the gateway reports a different plan than the one sent, so a
  gateway that ignores in-place plan updates doesn't leave the CR Programmed.
- Compare billingCustomerId and billingSubscriptionId before recording the baseline
  for a CR without a stored fingerprint.
- Add tests for each, and for an immutable-field edit after the baseline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
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