Skip to content

Commit

Permalink
tooling - Introduce PR Schema breaking change detection (#21205)
Browse files Browse the repository at this point in the history
  • Loading branch information
jackofallops authored Mar 30, 2023
1 parent d1d0fc2 commit 986ef22
Show file tree
Hide file tree
Showing 25 changed files with 1,392 additions and 2 deletions.
29 changes: 29 additions & 0 deletions .github/workflows/breaking-change-detection.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
name: Breaking Schema Changes

permissions:
contents: read
pull-requests: read


on:
pull_request:
types: ['opened', 'synchronize']
paths:
- '.github/workflows/breaking-change-detection.yaml'
- 'vendor/**'
- 'internal/**.go'

concurrency:
group: 'breakingChange-${{ github.head_ref }}'
cancel-in-progress: true

jobs:
detect:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-go@v3
with:
go-version-file: ./.go-version
- run: bash ./scripts/run-breaking-change-detection.sh
1 change: 1 addition & 0 deletions azurermProviderSchema.json

Large diffs are not rendered by default.

96 changes: 96 additions & 0 deletions internal/tools/schema-api/differ/diff.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
package differ

import (
"fmt"

"github.com/hashicorp/terraform-provider-azurerm/internal/tools/schema-api/providerjson"
schema_rules "github.com/hashicorp/terraform-provider-azurerm/internal/tools/schema-api/schema-rules"
)

type Differ struct {
base *providerjson.ProviderWrapper
current *providerjson.ProviderWrapper
}

func (d *Differ) Diff(fileName string, providerName string) []string {
if err := d.loadFromProvider(providerjson.LoadData(), providerName); err != nil {
return []string{err.Error()}
}

if err := d.loadFromFile(fileName); err != nil {
return []string{err.Error()}
}

if d.base.ProviderName != d.current.ProviderName {
return []string{fmt.Sprintf("provider name mismatch, expected %q, got %q", d.base.ProviderName, d.current.ProviderName)}
}

violations := make([]string, 0)

for resource, rs := range d.current.ProviderSchema.ResourcesMap {
_, ok := d.base.ProviderSchema.ResourcesMap[resource]
if !ok {
// New resource, no breaking changes to worry about
continue
}
for propertyName, propertySchema := range rs.Schema {
// Get the same from the base (released) json
baseItem, ok := d.base.ProviderSchema.ResourcesMap[resource].Schema[propertyName]
if !ok {
// New property, could be breaking - Required etc
baseItem = providerjson.SchemaJSON{}
}
if errs := compareNode(baseItem, propertySchema, propertyName); errs != nil {
violations = append(violations, errs...)
}
}
}

for dataSource, ds := range d.current.ProviderSchema.DataSourcesMap {
_, ok := d.base.ProviderSchema.DataSourcesMap[dataSource]
if !ok {
// New resource, no breaking changes to worry about
continue
}
for propertyName, propertySchema := range ds.Schema {
// Get the same from the base (released) json
baseItem, ok := d.base.ProviderSchema.DataSourcesMap[dataSource].Schema[propertyName]
if !ok {
// New property, could be breaking - Required etc
baseItem = providerjson.SchemaJSON{}
}
if errs := compareNode(baseItem, propertySchema, propertyName); errs != nil {
violations = append(violations, errs...)
}
}
}

return violations
}

func compareNode(base providerjson.SchemaJSON, current providerjson.SchemaJSON, nodeName string) (errs []string) {
if nodeIsBlock(base) {
newBaseRaw := base.Elem.(providerjson.ResourceJSON).Schema
newCurrent := current.Elem.(*providerjson.ResourceJSON).Schema
for k, newBase := range newBaseRaw {
errs = append(errs, compareNode(newBase, newCurrent[k], k)...)
}
}

for _, v := range schema_rules.BreakingChangeRules {
if err := v.Check(base, current, nodeName); err != nil {
errs = append(errs, *err)
}
}

return
}

func nodeIsBlock(input providerjson.SchemaJSON) bool {
if input.Type == "TypeList" || input.Type == "TypeSet" {
if _, ok := input.Elem.(providerjson.ResourceJSON); ok {
return true
}
}
return false
}
36 changes: 36 additions & 0 deletions internal/tools/schema-api/differ/load.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package differ

import (
"encoding/json"
"os"

"github.com/hashicorp/terraform-provider-azurerm/internal/tools/schema-api/providerjson"
)

func (d *Differ) loadFromFile(fileName string) error {
f, err := os.Open(fileName)
if err != nil {
return err
}
defer f.Close()
buf := &providerjson.ProviderWrapper{}
// TODO - Custom marshalling to fix the type assertions later? meh, works for now...
if err := json.NewDecoder(f).Decode(buf); err != nil {
return err
}
d.base = buf

return nil
}

func (d *Differ) loadFromProvider(data *providerjson.ProviderJSON, providerName string) error {
if s, err := providerjson.ProviderFromRaw(data); err != nil {
return err
} else {
d.current = &providerjson.ProviderWrapper{
ProviderName: providerName,
ProviderSchema: s,
}
}
return nil
}
106 changes: 106 additions & 0 deletions internal/tools/schema-api/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
package main

import (
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"

"github.com/hashicorp/go-azure-helpers/lang/pointer"
"github.com/hashicorp/terraform-provider-azurerm/internal/tools/schema-api/differ"
"github.com/hashicorp/terraform-provider-azurerm/internal/tools/schema-api/providerjson"
)

func main() {
f := flag.NewFlagSet("providerJson", flag.ExitOnError)

apiPort := f.Int("api-port", 8080, "the port on which to run the Provider JSON server")
dumpSchema := f.Bool("dump", false, "used to simply dump the entire provider schema")
providerName := f.String("provider-name", "azurerm", "set the provider name, defaults to `azurerm`")
exportSchema := f.String("export", "", "export the schema to the given path/filename. Intended for use in the release process")
detectBreakingChanges := f.String("detect", "", "compare current schema to named dump.")
errorOnBreakingChange := f.Bool("error-on-violation", false, "should the detect mode exit with a non-zero error code. Defaults to `false`")

if err := f.Parse(os.Args[1:]); err != nil {
fmt.Printf("error parsing args: %+v", err)
os.Exit(1)
}

data := providerjson.LoadData()

switch {
case pointer.From(dumpSchema):
{
// Call the method to stdout
log.Printf("dumping schema for '%s'", *providerName)
wrappedProvider := &providerjson.ProviderWrapper{
ProviderName: *providerName,
SchemaVersion: "1",
}
if err := providerjson.DumpWithWrapper(wrappedProvider, data); err != nil {
log.Fatalf("error dumping provider: %+v", err)
}

os.Exit(0)
}

case pointer.From(detectBreakingChanges) != "":
{
d := differ.Differ{}
if violations := d.Diff(*detectBreakingChanges, *providerName); violations != nil {
for _, v := range violations {
log.Println(v)
}
if pointer.From(errorOnBreakingChange) {
os.Exit(1)
}
}

os.Exit(0)
}

case pointer.From(exportSchema) != "":
{
log.Printf("dumping schema for '%s'", *providerName)
wrappedProvider := &providerjson.ProviderWrapper{
ProviderName: *providerName,
SchemaVersion: "1",
}
if err := providerjson.WriteWithWrapper(wrappedProvider, data, *exportSchema); err != nil {
log.Fatalf("error writing provider schema for %q to %q: %+v", *providerName, *exportSchema, err)
}

os.Exit(0)
}
}

if *apiPort < 1024 || *apiPort > 65534 {
log.Fatal(fmt.Printf("invalid value for apiport, must be between 1024 and 65534, got %+v", apiPort))
}

sig := make(chan os.Signal, 1)

signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)

go func() {
sig := <-sig
log.Printf("%s signal received, closing provider API server on port %d", sig, apiPort)
os.Exit(0)
}()

mux := http.NewServeMux()
// paths
mux.HandleFunc(providerjson.DataSourcesList, data.ListDataSources)
mux.HandleFunc(providerjson.ResourcesList, data.ListResources)

mux.HandleFunc(providerjson.DataSourcesPath, data.DataSourcesHandler)
mux.HandleFunc(providerjson.ResourcesPath, data.ResourcesHandler)

mux.HandleFunc(providerjson.DumpSchema, data.DumpAllSchema)

log.Printf("starting api service on localhost:%d", *apiPort)
log.Println(http.ListenAndServe(fmt.Sprintf(":%d", *apiPort), mux))
}
73 changes: 73 additions & 0 deletions internal/tools/schema-api/providerjson/handlers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package providerjson

import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
)

const (
DataSourcesList = "/ProviderSchema-data/v1/data-sources" // Lists all data sources in the Provider
ResourcesList = "/ProviderSchema-data/v1/resources" // Lists all Resources in the Provider
DataSourcesPath = "/ProviderSchema-data/v1/data-sources/" // Gets all ProviderSchema data for a data source
ResourcesPath = "/ProviderSchema-data/v1/resources/" // Gets all ProviderSchema data for a Resource
DumpSchema = "/ProviderSchema-data/v1/dump/" // Gets all ProviderSchema
)

func (p *ProviderJSON) DataSourcesHandler(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")

dsRaw := strings.Split(req.URL.RequestURI(), DataSourcesPath)
ds := strings.Split(dsRaw[1], "/")[0]
data, err := resourceFromRaw(p.DataSourcesMap[ds])
if err != nil {
w.WriteHeader(http.StatusNotFound)
log.Println(w.Write([]byte(fmt.Sprintf("[{\"error\": \"Could not process ProviderSchema for %q from provider: %+v\"}]", ds, err))))
} else if err := json.NewEncoder(w).Encode(data); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Println(w.Write([]byte(fmt.Sprintf("Marshall error: %+v", err))))
}
}

func (p *ProviderJSON) ResourcesHandler(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")

dsRaw := strings.Split(req.URL.RequestURI(), ResourcesPath)
ds := strings.Split(dsRaw[1], "/")[0]
data, err := resourceFromRaw(p.ResourcesMap[ds])
if err != nil {
w.WriteHeader(http.StatusNotFound)
log.Println(w.Write([]byte(fmt.Sprintf("[{\"error\": \"Could not process ProviderSchema for %q from provider: %+v\"}]", ds, err))))
} else if err := json.NewEncoder(w).Encode(data); err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Println(w.Write([]byte(fmt.Sprintf("Marshall error: %+v", err))))
}
}

func (p *ProviderJSON) ListResources(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
if err := json.NewEncoder(w).Encode(p.Resources()); err != nil {
log.Println(w.Write([]byte(fmt.Sprintf("Marshall error: %+v", err))))
}
}

func (p *ProviderJSON) ListDataSources(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
if err := json.NewEncoder(w).Encode(p.DataSources()); err != nil {
log.Println(w.Write([]byte(fmt.Sprintf("Marshall error: %+v", err))))
}
}

func (p *ProviderJSON) DumpAllSchema(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=UTF-8")
provider, err := ProviderFromRaw(p)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
log.Println(w.Write([]byte(fmt.Sprintf("[{\"error\": \"Could not process provider: %+v\"}]", err))))
}
if err := json.NewEncoder(w).Encode(provider); err != nil {
log.Println(w.Write([]byte(fmt.Sprintf("Marshall error: %+v", err))))
}
}
16 changes: 16 additions & 0 deletions internal/tools/schema-api/providerjson/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package providerjson

import (
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/terraform"
)

func (p *ProviderJSON) DataSources() []terraform.DataSource {
s := schema.Provider(*p)
return s.DataSources()
}

func (p *ProviderJSON) Resources() []terraform.ResourceType {
s := schema.Provider(*p)
return s.Resources()
}
Loading

0 comments on commit 986ef22

Please sign in to comment.