Skip to content

Commit 7eea4bd

Browse files
Merge pull request #8535 from hypershift-community/fix-CNTRLPLANE-502
CNTRLPLANE-502: Add CRD breaking changes validation to HyperShift CI
2 parents 829208f + 9155e6b commit 7eea4bd

114 files changed

Lines changed: 12992 additions & 4 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

Makefile

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ GENAPIDOCS := $(abspath $(TOOLS_BIN_DIR)/gen-crd-api-reference-docs)
2020
MOCKGEN := $(abspath $(TOOLS_BIN_DIR)/mockgen)
2121
YQ := $(abspath $(TOOLS_BIN_DIR)/yq)
2222
VERIFY_API_DEPS := $(abspath $(TOOLS_BIN_DIR)/verify-api-deps)
23+
CRD_SCHEMA_CHECK := $(abspath $(TOOLS_BIN_DIR)/crd-schema-check)
2324

2425
CODESPELL_VER := 2.4.1
2526
CODESPELL_BIN := codespell
@@ -143,8 +144,15 @@ verify-codecov: ## Validate codecov.yml against Codecov's API.
143144
--data-binary @codecov.yml https://codecov.io/validate \
144145
| tee /dev/stderr | grep -q "^Valid!"
145146

147+
.PHONY: verify-crd-schema
148+
verify-crd-schema: $(CRD_SCHEMA_CHECK) ## Verify CRD schemas for breaking changes against base branch.
149+
$(CRD_SCHEMA_CHECK) --comparison-base=$(PULL_BASE_SHA) \
150+
--config=hack/tools/crd-schema-check/crdify-config.yaml \
151+
--crd-dir=cmd/install/assets/crds/hypershift-operator \
152+
--crd-dir=karpenter-operator/controllers/karpenter/assets/zz_generated.crd-manifests
153+
146154
.PHONY: verify-parallel
147-
verify-parallel: verify-codespell verify-codecov verify-api-deps lint cpo-container-sync run-gitlint verify-docs-nav
155+
verify-parallel: verify-codespell verify-codecov verify-api-deps verify-crd-schema lint cpo-container-sync run-gitlint verify-docs-nav
148156

149157
.PHONY: verify
150158
verify: generate update staticcheck fmt vet
@@ -172,6 +180,8 @@ $(MOCKGEN): ${TOOLS_DIR}/go.mod
172180
$(VERIFY_API_DEPS): $(TOOLS_DIR)/go.mod # Build verify-api-deps tool
173181
cd $(TOOLS_DIR); $(GO) build -o $(BIN_DIR)/verify-api-deps ./verify-api-deps
174182

183+
$(CRD_SCHEMA_CHECK): $(TOOLS_DIR)/go.mod # Build crd-schema-check tool
184+
cd $(TOOLS_DIR); $(GO) build -o $(BIN_DIR)/crd-schema-check ./crd-schema-check
175185

176186
.PHONY: generate
177187
generate: $(MOCKGEN)
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# crdify configuration for HyperShift CRD schema checks.
2+
# See https://sigs.k8s.io/crdify for configuration reference.
3+
#
4+
# crdify checks the following by default (no configuration needed):
5+
# - Field removals (removing an existing field from the schema)
6+
# - Type changes (changing a field's type, e.g. string → integer)
7+
# - New required fields (adding a field to the required list)
8+
# - Scope changes (changing between Namespaced and Cluster)
9+
# - Stored version removal (removing a version that is marked as stored)
10+
#
11+
# This mirrors the policy used in openshift/api: adding new enum values
12+
# is technically a breaking change but is allowed in practice because
13+
# clients should tolerate unknown enum values.
14+
validations:
15+
- name: enum
16+
enforcement: Error
17+
configuration:
18+
additionPolicy: Allow
Lines changed: 341 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,341 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"path/filepath"
9+
"strings"
10+
11+
gogit "github.com/go-git/go-git/v5"
12+
"github.com/go-git/go-git/v5/plumbing"
13+
"github.com/go-git/go-git/v5/plumbing/object"
14+
apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1"
15+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
16+
kyaml "sigs.k8s.io/yaml"
17+
18+
"sigs.k8s.io/crdify/pkg/config"
19+
gitloader "sigs.k8s.io/crdify/pkg/loaders/git"
20+
"sigs.k8s.io/crdify/pkg/runner"
21+
"sigs.k8s.io/crdify/pkg/validations"
22+
)
23+
24+
// crdDirs is a custom flag type that collects multiple --crd-dir values.
25+
type crdDirs []string
26+
27+
func (d *crdDirs) String() string { return strings.Join(*d, ",") }
28+
func (d *crdDirs) Set(val string) error {
29+
*d = append(*d, val)
30+
return nil
31+
}
32+
33+
func main() {
34+
if err := run(); err != nil {
35+
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
36+
os.Exit(1)
37+
}
38+
}
39+
40+
func run() error {
41+
var dirs crdDirs
42+
comparisonBase := flag.String("comparison-base", "main",
43+
"git ref to compare CRD schemas against (branch, tag, or SHA)")
44+
configFile := flag.String("config", "",
45+
"path to a crdify configuration file (optional)")
46+
flag.Var(&dirs, "crd-dir", "directory containing CRD YAML files to check (can be specified multiple times)")
47+
flag.Parse()
48+
49+
if len(dirs) == 0 {
50+
return fmt.Errorf("at least one --crd-dir must be specified")
51+
}
52+
53+
repoRoot, err := findRepoRoot()
54+
if err != nil {
55+
return fmt.Errorf("finding repository root: %w", err)
56+
}
57+
58+
repo, err := gogit.PlainOpen(repoRoot)
59+
if err != nil {
60+
return fmt.Errorf("opening git repository at %s: %w", repoRoot, err)
61+
}
62+
63+
baseHash, err := resolveBaseHash(repo, *comparisonBase)
64+
if err != nil {
65+
return fmt.Errorf("resolving comparison base %q: %w", *comparisonBase, err)
66+
}
67+
68+
cfg, err := config.Load(*configFile)
69+
if err != nil {
70+
return fmt.Errorf("loading crdify config: %w", err)
71+
}
72+
73+
crdifyRunner, err := runner.New(cfg, runner.DefaultRegistry())
74+
if err != nil {
75+
return fmt.Errorf("configuring crdify runner: %w", err)
76+
}
77+
78+
var allErrors []string
79+
var allWarnings []string
80+
totalChecked := 0
81+
82+
for _, dir := range dirs {
83+
absDir := dir
84+
if !filepath.IsAbs(dir) {
85+
absDir = filepath.Join(repoRoot, dir)
86+
}
87+
88+
results, err := checkCRDsInDir(repoRoot, absDir, repo, baseHash, crdifyRunner)
89+
if err != nil {
90+
return fmt.Errorf("checking CRDs in %s: %w", dir, err)
91+
}
92+
93+
totalChecked += results.checked
94+
allErrors = append(allErrors, results.errors...)
95+
allWarnings = append(allWarnings, results.warnings...)
96+
}
97+
98+
for _, w := range allWarnings {
99+
fmt.Fprintf(os.Stderr, "Warning: %s\n", w)
100+
}
101+
102+
if len(allErrors) > 0 {
103+
fmt.Fprintf(os.Stderr, "\nFAIL: CRD schema check failed with %d error(s) across %d CRD(s):\n\n", len(allErrors), totalChecked)
104+
for _, e := range allErrors {
105+
fmt.Fprintf(os.Stderr, " ERROR: %s\n", e)
106+
}
107+
fmt.Fprintf(os.Stderr, "\nIf this is an intentional API change, see the crdify documentation for exception mechanisms.\n")
108+
fmt.Fprintf(os.Stderr, "For pre-existing violations from moved/renamed files, use Prow: /override verify-crd-schema\n")
109+
return fmt.Errorf("breaking CRD schema changes detected")
110+
}
111+
112+
fmt.Fprintf(os.Stderr, "PASS: CRD schema check passed (%d CRDs checked, %d warnings)\n", totalChecked, len(allWarnings))
113+
return nil
114+
}
115+
116+
// checkResults holds the results from checking a set of CRDs.
117+
type checkResults struct {
118+
checked int
119+
errors []string
120+
warnings []string
121+
}
122+
123+
// checkCRDsInDir walks a directory recursively and checks all CRD YAML files
124+
// for breaking changes against the base commit. Non-CRD YAML files encountered
125+
// during the walk (e.g., envtest TestSuite fixtures) are silently skipped via
126+
// isCRDYAML content-based filtering. Featuregate CRD variants (-Default and
127+
// -TechPreviewNoUpgrade) are also skipped; only the CustomNoUpgrade superset
128+
// variant is checked to avoid false positives when fields move between gates.
129+
func checkCRDsInDir(repoRoot, dir string, repo *gogit.Repository, baseHash *plumbing.Hash, crdifyRunner *runner.Runner) (checkResults, error) {
130+
var results checkResults
131+
132+
entries, err := os.ReadDir(dir)
133+
if err != nil {
134+
return results, fmt.Errorf("reading directory %s: %w", dir, err)
135+
}
136+
137+
for _, entry := range entries {
138+
path := filepath.Join(dir, entry.Name())
139+
140+
if entry.IsDir() {
141+
subResults, err := checkCRDsInDir(repoRoot, path, repo, baseHash, crdifyRunner)
142+
if err != nil {
143+
return results, err
144+
}
145+
results.checked += subResults.checked
146+
results.errors = append(results.errors, subResults.errors...)
147+
results.warnings = append(results.warnings, subResults.warnings...)
148+
continue
149+
}
150+
151+
ext := filepath.Ext(entry.Name())
152+
if ext != ".yaml" && ext != ".yml" {
153+
continue
154+
}
155+
156+
if skipFeatureGateVariant(entry.Name()) {
157+
continue
158+
}
159+
160+
data, err := os.ReadFile(path)
161+
if err != nil {
162+
return results, fmt.Errorf("reading file %s: %w", path, err)
163+
}
164+
165+
if !isCRDYAML(data) {
166+
continue
167+
}
168+
169+
newCRD, err := readCRD(data)
170+
if err != nil {
171+
return results, fmt.Errorf("parsing CRD from %s: %w", path, err)
172+
}
173+
174+
newCRD = filterVersionsWithSchema(newCRD)
175+
if len(newCRD.Spec.Versions) == 0 {
176+
continue
177+
}
178+
179+
relPath, err := filepath.Rel(repoRoot, path)
180+
if err != nil {
181+
return results, fmt.Errorf("computing relative path for %s: %w", path, err)
182+
}
183+
184+
oldCRD, err := loadCRDFromCommit(repo, baseHash, relPath)
185+
if err != nil {
186+
return results, fmt.Errorf("loading CRD from base commit for %s: %w", relPath, err)
187+
}
188+
if oldCRD == nil {
189+
fmt.Fprintf(os.Stderr, "info: %s is new (not found in base), skipping comparison\n", relPath)
190+
continue
191+
}
192+
if len(oldCRD.Spec.Versions) == 0 {
193+
fmt.Fprintf(os.Stderr, "info: %s exists in base but has no versions with schemas, skipping comparison\n", relPath)
194+
continue
195+
}
196+
197+
compResults := compareCRDs(oldCRD, newCRD, crdifyRunner)
198+
results.checked++
199+
200+
for _, cr := range compResults.CRDValidation {
201+
for _, e := range cr.Errors {
202+
results.errors = append(results.errors, fmt.Sprintf("%s: %s: %s", relPath, cr.Name, e))
203+
}
204+
for _, w := range cr.Warnings {
205+
results.warnings = append(results.warnings, fmt.Sprintf("%s: %s: %s", relPath, cr.Name, w))
206+
}
207+
}
208+
209+
for _, vr := range compResults.SameVersionValidation {
210+
collectPropertyResults(&results, relPath, fmt.Sprintf("version %s", vr.Version), vr.PropertyComparisons)
211+
}
212+
213+
for _, vr := range compResults.ServedVersionValidation {
214+
collectPropertyResults(&results, relPath, fmt.Sprintf("served version %s", vr.Version), vr.PropertyComparisons)
215+
}
216+
}
217+
218+
return results, nil
219+
}
220+
221+
// collectPropertyResults extracts errors and warnings from property comparison
222+
// results into the check results, using the provided version label for formatting.
223+
func collectPropertyResults(results *checkResults, relPath, versionLabel string, comparisons []validations.PropertyComparisonResult) {
224+
for _, pr := range comparisons {
225+
for _, cr := range pr.ComparisonResults {
226+
for _, e := range cr.Errors {
227+
results.errors = append(results.errors, fmt.Sprintf("%s: %s: %s: %s: %s", relPath, versionLabel, pr.Property, cr.Name, e))
228+
}
229+
for _, w := range cr.Warnings {
230+
results.warnings = append(results.warnings, fmt.Sprintf("%s: %s: %s: %s: %s", relPath, versionLabel, pr.Property, cr.Name, w))
231+
}
232+
}
233+
}
234+
}
235+
236+
// resolveBaseHash resolves a git reference to a commit hash.
237+
func resolveBaseHash(repo *gogit.Repository, ref string) (*plumbing.Hash, error) {
238+
hash, err := repo.ResolveRevision(plumbing.Revision(ref))
239+
if err != nil {
240+
return nil, fmt.Errorf("resolving revision %q: %w", ref, err)
241+
}
242+
return hash, nil
243+
}
244+
245+
// loadCRDFromCommit reads a CRD YAML file from a specific git commit using
246+
// crdify's git loader.
247+
// Returns (nil, nil) if the file doesn't exist in the commit (new file).
248+
//
249+
// Known limitation: lookup is path-based — if a CRD file is moved or renamed
250+
// between the base commit and HEAD (e.g., during a directory restructure), the
251+
// new-path file will appear as "new" and skip comparison entirely. This matches
252+
// the approach used in openshift/api and is acceptable in practice; use
253+
// `/override verify-crd-schema` for legitimate moves.
254+
func loadCRDFromCommit(repo *gogit.Repository, hash *plumbing.Hash, path string) (*apiextensionsv1.CustomResourceDefinition, error) {
255+
if hash == nil {
256+
return nil, nil
257+
}
258+
259+
crd, err := gitloader.LoadCRDFileFromRepositoryWithRef(repo, hash, path)
260+
if err != nil {
261+
if errors.Is(err, object.ErrFileNotFound) ||
262+
errors.Is(err, object.ErrDirectoryNotFound) ||
263+
errors.Is(err, object.ErrEntryNotFound) {
264+
return nil, nil
265+
}
266+
return nil, fmt.Errorf("reading file %s from base commit: %w", path, err)
267+
}
268+
269+
crd = filterVersionsWithSchema(crd)
270+
return crd, nil
271+
}
272+
273+
// readCRD unmarshals YAML data into a CustomResourceDefinition.
274+
func readCRD(data []byte) (*apiextensionsv1.CustomResourceDefinition, error) {
275+
crd := &apiextensionsv1.CustomResourceDefinition{}
276+
if err := kyaml.Unmarshal(data, crd); err != nil {
277+
return nil, err
278+
}
279+
return crd, nil
280+
}
281+
282+
// isCRDYAML checks whether the YAML data represents a CustomResourceDefinition.
283+
func isCRDYAML(data []byte) bool {
284+
obj := &metav1.PartialObjectMetadata{}
285+
if err := kyaml.Unmarshal(data, obj); err != nil {
286+
return false
287+
}
288+
return obj.APIVersion == apiextensionsv1.SchemeGroupVersion.String() && obj.Kind == "CustomResourceDefinition"
289+
}
290+
291+
// filterVersionsWithSchema returns a copy of the CRD with only versions that have OpenAPI schemas.
292+
func filterVersionsWithSchema(crd *apiextensionsv1.CustomResourceDefinition) *apiextensionsv1.CustomResourceDefinition {
293+
out := crd.DeepCopy()
294+
filtered := make([]apiextensionsv1.CustomResourceDefinitionVersion, 0, len(out.Spec.Versions))
295+
for _, v := range out.Spec.Versions {
296+
if v.Schema != nil && v.Schema.OpenAPIV3Schema != nil {
297+
filtered = append(filtered, v)
298+
}
299+
}
300+
out.Spec.Versions = filtered
301+
return out
302+
}
303+
304+
// skipFeatureGateVariant returns true for featuregate CRD variants that should
305+
// be skipped. Only the CustomNoUpgrade variant (the superset schema containing
306+
// all fields) is checked. Default and TechPreviewNoUpgrade variants would
307+
// produce false positives when fields move between featuregate levels.
308+
func skipFeatureGateVariant(filename string) bool {
309+
return strings.HasSuffix(filename, "-Default.crd.yaml") ||
310+
strings.HasSuffix(filename, "-TechPreviewNoUpgrade.crd.yaml")
311+
}
312+
313+
// findRepoRoot walks up from cwd to find the .git directory.
314+
func findRepoRoot() (string, error) {
315+
cwd, err := os.Getwd()
316+
if err != nil {
317+
return "", fmt.Errorf("getting working directory: %w", err)
318+
}
319+
320+
dir := cwd
321+
for {
322+
if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
323+
return dir, nil
324+
}
325+
parent := filepath.Dir(dir)
326+
if parent == dir {
327+
break
328+
}
329+
dir = parent
330+
}
331+
return "", fmt.Errorf("no .git directory found above %s", cwd)
332+
}
333+
334+
// compareCRDs is the testable core of the comparison logic.
335+
// Returns an empty result when oldCRD is nil (new CRD, nothing to compare against).
336+
func compareCRDs(oldCRD, newCRD *apiextensionsv1.CustomResourceDefinition, crdifyRunner *runner.Runner) *runner.Results {
337+
if oldCRD == nil {
338+
return &runner.Results{}
339+
}
340+
return crdifyRunner.Run(oldCRD, newCRD)
341+
}

0 commit comments

Comments
 (0)