Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ kubectl get xrdconversionconfig xpostgresqlinstances-conversion -o yaml

## Examples

[`examples/`](examples/) holds five self-contained conversion stories, smallest first — a field rename, an enum remap, a `forEach` array reshape, a three-version Crossplane XR migration, and the same model against a plain native CRD. Each directory has a schema, a config, sample objects at every served version, and a README explaining the scenario, and each is independently runnable offline:
[`examples/`](examples/) holds six self-contained conversion stories, smallest first — a field rename, an enum remap, a `forEach` array reshape, a `oneOf` union branch mapping, a three-version Crossplane XR migration, and the same model against a plain native CRD. Each directory has a schema, a config, sample objects at every served version, and a README explaining the scenario, and each is independently runnable offline:

```console
go run ./cmd/convctl test --config examples/field-rename/xrdconversionconfig.yaml \
Expand All @@ -95,7 +95,7 @@ go run ./cmd/convctl test --config examples/field-rename/xrdconversionconfig.yam

## Conversion strategies

`fieldRename`, `scalarToObject` / `objectToScalar`, `singletonArrayToObject` / `objectToSingletonArray`, `fieldsToMap` / `mapToFields`, `toAnnotation` / `toLabel`, `fromAnnotation` / `fromLabel`, `enumRemap`, `defaultValue`, `constant`, `delete`, `jsonPatch` (escape hatch), `forEach` (per-array-element, up to two nested levels), `typeCoerce`, `scalarToFields` / `fieldsToScalar`, `arrayToMapByKey` / `mapToArrayByKey`, `numericScale`, `listJoin` / `listSplit`, `quantity`, `duration`, `mapKeyRename`, `cel` (always-lossy value-math escape hatch). Every rule that the engine determines is lossy in any direction requires `acknowledgeLossy: true` plus an optional `reason` — this is enforced by both the admission webhook and the controller, and the default posture is fail-closed: any hub or spoke field left uncovered by a rule (and not structurally identical on both sides) is a validation error, not a silent pass.
`fieldRename`, `scalarToObject` / `objectToScalar`, `singletonArrayToObject` / `objectToSingletonArray`, `fieldsToMap` / `mapToFields`, `toAnnotation` / `toLabel`, `fromAnnotation` / `fromLabel`, `enumRemap`, `defaultValue`, `constant`, `delete`, `jsonPatch` (escape hatch), `forEach` (per-array-element, up to two nested levels), `typeCoerce`, `scalarToFields` / `fieldsToScalar`, `arrayToMapByKey` / `mapToArrayByKey`, `numericScale`, `listJoin` / `listSplit`, `quantity`, `duration`, `mapKeyRename`, `cel` (always-lossy value-math escape hatch), `branchMap` (maps the branches of a `oneOf` union between versions). Every rule that the engine determines is lossy in any direction requires `acknowledgeLossy: true` plus an optional `reason` — this is enforced by both the admission webhook and the controller, and the default posture is fail-closed: any hub or spoke field left uncovered by a rule (and not structurally identical on both sides) is a validation error, not a silent pass.

A few of the newer strategies are worth calling out specifically:

Expand All @@ -104,6 +104,7 @@ A few of the newer strategies are worth calling out specifically:
- **`arrayToMapByKey`** / **`mapToArrayByKey`** convert a list of objects into a map keyed by one of their fields, and back — the standard "list-map versus map" API-evolution pattern. Array→map is lossless (a duplicate or missing key is a hard runtime error, never a silent drop); map→array is always treated as lossy, since the reconstructed array is emitted sorted by key rather than reproducing whatever order the original array had.
- **`numericScale`** rescales a numeric field by a fixed factor (`hubValue == spokeValue * factor`) — e.g. stored megabytes displayed as gigabytes. Whichever direction lands on an integer-typed field is treated as lossy, since the division/multiplication may not land on a whole number for every possible input.
- **`listJoin`** / **`listSplit`** convert an array of scalars into a single delimited string, and back. Always lossless; an element that happens to contain the separator as a substring will fail to round-trip cleanly, which is correctly surfaced by `convctl test` as a genuine data problem rather than an expected characteristic of the strategy.
- **`branchMap`** maps the branches of a `oneOf` union — the "exactly one of `s3`, `gcs` or `azure`" shape — between versions, remapping an optional discriminator alongside and running nested rules scoped to each branch. In a CRD the apiserver requires every property named inside a `oneOf` to also be declared in the parent's own `properties`, so a branch is an ordinary addressable field and the active one is identified by which branch property is present, not by validating against each branch schema. Converting an object with no branch set, or with more than one, is a hard error in both directions; collapsing two hub branches onto one spoke branch is expressible but lossy coming back.

`internal/cli/testdata/full` exercises all of these (and every other built-in strategy) end to end across a 3-version fixture — it's the best starting point for seeing exact YAML shapes in context.

Expand Down Expand Up @@ -166,14 +167,14 @@ a [kind](https://kind.sigs.k8s.io/) cluster, builds this repo's
`manager`/`webhook-server` images and loads them straight into the cluster
(no registry push), and installs the operator via its own Helm chart:

- `make test-e2e` (`hack/e2e-test.sh`) — both features enabled (the common case): installs cert-manager and [Crossplane](https://crossplane.io) (v2 — this operator targets Crossplane's current `apiextensions.crossplane.io/v2` XRD API), applies a real `CompositeResourceDefinition` + `XRDConversionConfig` covering all 29 built-in strategies, and confirms composite resources created at every served version read back correctly converted at every other version.
- `make test-e2e` (`hack/e2e-test.sh`) — both features enabled (the common case): installs cert-manager and [Crossplane](https://crossplane.io) (v2 — this operator targets Crossplane's current `apiextensions.crossplane.io/v2` XRD API), applies a real `CompositeResourceDefinition` + `XRDConversionConfig` covering all 30 built-in strategies, and confirms composite resources created at every served version read back correctly converted at every other version.
- `make test-e2e-crd-only` (`hack/e2e-test-crd-only.sh`) — `features.crossplane.enabled=false`, Crossplane never installed at all: confirms the manager comes up healthy with no Crossplane CRDs on the cluster, that a `CRDConversionConfig` against a plain native CRD converts correctly, and that an `XRDConversionConfig` is rejected outright by the admission webhook.
- `make test-e2e-crossplane-only` (`hack/e2e-test-crossplane-only.sh`) — `features.nativeCRD.enabled=false`: confirms XRD/Crossplane conversion is unaffected by disabling native CRD support, and that a `CRDConversionConfig` is rejected outright.
- `make test-e2e-legacy-claims` (`hack/e2e-test-legacy-claims.sh`) — `scope: LegacyCluster` with `claimNames`, the shape every cluster upgraded from Crossplane 1.x still runs and the only one that generates a **claim CRD**: proves a claim created at `v1` reads back correctly converted at `v2` and `v3`, that the bare `spec.*` machinery layout (`compositionRef`, `claimRef`, `resourceRef`, `compositeDeletePolicy`, `writeConnectionSecretToRef`) survives conversion on both object classes, that a condition the test itself writes survives alongside Crossplane's, that **both** generated CRDs carry `spec.conversion` and `ConversionPropagated` reaches True, and that `convctl test --live` and `migrate-storage --prune-stored-versions` cover both.
- `make test-e2e-package-managed` (`hack/e2e-test-package-managed.sh`) — the **XRD conversion guard**: replays the Crossplane package establisher's full non-SSA replace of an XRD in a loop and asserts that not one read at a non-storage version ever comes back unconverted. Then repeats with the guard disabled and asserts the loop **does** catch bad reads — a guard test that cannot fail is not a test. The failure mode is an HTTP 200 with wrong data, so the loop checks converted field values rather than exit codes. Also needs `python3`.
- `make test-e2e-load` (`hack/e2e-load.sh`) — native-CRD kind cluster, then synthetic `ConversionReview` batches of varying object count/size against the live webhook-server; prints latency/throughput for [Capacity planning](docs/operations/capacity.md).
- `make test-e2e-reassign` (`hack/e2e-reassign.sh`) — moves a target between two `ConversionWebhookServer` instances, three times (an explicit `webhookServerRef` pin, an unpin, and a sharding-driven move), while sustained reads and writes flow through it, and asserts **zero failed requests and zero wrong values**. Also asserts each move was *verified* against the destination's published served targets rather than taking the unverified fallback, so it cannot pass with the handover mechanism removed. Also needs `python3`.
- `make test-e2e-scale` (`hack/e2e-scale.sh`) — native-CRD kind cluster, then a generated fleet of CRDs (3 versions each, 3–10 strategies per spoke, all 29 strategies used) plus parallel Get/List of live CRs through the apiserver conversion path. Override `TARGETS`, `INSTANCES`, and `PARALLEL` (for example `TARGETS=100 INSTANCES=100 PARALLEL=32`); set `RESULT_JSON` to write the measurements as JSON. Not in the PR matrix — it runs nightly (`.github/workflows/scale.yml`) at 300 × 20, publishing an artifact and failing on a relative regression.
- `make test-e2e-scale` (`hack/e2e-scale.sh`) — native-CRD kind cluster, then a generated fleet of CRDs (3 versions each, 3–10 strategies per spoke, all 30 strategies used) plus parallel Get/List of live CRs through the apiserver conversion path. Override `TARGETS`, `INSTANCES`, and `PARALLEL` (for example `TARGETS=100 INSTANCES=100 PARALLEL=32`); set `RESULT_JSON` to write the measurements as JSON. Not in the PR matrix — it runs nightly (`.github/workflows/scale.yml`) at 300 × 20, publishing an artifact and failing on a relative regression.

Requires `docker`, `kind`, `kubectl`, and `helm` on `PATH` (plus `go` for
`test-e2e-legacy-claims` and `python3` for `test-e2e-package-managed` and
Expand Down
23 changes: 23 additions & 0 deletions api/v1alpha1/xrdconversionconfig_convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,29 @@ func convertParams(r ConversionRule) (engine.RuleParams, error) {
Renames: r.MapKeyRename.Renames,
}, nil

case StrategyBranchMap:
if r.BranchMap == nil {
return nil, errors.New("requires branchMap params")
}
branches := make([]engine.BranchMapping, 0, len(r.BranchMap.Branches))
for i, b := range r.BranchMap.Branches {
nested, err := convertRules(b.Rules)
if err != nil {
return nil, fmt.Errorf("branch %d (%s): %w", i, b.HubBranch, err)
}
branches = append(branches, engine.BranchMapping{
HubBranch: b.HubBranch, SpokeBranch: b.SpokeBranch,
HubDiscriminatorValue: b.HubDiscriminatorValue,
SpokeDiscriminatorValue: b.SpokeDiscriminatorValue,
Rules: nested,
})
}
return engine.BranchMapParams{
HubPath: engine.ParsePath(r.BranchMap.HubPath), SpokePath: engine.ParsePath(r.BranchMap.SpokePath),
Discriminator: r.BranchMap.Discriminator,
Branches: branches,
}, nil

case StrategyCEL:
if r.CEL == nil {
return nil, errors.New("requires cel params")
Expand Down
58 changes: 57 additions & 1 deletion api/v1alpha1/xrdconversionconfig_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ const (
)

// Strategy names one of the engine's built-in conversion strategies.
// +kubebuilder:validation:Enum=FieldRename;ScalarToObject;ObjectToScalar;SingletonArrayToObject;ObjectToSingletonArray;FieldsToMap;MapToFields;ToAnnotation;ToLabel;FromAnnotation;FromLabel;EnumRemap;DefaultValue;Constant;Delete;JSONPatch;ForEach;TypeCoerce;ScalarToFields;FieldsToScalar;ArrayToMapByKey;MapToArrayByKey;NumericScale;ListJoin;ListSplit;Quantity;Duration;MapKeyRename;CEL
// +kubebuilder:validation:Enum=FieldRename;ScalarToObject;ObjectToScalar;SingletonArrayToObject;ObjectToSingletonArray;FieldsToMap;MapToFields;ToAnnotation;ToLabel;FromAnnotation;FromLabel;EnumRemap;DefaultValue;Constant;Delete;JSONPatch;ForEach;TypeCoerce;ScalarToFields;FieldsToScalar;ArrayToMapByKey;MapToArrayByKey;NumericScale;ListJoin;ListSplit;Quantity;Duration;MapKeyRename;CEL;BranchMap
type Strategy string

const (
Expand Down Expand Up @@ -105,6 +105,7 @@ const (
StrategyDuration Strategy = "Duration"
StrategyMapKeyRename Strategy = "MapKeyRename"
StrategyCEL Strategy = "CEL"
StrategyBranchMap Strategy = "BranchMap"
)

// TargetXRDRef identifies the Crossplane CompositeResourceDefinition this
Expand Down Expand Up @@ -303,6 +304,59 @@ type JSONPatchParams struct {
LosslessOverride bool `json:"losslessOverride,omitempty"`
}

// BranchMapParams maps the branches of a union-typed field between hub and
// spoke — the "one of s3, gcs or azure" shape mature platform APIs express
// with `oneOf`.
//
// The active branch is identified by **which branch property is present**,
// not by validating the object against each branch schema. That is what a
// union looks like in a legal CRD: the apiserver requires every property
// named inside a `oneOf` to also be declared in the parent's own
// properties, so a union is a set of declared, mutually-exclusive fields.
//
// No branch set, or more than one, is a hard runtime conversion error.
// That is oneOf's contract — exactly one — and not anyOf's, which permits
// overlap. BranchMap fits an anyOf whose branches are mutually exclusive
// in practice; one that genuinely allows two at once has no single
// correspondence to map, and is better handled with ordinary rules over
// the individual branch properties.
type BranchMapParams struct {
// HubPath and SpokePath are the union-typed OBJECT on each side, not a
// branch within it.
HubPath string `json:"hubPath"`
SpokePath string `json:"spokePath"`
// Discriminator optionally names a sibling property whose value also
// identifies the branch (`backend: s3`). It is remapped alongside the
// branch, so hub and spoke may spell their branch names differently.
// It must be a declared property of both unions.
// +optional
Discriminator string `json:"discriminator,omitempty"`
// +kubebuilder:validation:MinItems=1
Branches []BranchMapping `json:"branches"`
}

// BranchMapping is one branch correspondence.
type BranchMapping struct {
// HubBranch and SpokeBranch are property names inside the two union
// objects. Both must be declared properties.
HubBranch string `json:"hubBranch"`
SpokeBranch string `json:"spokeBranch"`
// HubDiscriminatorValue and SpokeDiscriminatorValue are used only when
// BranchMapParams.Discriminator is set. Empty means the branch's own
// name, which is the common case.
// +optional
HubDiscriminatorValue string `json:"hubDiscriminatorValue,omitempty"`
// +optional
SpokeDiscriminatorValue string `json:"spokeDiscriminatorValue,omitempty"`
// Rules apply to this branch pair, with paths relative to the branch
// itself — `bucket`, not `spec.backup.s3.bucket`. The same scoping
// ForEach gives an array element.
// +optional
// +kubebuilder:pruning:PreserveUnknownFields
// +kubebuilder:validation:Schemaless
Rules []ConversionRule `json:"rules,omitempty"`
}

// ForEachParams applies a nested rule list to each element of a hub array
// and the corresponding spoke array. Nested rule paths are relative to a
// single array element. Nesting is capped at depth 2 (a ForEach may wrap
Expand Down Expand Up @@ -508,6 +562,8 @@ type ConversionRule struct {
MapKeyRename *MapKeyRenameParams `json:"mapKeyRename,omitempty"`
// +optional
CEL *CELParams `json:"cel,omitempty"`
// +optional
BranchMap *BranchMapParams `json:"branchMap,omitempty"`

// AcknowledgeLossy must be true if this rule is lossy in any
// direction, or validation fails (fail-closed default posture).
Expand Down
49 changes: 49 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading