Skip to content

Commit

Permalink
Add the "self" command to crddiff for checking breaking schema change…
Browse files Browse the repository at this point in the history
…s in the declared versions of a single CRD

Signed-off-by: Alper Rifat Ulucinar <[email protected]>
  • Loading branch information
ulucinar committed Nov 29, 2022
1 parent fd626f0 commit 3a04465
Show file tree
Hide file tree
Showing 4 changed files with 602 additions and 88 deletions.
39 changes: 29 additions & 10 deletions cmd/crddiff/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,20 +26,39 @@ import (

func main() {
var (
app = kingpin.New("crddiff", "A tool for checking breaking API changes between two CRD OpenAPI v3 schemas").DefaultEnvars()
baseCRDPath = app.Arg("base", "The manifest file path of the CRD to be used as the base").Required().ExistingFile()
revisionCRDPath = app.Arg("revision", "The manifest file path of the CRD to be used as a revision to the base").Required().ExistingFile()
app = kingpin.New("crddiff", "A tool for checking breaking API changes between two CRD OpenAPI v3 schemas. The schemas can come from either two revisions of a CRD, or from the versions declared in a single CRD.").DefaultEnvars()

cmdSelf = app.Command("self", "Use OpenAPI v3 schemas from a single CRD")
crdPath = cmdSelf.Arg("crd", "The manifest file path of the CRD whose versions are to be checked for breaking changes").Required().ExistingFile()

cmdRevision = app.Command("revision", "Compare the first schema available in a base CRD against the first schema from a revision CRD")
baseCRDPath = cmdRevision.Arg("base", "The manifest file path of the CRD to be used as the base").Required().ExistingFile()
revisionCRDPath = cmdRevision.Arg("revision", "The manifest file path of the CRD to be used as a revision to the base").Required().ExistingFile()
)
kingpin.MustParse(app.Parse(os.Args[1:]))

crdDiff, err := crdschema.NewDiff(*baseCRDPath, *revisionCRDPath)
var crdDiff crdschema.SchemaCheck
var err error
if baseCRDPath != nil && revisionCRDPath != nil {
crdDiff, err = crdschema.NewRevisionDiff(*baseCRDPath, *revisionCRDPath)
} else {
crdDiff, err = crdschema.NewSelfDiff(*crdPath)
}
kingpin.FatalIfError(err, "Failed to load CRDs")
d, err := crdDiff.GetBreakingChanges()
versionMap, err := crdDiff.GetBreakingChanges()
kingpin.FatalIfError(err, "Failed to compute CRD breaking API changes")
if d.Empty() {
return
}

l := log.New(os.Stderr, "", 0)
l.Println(crdschema.GetDiffReport(d))
syscall.Exit(1)
breakingDetected := false
for v, d := range versionMap {
if d.Empty() {
continue
}
breakingDetected = true
l.Printf("Version %q:\n", v)
l.Println(crdschema.GetDiffReport(d))
}
if breakingDetected {
syscall.Exit(1)
}
}
156 changes: 111 additions & 45 deletions internal/crdschema/crd.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,27 @@ import (
)

const (
errCRDLoad = "failed to load the CustomResourceDefinition"
errBreakingchangesCompute = "failed to compute breaking changes in base and revision CRD schemas"
errCRDLoad = "failed to load the CustomResourceDefinition"
errBreakingRevisionChangesCompute = "failed to compute breaking changes in base and revision CRD schemas"
errBreakingSelfVersionsCompute = "failed to compute breaking changes in the versions of a CRD"
)

// Diff can compute schema changes between the base CRD found at `basePath`
type SchemaCheck interface {
GetBreakingChanges() (map[string]*diff.Diff, error)
}

// RevisionDiff can compute schema changes between the base CRD found at `basePath`
// and the revision CRD found at `revisionPath`.
type Diff struct {
type RevisionDiff struct {
baseCRD *v1.CustomResourceDefinition
revisionCRD *v1.CustomResourceDefinition
}

// NewDiff returns a new Diff initialized with the base and revision
// CRDs loaded from the specified base and revision CRD paths.
func NewDiff(basePath, revisionPath string) (*Diff, error) {
d := &Diff{}
// NewRevisionDiff returns a new RevisionDiff initialized with
// the base and revision CRDs loaded from the specified
// base and revision CRD paths.
func NewRevisionDiff(basePath, revisionPath string) (*RevisionDiff, error) {
d := &RevisionDiff{}
var err error
d.baseCRD, err = loadCRD(basePath)
if err != nil {
Expand All @@ -55,6 +61,24 @@ func NewDiff(basePath, revisionPath string) (*Diff, error) {
return d, nil
}

// SelfDiff can compute schema changes between the consecutive versions
// declared for a CRD.
type SelfDiff struct {
crd *v1.CustomResourceDefinition
}

// NewSelfDiff returns a new SelfDiff initialized with a CRD loaded
// from the specified path.
func NewSelfDiff(crdPath string) (*SelfDiff, error) {
d := &SelfDiff{}
var err error
d.crd, err = loadCRD(crdPath)
if err != nil {
return nil, errors.Wrap(err, errCRDLoad)
}
return d, nil
}

func loadCRD(m string) (*v1.CustomResourceDefinition, error) {
crd := &v1.CustomResourceDefinition{}
buff, err := os.ReadFile(m)
Expand All @@ -67,63 +91,105 @@ func loadCRD(m string) (*v1.CustomResourceDefinition, error) {
return crd, nil
}

func getOpenAPIv3Document(crd *v1.CustomResourceDefinition) (*openapi3.T, error) {
if len(crd.Spec.Versions) != 1 {
return nil, errors.New("invalid CRD manifest: Only CRDs with exactly one version are supported")
}
if crd.Spec.Versions[0].Schema == nil || crd.Spec.Versions[0].Schema.OpenAPIV3Schema == nil {
return nil, errors.New("invalid CRD manifest: CRD's .Spec.Versions[0].Schema.OpenAPIV3Schema cannot be nil")
}

t := &openapi3.T{
Info: &openapi3.Info{},
Paths: make(openapi3.Paths),
}
c := make(openapi3.Content)
t.Paths["/crd"] = &openapi3.PathItem{
Put: &openapi3.Operation{
RequestBody: &openapi3.RequestBodyRef{
Value: &openapi3.RequestBody{
Content: c,
func getOpenAPIv3Document(crd *v1.CustomResourceDefinition) ([]*openapi3.T, error) {
schemas := make([]*openapi3.T, 0, len(crd.Spec.Versions))
for _, v := range crd.Spec.Versions {
if v.Schema == nil || v.Schema.OpenAPIV3Schema == nil {
return nil, errors.Errorf("invalid CRD manifest: CRD's .Spec.Versions[%q].Schema.OpenAPIV3Schema cannot be nil", v.Name)
}
t := &openapi3.T{
Info: &openapi3.Info{
Version: v.Name,
},
Paths: make(openapi3.Paths),
}
c := make(openapi3.Content)
t.Paths["/crd"] = &openapi3.PathItem{
Put: &openapi3.Operation{
RequestBody: &openapi3.RequestBodyRef{
Value: &openapi3.RequestBody{
Content: c,
},
},
},
},
}
s := &openapi3.Schema{}
c["application/json"] = &openapi3.MediaType{
Schema: &openapi3.SchemaRef{
Value: s,
},
}
s := &openapi3.Schema{}
c["application/json"] = &openapi3.MediaType{
Schema: &openapi3.SchemaRef{
Value: s,
},
}

// convert from CRD validation schema to openAPI v3 schema
buff, err := k8syaml.Marshal(v.Schema.OpenAPIV3Schema)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal CRD validation schema")
}
if err := k8syaml.Unmarshal(buff, s); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal CRD validation schema into openAPI v3 schema")
}
schemas = append(schemas, t)
}
return schemas, nil
}

// convert from CRD validation schema to openAPI v3 schema
buff, err := k8syaml.Marshal(crd.Spec.Versions[0].Schema.OpenAPIV3Schema)
// GetBreakingChanges returns the breaking changes found in the
// consecutive versions of a CRD.
func (d *SelfDiff) GetBreakingChanges() (map[string]*diff.Diff, error) {
selfDocs, err := getOpenAPIv3Document(d.crd)
if err != nil {
return nil, errors.Wrap(err, "failed to marshal CRD validation schema")
return nil, errors.Wrap(err, errBreakingSelfVersionsCompute)
}
diffMap := make(map[string]*diff.Diff)
if len(selfDocs) < 2 {
return diffMap, nil
}
if err := k8syaml.Unmarshal(buff, s); err != nil {
return nil, errors.Wrap(err, "failed to unmarshal CRD validation schema into openAPI v3 schema")
prev := 0
for prev < len(selfDocs)-1 {
revisionDoc := selfDocs[prev+1]
sd, err := schemaDiff(selfDocs[prev], revisionDoc)
if err != nil {
return nil, errors.Wrap(err, errBreakingSelfVersionsCompute)
}
diffMap[revisionDoc.Info.Version] = sd
}
return t, nil
return diffMap, nil
}

// GetBreakingChanges returns a diff representing
// the detected breaking schema changes between the base and revision CRDs.
func (d *Diff) GetBreakingChanges() (*diff.Diff, error) {
baseDoc, err := getOpenAPIv3Document(d.baseCRD)
func (d *RevisionDiff) GetBreakingChanges() (map[string]*diff.Diff, error) {
baseDocs, err := getOpenAPIv3Document(d.baseCRD)
if err != nil {
return nil, errors.Wrap(err, errBreakingchangesCompute)
return nil, errors.Wrap(err, errBreakingRevisionChangesCompute)
}
revisionDoc, err := getOpenAPIv3Document(d.revisionCRD)
revisionDocs, err := getOpenAPIv3Document(d.revisionCRD)
if err != nil {
return nil, errors.Wrap(err, errBreakingchangesCompute)
return nil, errors.Wrap(err, errBreakingRevisionChangesCompute)
}

diffMap := make(map[string]*diff.Diff, len(baseDocs))
for i, baseDoc := range baseDocs {
versionName := baseDoc.Info.Version
if i >= len(revisionDocs) || revisionDocs[i].Info.Version != versionName {
// no corresponding version to compare in the revision
return nil, errors.Errorf("revision has no corresponding version to compare with the base for the version name: %s", versionName)
}
sd, err := schemaDiff(baseDoc, revisionDocs[i])
if err != nil {
return nil, errors.Wrap(err, errBreakingRevisionChangesCompute)
}
diffMap[versionName] = sd
}
return diffMap, nil
}

func schemaDiff(baseDoc, revisionDoc *openapi3.T) (*diff.Diff, error) {
config := diff.NewConfig()
// currently we only need to detect breaking API changes
config.BreakingOnly = true
sd, err := diff.Get(config, baseDoc, revisionDoc)
return sd, errors.Wrap(err, errBreakingchangesCompute)
return sd, errors.Wrap(err, "failed to compute breaking changes between OpenAPI v3 schemas")
}

// GetDiffReport is a utility function to format the specified diff as a string
Expand Down
Loading

0 comments on commit 3a04465

Please sign in to comment.