feat: propagate Crossplane default labels and user metadata to all Cloud Foundry resources#281
feat: propagate Crossplane default labels and user metadata to all Cloud Foundry resources#281gergely-szabo-sap wants to merge 16 commits into
Conversation
a8b031a to
5c94f9c
Compare
5c94f9c to
c31264d
Compare
Add internal/clients/metadata package with helpers for managing Cloud
Foundry resource metadata (labels and annotations) across all providers:
- BuildMetadata: merges Crossplane default labels (GetExternalTags)
with user labels/annotations into *cfresource.Metadata; nil pointer
values produce CF deletion markers via RemoveLabel/RemoveAnnotation
- MetadataMapEqual: exact equality check (nil == empty, nil-ptr != ptr(""))
- MetadataMapContains: subset check (desired ⊆ actual); extra keys in
actual are ignored -- correct for merge-patch drift detection where
CF resources may have system labels the provider doesn't manage
- IsMetadataUpToDate: convenience wrapper using MetadataMapContains
for both labels and annotations
- DiffMetadata: computes merge-patch diff for Update calls; only
processes keys in desired (keys in actual but not desired are left
unchanged per CF API convention); includes nil-value deletion
markers; accepts both label and annotation pairs
These helpers will replace per-controller duplicate implementations
(e.g. SRB controller's metadataMapEqual/isMetadataUpToDate) and
provide the foundation for issue #39 external resource labeling.
Add shared metadata client helpers (BuildMetadata, IsMetadataUpToDate, DiffMetadata) for issue #39 external resource labeling support. Standardize all 8 eligible resource types to use ResourceMetadata inline struct consistently, replacing the mixed separate Labels/Annotations fields on Space, Org, Domain, and ServiceInstance. Update downstream consumers (client observation functions, domain GenerateUpdate, exporter convert files) accordingly.
Move the "nil managed resource" test case from a nested position inside the "nil pointer value overrides default label" test to a top-level t.Run sibling in TestBuildMetadata. Replace SRB controller's local isMetadataUpToDate and metadataMapEqual helpers with metadata.IsMetadataUpToDate and metadata.MetadataMapEqual from the shared metadata package. This adopts correct merge-patch semantics (subset check via MetadataMapContains) instead of exact equality, and removes ~45 lines of duplicated logic. Update two SRB test expectations for subset semantics: - ExtraLabelInActual: false -> true (desired is subset of actual) - SpecWithoutMetadataBindingHasLabels: false -> true (empty desired is subset of anything)
…ayloads All 7 eligible resource Update functions now accept a managed resource parameter and call BuildMetadata, ensuring default Crossplane labels (crossplane-kind, crossplane-name, crossplane-providerconfig) and user-provided labels/annotations from forProvider are included in CF Update API calls. Controllers updated to pass the managed resource to Update calls. Route RouteService interface Update signature extended with mg param. SCB update replaces TODO with actual metadata handling. SRB update now always sets metadata including defaults.
Replace hardcoded 'return true' stubs in Domain, Route, and SCB IsUpToDate functions with real metadata comparison using the shared metadata package. Thread the managed resource through all IsUpToDate signatures so BuildMetadata can merge Crossplane default labels with user-specified labels/annotations for comparison against observed CF resource metadata. For ServiceInstance, split the existing IsUpToDate into a specUpToDate helper (name, plan, URLs) and a new IsUpToDate that also checks metadata. Controller observe tests updated to expect ResourceUpToDate: false since fake mocks don't provide CF metadata, correctly reflecting that metadata drift is now always detected.
…ceInstance Copy Labels and Annotations from CF resource metadata into the Crossplane observation during GenerateObservation / UpdateObservation. This closes the metadata loop — without read-back, IsUpToDate would see empty status.Labels and perpetually flag metadata drift. Domain, Org, Space, and SRB already had this from earlier rounds. App, Route, ServiceCredentialBinding, and ServiceInstance were missing it.
…le update guards Org IsUpToDate now accepts the managed resource and compares metadata via the shared package, completing the last holdout. Org LateInitialize backfills spec labels/annotations from CF when empty, preventing overwrite of pre-existing metadata. Domain and Space controller Update guards removed: the IsUpToDate check now properly gates all changes including metadata, and DomainUpdate has no Name field so the old name-change guard was a no-op.
Add metadata builder methods to App, Domain, Org, Route, ServiceCredentialBinding, ServiceInstance, and Space fakes. Each returns itself for chaining and nil-initializes Metadata. Route also gets a new Route wrapper type with SetHost/SetGUID alongside the existing FakeRoute constructor and MockRoute mock, so controller tests can build routes with metadata via the builder pattern.
Add GenerateCreate, GenerateUpdate, GenerateObservation, and IsUpToDate tests for Domain, Org, and Space clients (new files). Add TestIsUpToDate_Metadata to App, Route, SCB, and ServiceInstance existing test files. Covers nil metadata, label drift/match, annotation drift/match, and subset semantics where applicable.
Add e2e test coverage for Crossplane default labels and user-provided labels/annotations on CF resources. New AssertDefaultLabels and AssertLabelsAndAnnotations helpers verify crossplane-kind, crossplane-name, and crossplane-providerconfig labels as well as user metadata. - Add label/annotation assertions to app, orgspace, services, and SRB tests - Add labels and annotations to all CR YAML manifests - Poll for updated labels after SRB update (reconciler async) - Skip label checks for observe-only resources (Org, Domain, etc.) - Fix gofmt spacing, trailing whitespace, missing newlines - Fix dangling close-paren syntax error in orgspace test - Move SRB updated manifest to updated/ subdirectory
Verify that Crossplane default labels (crossplane-kind, crossplane-name, crossplane-provider-config) are correctly applied to existing Space resources after a provider upgrade. - Pre-upgrade: assert no crossplane-* labels on CF resource - Post-upgrade: assert all 3 default labels present with correct values - Uses observe-only Org CR as Space dependency - Follows CustomUpgradeTestBuilder pattern from existing upgrade tests
…rces Add metadata.StripDefaultLabels() to remove crossplane-kind, crossplane-name, and crossplane-provider-config labels from CF resource metadata before writing exported CR manifests. These labels are infrastructure metadata computed by the controller and should not appear in ForProvider specs — a new CR gets different values and the controller recomputes them on reconcile. - New: cmd/exporter/cf/metadata/metadata.go — StripDefaultLabels helper - Updated app, org, serviceinstance, and space exporters - App exporter also gains previously missing ResourceMetadata block
- Group metadata import with other crossplane-provider-cloudfoundry imports in exporter convert files (goimports convention) - Remove json.MarshalIndent debug logging from SRB e2e test poll loop
…eByGUID Replace the hybrid GetByIDOrSpec lookup with two dedicated methods: - FindRouteBySpec: backwards-compat lookup when external name is empty - GetRouteByGUID: direct GUID lookup (primary path) Moves late-initialization to the backwards-compat block, running before GUID validation. GetRouteByGUID returns (obs, exists, error) instead of the old nil-check convention. Note: test mocks still reference GetByIDOrSpec and need updating.
…data check - Add metadata.StripDefaultLabels to remove crossplane-kind/name/provider labels from late-initialized spec.Labels in org.LateInitialize - Fix serviceroutebinding controller: call BuildMetadata before IsMetadataUpToDate to include default labels in comparison (was comparing bare user labels only) - Update controller tests: add withDefaultMetadataLabels() modifier to set GVK, populate CF resource mocks with crossplane-* labels, correct ResourceUpToDate expectations from false to true
c31264d to
5cd984c
Compare
Both spaces had forProvider.name 'e2e-space', causing a CF naming collision within the same org. Renamed to e2e-space-ref and e2e-space-name to match their CR metadata names.
|
This PR has been split into 6 smaller, independently reviewable PRs for easier review:
Merge order: #287 → #288 → #289 → #290 → #291 → #292 Each PR builds and passes all tests independently. Please review them in order. |
PR Review Guide: Support External Resource LabelingFeature OverviewThis PR adds two capabilities to the provider:
Before this PR
After this PR
Step-by-Step WalkthroughStep 1: Shared metadata type —
|
| Function | Purpose |
|---|---|
BuildMetadata(mg, userLabels, userAnnotations) |
Merges default Crossplane labels with user metadata into a *cfresource.Metadata for Create/Update calls |
IsMetadataUpToDate(desired, desired, actual, actual) |
Subset-check: returns true iff every key in desired exists and matches in actual |
MetadataMapContains(desired, actual) |
Core subset check for a single map — used by IsMetadataUpToDate |
MetadataMapEqual(desired, actual) |
Exact equality check — kept for test utility, not used in drift detection |
DiffMetadata(desired, desired, actual, actual) |
Produces a minimal *cfresource.Metadata merge-patch for Update calls |
StripDefaultLabels(labels) |
Removes crossplane-kind/crossplane-name/crossplane-providerconfig from a label map |
Key design decisions:
-
Subset semantics, not exact equality.
MetadataMapContainsonly checks
that desired keys are present and correct in actual. Extra keys in actual
are ignored. This is critical because:- The CF platform may add its own labels
- Other actors (humans, CI) may add labels the provider didn't set
- With exact equality, any extra label → perpetual drift loop
-
BuildMetadataalways includes default labels. Even if
spec.forProvider.labelsis empty,BuildMetadatacalls
resource.GetExternalTags(mg)which returns at leastcrossplane-kindand
crossplane-name. User labels override defaults on key collision. -
Nil pointer = deletion marker. In
spec.forProvider.labels, a key with
anilvalue means "delete this label from the CF resource on Update."
BuildMetadatapasses these through viam.RemoveLabel(). -
StripDefaultLabelsprevents spec pollution. Used inLateInitialize
and the exporter to strip default labels from maps that will be written into
spec.forProvider.labels. The controller recomputes these on every
reconcile — storing them in spec would be semantically incorrect.
Step 3: Client wiring — Create, Update, IsUpToDate
All 8 metadata-capable clients follow the same pattern. Using App as the
representative example:
Create (internal/clients/app/app.go):
appCreate.Metadata = metadata.BuildMetadata(mg, spec.Labels, spec.Annotations)Update (internal/clients/app/app.go):
appUpdate.Metadata = metadata.BuildMetadata(mg, spec.Labels, spec.Annotations)IsUpToDate (internal/clients/app/app.go):
desired := metadata.BuildMetadata(mg, spec.Labels, spec.Annotations)
metadataUpToDate := metadata.IsMetadataUpToDate(desired.Labels, desired.Annotations, status.Labels, status.Annotations)
return !changes.HasChanges() && metadataUpToDate, nilKey observation: All three code paths use BuildMetadata(mg, spec.Labels, spec.Annotations) — the same function produces the desired metadata set. This
ensures consistency: what you create is what you check for drift.
Variations across resources:
| Resource | IsUpToDate pattern |
|---|---|
| App | !changes.HasChanges() && metadataUpToDate |
| Domain | Metadata-only check (no spec changes) |
| Org | Short-circuits on spec.Name != observed.Name first |
| Space | Short-circuits on name/SSH mismatch first |
| Route | Returns metadata result directly |
| ServiceInstance | Combined with specUpToDate and credentialsUpToDate |
| SCB | Metadata-only check |
| SRB | In controller's handleObservationState, not client IsUpToDate |
Step 4: Observation population — reading labels back from CF
Each client's GenerateObservation / UpdateObservation function copies the
CF resource's metadata into the CR's status:
// From internal/clients/space/space.go
if o.Metadata != nil {
obs.Labels = o.Metadata.Labels
obs.Annotations = o.Metadata.Annotations
}This makes labels/annotations visible in status.atProvider.labels and
status.atProvider.annotations.
Step 5: LateInitialize — preventing default label pollution in spec
Only Org currently has a LateInitialize that copies observed labels into
spec.forProvider.labels. It uses StripDefaultLabels:
// From internal/clients/org/org.go
if len(spec.Labels) == 0 && from.Metadata != nil {
spec.Labels = metadata.StripDefaultLabels(from.Metadata.Labels)
}Without StripDefaultLabels, the observed default labels (crossplane-kind,
crossplane-name, crossplane-providerconfig) would be copied into
spec.forProvider.labels, which is wrong because:
- The CR name may change — but the old
crossplane-namevalue would be frozen
in spec. - The controller recomputes these labels on every reconcile from the CR's
current identity — storing stale values in spec is semantically incorrect. - If the CR is exported and re-imported with a different name/ProviderConfig,
the stale default labels would be applied to the new resource.
Step 6: SRB controller — handleObservationState
The ServiceRouteBinding controller has a unique pattern: its IsUpToDate logic
lives in the controller (not the client), inside handleObservationState. This
is because SRB uses a last-operation state machine instead of a direct
IsUpToDate call.
CF Service Route Bindings are asynchronous-only operations. When you create
or delete an SRB, the CF API doesn't return a synchronous result — it starts a
"last operation" that transitions through Initial → InProgress → Succeeded|Failed.
The SRB controller must inspect binding.LastOperation.State to know whether
the resource is ready, still working, or failed. You can't just call IsUpToDate
unconditionally because:
- If the last operation is
InProgress, the resource is still being
created/deleted — callingIsUpToDatewould be meaningless and might trigger
a redundant Update while the operation is running. - If the last operation is
Failed, the resource is in an error state —
IsUpToDateis irrelevant until the failure is resolved. - Only when the last operation is
Succeededdoes it make sense to ask "is the
current state matching what I want?"
So the SRB controller uses a state machine (handleObservationState) with a
switch on the last operation state. The metadata drift check only runs inside
the LastOperationSucceeded branch. This is why the check lives in the
controller rather than the client — the decision of when it's safe to check
drift is tightly coupled to the state machine logic.
By contrast, the other 7 resources (App, Domain, Space, etc.) are synchronous —
the controller fetches the CF resource, populates the observation, then calls
client.IsUpToDate(cr, spec, status) as a single function call. Adding metadata
drift detection for those resources meant adding one line to the client's
IsUpToDate.
The LastOperationSucceeded branch now calls BuildMetadata and
IsMetadataUpToDate:
// From internal/controller/serviceroutebinding/controller.go
case v1alpha1.LastOperationSucceeded:
// ...
desired := metadata.BuildMetadata(cr, cr.Spec.ForProvider.Labels, cr.Spec.ForProvider.Annotations)
upToDate := metadata.IsMetadataUpToDate(desired.Labels, desired.Annotations, actualLabels, actualAnnotations)
return managed.ExternalObservation{ResourceExists: true, ResourceUpToDate: upToDate}, nilStep 7: Exporter — stripping default labels from exported CRs
File: cmd/exporter/cf/metadata/metadata.go
The exporter strips default Crossplane labels from exported CR manifests. This
is the same StripDefaultLabels logic as in the client package, but lives in
the exporter's own package (no import cycle).
Why: Exported CRs may be imported into a different cluster with a different
name, namespace, or ProviderConfig. The default labels are environment-specific
and must be regenerated by the controller at the destination.
Step 8: Route controller refactoring
The route controller's GetByIDOrSpec was split into:
FindRouteBySpec(forProvider)— used during adoption (no external name set)GetRouteByGUID(guid)— used during normal reconciliation
This is a structural change that was needed because the old combined method
didn't cleanly support the Create/Update metadata wiring. The split makes the
reconciliation paths explicit.
Step 9: Tests
Unit tests (internal/clients/metadata/metadata_test.go):
MetadataMapContains— subset semantics, nil handling, empty mapsMetadataMapEqual— exact equalityDiffMetadata— merge-patch generationIsMetadataUpToDate— combined labels+annotations check
Controller tests — all 8 controllers updated:
args.mguseswithDefaultMetadataLabels()to set GVK (required because
GetExternalTagsuses the GVK to producecrossplane-kind)- Mock CF resources use
.SetLabels(...)with matching default labels want.mgalso useswithDefaultMetadataLabels()(GVK persists after Observe)ResourceUpToDate: truefor cases where all conditions (spec + metadata) matchResourceUpToDate: falsekept for cases with spec mismatches that
short-circuit before the metadata check
E2e tests (test/e2e/):
- Existing YAML manifests extended with
labelsandannotationsfields - Existing "ready" assess steps extended with
AssertDefaultLabelschecks - No separate label-update test steps — labels are verified inline during the
normal reconciliation flow
Upgrade test (test/upgrade/label_upgrade_test.go):
- Pre-upgrade: verifies no
crossplane-*labels exist on existing resources - Post-upgrade: verifies all 3 default labels are present with correct values
- Uses
CustomUpgradeTestBuilderwith custom pre/post assessment functions
Backward Compatibility
| Scenario | Impact |
|---|---|
| Existing managed resources | Default labels are applied on next Update cycle after upgrade. No manual intervention needed. |
CR spec (spec.forProvider) |
Unchanged — LateInitialize strips default labels before writing into spec. |
| Drift detection | Changed from exact-match (SRB) or none (others) to subset-check. Extra labels on CF resources no longer trigger updates. |
| Exported CRs | Default labels stripped — safe for import into different environments. |
| Observe-only resources (managementPolicies: [Observe]) | No default labels applied — no impact. |
| } | ||
| return !changes.HasChanges(), nil | ||
| desired := metadata.BuildMetadata(mg, spec.Labels, spec.Annotations) | ||
| metadataUpToDate := metadata.IsMetadataUpToDate(desired.Labels, desired.Annotations, status.Labels, status.Annotations) |
There was a problem hiding this comment.
Wouldn't this create an infinite loop? DetectChanges only checks the image and name, but if a Label or Annotation changed, HasChanges is false, Update will not call client.Update because in the controller, we have } else if changes.HasChanges() {, and it will do the same in the next reconciliation. Isn't a simple else enough in Update so it always updates when isUpToDate is false?
| // return always true, as for now the Org resource is observe only | ||
|
|
||
| return spec.Name == observed.Name | ||
| func IsUpToDate(mg xpresource.Managed, spec v1alpha1.OrgParameters, observed *resource.Organization) bool { |
There was a problem hiding this comment.
Org is observe-only I think, so we couldn't reconcile the drift. Do we even need this here? Maybe a simple return spec.Name == observed.Name is enough to at least see if the Org name changed externally.
| } | ||
| // TODO: ADD labels and annotations | ||
|
|
||
| if len(spec.Labels) == 0 && from.Metadata != nil { |
There was a problem hiding this comment.
Shouldn't we implement the same for Space, Domain, App, etc., but skip it for Org because it's observe-only?
| for key, desiredVal := range desired { | ||
| actualVal, exists := actual[key] | ||
| if !exists { | ||
| return false |
There was a problem hiding this comment.
Shouldn't we check if we are here because we have set desiredVal to nil - continue if
desiredVal is nil, or every deletion loops.
| m := cfresource.NewMetadata() | ||
| m.Labels = diffMap(desiredLabels, actualLabels) | ||
| m.Annotations = diffMap(desiredAnnotations, actualAnnotations) | ||
| return m |
There was a problem hiding this comment.
Shouldn't we return nil when both diffMaps are empty? Why return the Metadata when nothing changed?
| } else if changes.HasChanges() { | ||
| _, err := c.client.Update(ctx, guid, cr.Spec.ForProvider) | ||
| _, err := c.client.Update(ctx, guid, cr, cr.Spec.ForProvider) | ||
| if err != nil { |
There was a problem hiding this comment.
if the metadata gets changed, won't it fall through here silently as there is no else block. Won't this creates an infinite reconcile loop: Observe returns ResourceUpToDate: false forever but Update never fixes it.
| // +mapType=granular | ||
| Labels map[string]*string `json:"labels,omitempty"` | ||
| // (Attributes) The metadata associated with the Cloud Foundry resource. | ||
| ResourceMetadata `json:",inline"` |
There was a problem hiding this comment.
I noticed that ResourceMetadata is missing // +mapType=granular, wouldn't this be a behaviour regression? Maybe we can add it to the struct's fields?
Summary
Adds automatic propagation of Crossplane default labels (crossplane-kind, crossplane-name, crossplane-provider-config) and user-specified metadata (labels/annotations) to all Cloud Foundry resources managed by the provider.
Default Crossplane labels
When a managed CF resource is created or updated, the provider automatically applies
Crossplane-recommended labels from resource.GetExternalTags() (crossplane-runtime
v1.20.0). These are:
Where they appear:
How behavior changes:
Backward compatibility:
Resources without metadata support:
Six CF resource types (OrgQuota, SpaceQuota, OrgRole, SpaceRole, OrgMembers, SpaceMembers) do not support metadata in the CF API. These are excluded from label/annotation propagation — the ResourceMetadata struct is not embedded in their CRD types, and no metadata drift detection is performed.
Changes
Shared metadata infrastructure
Resource metadata consolidation
Client and controller wiring
Exporter
Route controller refactoring
Tests