Skip to content
Open
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
50 changes: 36 additions & 14 deletions pkg/client/crd.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import (
"k8s.io/apimachinery/pkg/types"
"k8s.io/apimachinery/pkg/util/wait"
logr "sigs.k8s.io/controller-runtime/pkg/log"

crdcompat "github.com/kubernetes-sigs/kro/pkg/graph/crd/compat"
)

const (
Expand Down Expand Up @@ -110,52 +112,72 @@ func newCRDWrapper(cfg CRDWrapperConfig) *CRDWrapper {
// Ensure ensures a CRD exists, up-to-date, and is ready. This can be
// a dangerous operation as it will update the CRD if it already exists.
//
// The caller is responsible for ensuring the CRD, isn't introducing
// breaking changes.
func (w *CRDWrapper) Ensure(ctx context.Context, crd v1.CustomResourceDefinition) error {
// If a CRD does exist, it will compare the existing CRD with the desired CRD
// and update it if necessary. If the existing CRD has breaking changes, it
// will return an error.
func (w *CRDWrapper) Ensure(ctx context.Context, desired v1.CustomResourceDefinition) error {
log := logr.FromContext(ctx)
existing, err := w.Get(ctx, crd.Name)
existing, err := w.Get(ctx, desired.Name)
if err != nil {
if !apierrors.IsNotFound(err) {
return fmt.Errorf("failed to check for existing CRD: %w", err)
}

log.Info("Creating CRD", "name", crd.Name)
if err := w.create(ctx, crd); err != nil {
log.Info("Creating CRD", "name", desired.Name)
if err := w.create(ctx, desired); err != nil {
return fmt.Errorf("failed to create CRD: %w", err)
}
} else {
kroOwned, nameMatch, idMatch := metadata.CompareRGDOwnership(existing.ObjectMeta, crd.ObjectMeta)
// Check ownership first
kroOwned, nameMatch, idMatch := metadata.CompareRGDOwnership(existing.ObjectMeta, desired.ObjectMeta)
if !kroOwned {
return fmt.Errorf(
"failed to update CRD %s: CRD already exists and is not owned by KRO", crd.Name,
"failed to update CRD %s: CRD already exists and is not owned by KRO", desired.Name,
)
}

if !nameMatch {
existingRGDName := existing.Labels[metadata.ResourceGraphDefinitionNameLabel]
return fmt.Errorf(
"failed to update CRD %s: CRD is owned by another ResourceGraphDefinition %s",
crd.Name, existingRGDName,
desired.Name, existingRGDName,
)
}

if nameMatch && !idMatch {
log.Info(
"Adopting CRD with different RGD ID - RGD may have been deleted and recreated",
"crd", crd.Name,
"crd", desired.Name,
"existingRGDID", existing.Labels[metadata.ResourceGraphDefinitionIDLabel],
"newRGDID", crd.Labels[metadata.ResourceGraphDefinitionIDLabel],
"newRGDID", desired.Labels[metadata.ResourceGraphDefinitionIDLabel],
)
}

log.Info("Updating existing CRD", "name", crd.Name)
if err := w.patch(ctx, crd); err != nil {
// Check for breaking schema changes
report, err := crdcompat.CompareVersions(existing.Spec.Versions, desired.Spec.Versions)
if err != nil {
return fmt.Errorf("failed to check schema compatibility: %w", err)
}

// If there are no changes at all, we can skip the update
if !report.HasChanges() {
log.V(1).Info("CRD is up-to-date", "name", desired.Name)
return nil
}

// Check for breaking changes
if !report.IsCompatible() {
log.Info("Breaking changes detected in CRD update", "name", desired.Name, "breakingChanges", len(report.BreakingChanges), "summary", report)
return fmt.Errorf("cannot update CRD %s: breaking changes detected: %s", desired.Name, report)
}

log.Info("Updating existing CRD", "name", desired.Name)
if err := w.patch(ctx, desired); err != nil {
return fmt.Errorf("failed to patch CRD: %w", err)
}
}

return w.waitForReady(ctx, crd.Name)
return w.waitForReady(ctx, desired.Name)
}

// Get retrieves a CRD by name
Expand Down
166 changes: 166 additions & 0 deletions pkg/graph/crd/compat/changes.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
// Copyright 2025 The Kube Resource Orchestrator Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License"). You may
// not use this file except in compliance with the License. A copy of the
// License is located at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// or in the "license" file accompanying this file. This file is distributed
// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
// express or implied. See the License for the specific language governing
// permissions and limitations under the License.

package compat

import (
"fmt"
"strings"
)

// ChangeType represents the type of schema change
type ChangeType string

const (
// Breaking change types
PropertyRemoved ChangeType = "PROPERTY_REMOVED"
TypeChanged ChangeType = "TYPE_CHANGED"
RequiredAdded ChangeType = "REQUIRED_ADDED"
EnumRestricted ChangeType = "ENUM_RESTRICTED"
PatternChanged ChangeType = "PATTERN_CHANGED"
PatternAdded ChangeType = "PATTERN_ADDED"

// Non-breaking change types
PropertyAdded ChangeType = "PROPERTY_ADDED"
DescriptionChanged ChangeType = "DESCRIPTION_CHANGED"
DefaultChanged ChangeType = "DEFAULT_CHANGED"
RequiredRemoved ChangeType = "REQUIRED_REMOVED"
EnumExpanded ChangeType = "ENUM_EXPANDED"
PatternRemoved ChangeType = "PATTERN_REMOVED"
)

// Change represents a single schema change
type Change struct {
// Path is the JSON path to the changed property
Path string
// ChangeType is the type of change
ChangeType ChangeType
// OldValue is the string representation of the old value (if applicable)
OldValue string
// NewValue is the string representation of the new value (if applicable)
NewValue string
}

// Report contains the full analysis of schema differences
type Report struct {
// BreakingChanges are changes that break backward compatibility
BreakingChanges []Change
// NonBreakingChanges are changes that maintain backward compatibility
NonBreakingChanges []Change
}

// IsCompatible returns true if no breaking changes were detected
func (r *Report) IsCompatible() bool {
return len(r.BreakingChanges) == 0
}

// HasBreakingChanges returns true if breaking changes were detected
func (r *Report) HasBreakingChanges() bool {
return len(r.BreakingChanges) > 0
}

// HasChanges returns true if any changes were detected
func (r *Report) HasChanges() bool {
return len(r.BreakingChanges) > 0 || len(r.NonBreakingChanges) > 0
}

const maxBreakingChangesSummary = 3

// SummarizeBreakingChanges returns a user-friendly summary of breaking changes
func (r *Report) String() string {
if !r.HasBreakingChanges() {
return "no breaking changes"
}

changeDescs := make([]string, 0, maxBreakingChangesSummary)

for i, change := range r.BreakingChanges {
// Cut off the summary if there are too many breaking changes
if i >= maxBreakingChangesSummary {
remaining := len(r.BreakingChanges) - i
if remaining > 0 {
changeDescs = append(changeDescs, fmt.Sprintf("and %d more changes", remaining))
}
break
}
changeDescs = append(changeDescs, change.Description())
}

return strings.Join(changeDescs, "; ")
}

// AddBreakingChange adds a breaking change to the result with automatically generated description
func (r *Report) AddBreakingChange(path string, changeType ChangeType, oldValue, newValue string) {
r.BreakingChanges = append(r.BreakingChanges, Change{
Path: path,
ChangeType: changeType,
OldValue: oldValue,
NewValue: newValue,
})
}

// AddNonBreakingChange adds a non-breaking change to the result with automatically generated description
func (r *Report) AddNonBreakingChange(path string, changeType ChangeType, oldValue, newValue string) {
r.NonBreakingChanges = append(r.NonBreakingChanges, Change{
Path: path,
ChangeType: changeType,
OldValue: oldValue,
NewValue: newValue,
})
}

// lastPathComponent extracts the last component from a JSON path
func lastPathComponent(path string) string {
parts := strings.Split(path, ".")
if len(parts) == 0 {
return path
}
return parts[len(parts)-1]
}

// Description generates a human-readable description based on the change type
func (c Change) Description() string {
propName := lastPathComponent(c.Path)

switch c.ChangeType {
case PropertyRemoved:
return fmt.Sprintf("Property %s was removed", propName)
case PropertyAdded:
if c.NewValue == "required" {
return fmt.Sprintf("Required property %s was added", propName)
}
return fmt.Sprintf("Optional property %s was added", propName)
case TypeChanged:
return fmt.Sprintf("Type changed from %s to %s", c.OldValue, c.NewValue)
case RequiredAdded:
return fmt.Sprintf("Field %s is newly required", c.NewValue)
case RequiredRemoved:
return fmt.Sprintf("Field %s is no longer required", c.OldValue)
case EnumRestricted:
return fmt.Sprintf("Enum value %s was removed", c.OldValue)
case EnumExpanded:
return fmt.Sprintf("Enum value %s was added", c.NewValue)
case PatternChanged:
return fmt.Sprintf("Validation pattern changed from %s to %s", c.OldValue, c.NewValue)
case PatternAdded:
return fmt.Sprintf("Validation pattern %s was added", c.NewValue)
case PatternRemoved:
return fmt.Sprintf("Validation pattern %s was removed", c.OldValue)
case DescriptionChanged:
return "Description field was changed"
case DefaultChanged:
return "Default value was changed"
default:
return fmt.Sprintf("Unknown change to %s", c.Path)
}
}
74 changes: 74 additions & 0 deletions pkg/graph/crd/compat/compat.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// Copyright 2025 The Kube Resource Orchestrator Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License"). You may
// not use this file except in compliance with the License. A copy of the
// License is located at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// or in the "license" file accompanying this file. This file is distributed
// on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either
// express or implied. See the License for the specific language governing
// permissions and limitations under the License.

package compat

import (
"fmt"

v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
)

// CompareVersions compares CRD versions and returns a compatibility report.
// This is a convenience wrapper that extracts schemas from version slices.
// It expects exactly one version in each slice.
func CompareVersions(oldVersions, newVersions []v1.CustomResourceDefinitionVersion) (*Report, error) {
if len(oldVersions) != 1 || len(newVersions) != 1 {
return nil, fmt.Errorf("expected exactly one version in each CRD, got %d old and %d new versions",
len(oldVersions), len(newVersions))
}

oldVersion := oldVersions[0]
newVersion := newVersions[0]

// Check version names match
if oldVersion.Name != newVersion.Name {
return &Report{
BreakingChanges: []Change{
{
Path: "version",
ChangeType: TypeChanged,
OldValue: oldVersion.Name,
NewValue: newVersion.Name,
},
},
}, nil
}

// Verify schemas exist
if oldVersion.Schema == nil || oldVersion.Schema.OpenAPIV3Schema == nil {
return nil, fmt.Errorf("old version %s has no schema", oldVersion.Name)
}

if newVersion.Schema == nil || newVersion.Schema.OpenAPIV3Schema == nil {
return nil, fmt.Errorf("new version %s has no schema", newVersion.Name)
}

// Compare schemas first
report := Compare(oldVersion.Schema.OpenAPIV3Schema, newVersion.Schema.OpenAPIV3Schema)

// Check version metadata changes that could break kro
// Served going from true to false would break API access
if oldVersion.Served && !newVersion.Served {
report.AddBreakingChange("version.served", TypeChanged, "true", "false")
}

// Status subresource removal would break status updates
oldHasStatus := oldVersion.Subresources != nil && oldVersion.Subresources.Status != nil
newHasStatus := newVersion.Subresources != nil && newVersion.Subresources.Status != nil
if oldHasStatus && !newHasStatus {
report.AddBreakingChange("version.subresources.status", PropertyRemoved, "", "")
}

return report, nil
}
Loading