mirror of
https://github.com/yannh/kubeconform.git
synced 2026-06-28 16:00:44 +00:00
* Go implementation of openapi2jsonschema * Add go version of openapi2jsonschema to container
405 lines
9.5 KiB
Go
405 lines
9.5 KiB
Go
// Derived from https://github.com/instrumenta/openapi2jsonschema
|
|
// Go port of openapi2jsonschema.py.
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// OrderedMap preserves key insertion order, matching Python dict semantics
|
|
// (insertion-ordered) used by PyYAML SafeLoader and json.dumps.
|
|
type OrderedMap struct {
|
|
keys []string
|
|
values map[string]any
|
|
}
|
|
|
|
func NewOrderedMap() *OrderedMap {
|
|
return &OrderedMap{values: map[string]any{}}
|
|
}
|
|
|
|
func (m *OrderedMap) Has(k string) bool {
|
|
_, ok := m.values[k]
|
|
return ok
|
|
}
|
|
|
|
func (m *OrderedMap) Get(k string) (any, bool) {
|
|
v, ok := m.values[k]
|
|
return v, ok
|
|
}
|
|
|
|
func (m *OrderedMap) Set(k string, v any) {
|
|
if _, ok := m.values[k]; !ok {
|
|
m.keys = append(m.keys, k)
|
|
}
|
|
m.values[k] = v
|
|
}
|
|
|
|
func (m *OrderedMap) Keys() []string { return m.keys }
|
|
|
|
func (m *OrderedMap) Len() int { return len(m.keys) }
|
|
|
|
// MarshalJSON emits keys in insertion order with no HTML escaping, matching
|
|
// Python's json.dumps default.
|
|
func (m *OrderedMap) MarshalJSON() ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
buf.WriteByte('{')
|
|
for i, k := range m.keys {
|
|
if i > 0 {
|
|
buf.WriteByte(',')
|
|
}
|
|
kb, err := encodeJSON(k)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
buf.Write(kb)
|
|
buf.WriteByte(':')
|
|
vb, err := encodeJSON(m.values[k])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
buf.Write(vb)
|
|
}
|
|
buf.WriteByte('}')
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func encodeJSON(v any) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(v); err != nil {
|
|
return nil, err
|
|
}
|
|
// Encoder always appends a trailing newline; strip it.
|
|
out := buf.Bytes()
|
|
if n := len(out); n > 0 && out[n-1] == '\n' {
|
|
out = out[:n-1]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// yamlToData converts a yaml.Node into Go values made of *OrderedMap, []any,
|
|
// and primitive types, preserving mapping key order.
|
|
func yamlToData(n *yaml.Node) (any, error) {
|
|
switch n.Kind {
|
|
case yaml.DocumentNode:
|
|
if len(n.Content) == 0 {
|
|
return nil, nil
|
|
}
|
|
return yamlToData(n.Content[0])
|
|
case yaml.MappingNode:
|
|
m := NewOrderedMap()
|
|
for i := 0; i < len(n.Content); i += 2 {
|
|
kNode := n.Content[i]
|
|
vNode := n.Content[i+1]
|
|
var key string
|
|
if err := kNode.Decode(&key); err != nil {
|
|
key = kNode.Value
|
|
}
|
|
v, err := yamlToData(vNode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
m.Set(key, v)
|
|
}
|
|
return m, nil
|
|
case yaml.SequenceNode:
|
|
out := make([]any, 0, len(n.Content))
|
|
for _, c := range n.Content {
|
|
v, err := yamlToData(c)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, v)
|
|
}
|
|
return out, nil
|
|
case yaml.ScalarNode:
|
|
// Decode preserves YAML scalar typing (bool, int, float, string, null).
|
|
var v any
|
|
if err := n.Decode(&v); err != nil {
|
|
return nil, err
|
|
}
|
|
return v, nil
|
|
case yaml.AliasNode:
|
|
return yamlToData(n.Alias)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// additionalProperties recreates the kubectl behaviour: any object with a
|
|
// "properties" key gets `additionalProperties: false` set (unless the caller
|
|
// asks to skip it for the root).
|
|
func additionalProperties(data any, skip bool) any {
|
|
m, ok := data.(*OrderedMap)
|
|
if !ok {
|
|
if list, ok := data.([]any); ok {
|
|
for i := range list {
|
|
list[i] = additionalProperties(list[i], false)
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
if m.Has("properties") && !skip {
|
|
if !m.Has("additionalProperties") {
|
|
m.Set("additionalProperties", false)
|
|
}
|
|
}
|
|
for _, k := range m.Keys() {
|
|
v, _ := m.Get(k)
|
|
m.Set(k, additionalProperties(v, false))
|
|
}
|
|
return m
|
|
}
|
|
|
|
// replaceIntOrString replaces `format: int-or-string` with the canonical
|
|
// oneOf{string,integer} expansion.
|
|
func replaceIntOrString(data any) any {
|
|
m, ok := data.(*OrderedMap)
|
|
if !ok {
|
|
if list, ok := data.([]any); ok {
|
|
out := make([]any, len(list))
|
|
for i, x := range list {
|
|
out[i] = replaceIntOrString(x)
|
|
}
|
|
return out
|
|
}
|
|
return data
|
|
}
|
|
out := NewOrderedMap()
|
|
for _, k := range m.Keys() {
|
|
v, _ := m.Get(k)
|
|
switch vv := v.(type) {
|
|
case *OrderedMap:
|
|
if f, ok := vv.Get("format"); ok {
|
|
if s, ok := f.(string); ok && s == "int-or-string" {
|
|
replacement := NewOrderedMap()
|
|
strType := NewOrderedMap()
|
|
strType.Set("type", "string")
|
|
intType := NewOrderedMap()
|
|
intType.Set("type", "integer")
|
|
replacement.Set("oneOf", []any{strType, intType})
|
|
out.Set(k, replacement)
|
|
continue
|
|
}
|
|
}
|
|
out.Set(k, replaceIntOrString(vv))
|
|
case []any:
|
|
rep := make([]any, len(vv))
|
|
for i, x := range vv {
|
|
rep[i] = replaceIntOrString(x)
|
|
}
|
|
out.Set(k, rep)
|
|
default:
|
|
out.Set(k, v)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func writeSchemaFile(schema any, filename string, denyRootAdditionalProperties bool) error {
|
|
schema = additionalProperties(schema, !denyRootAdditionalProperties)
|
|
schema = replaceIntOrString(schema)
|
|
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
enc.SetIndent("", " ")
|
|
if err := enc.Encode(schema); err != nil {
|
|
return err
|
|
}
|
|
// Encoder appends "\n"; Python's `print(s, file=f)` also appends one.
|
|
// json.dumps itself does not, so the encoder's newline matches print().
|
|
|
|
// Treat the input as a path component only — guard against directory
|
|
// traversal in user-supplied filenames.
|
|
filename = filepath.Base(filename)
|
|
if err := os.WriteFile(filename, buf.Bytes(), 0o644); err != nil {
|
|
return err
|
|
}
|
|
fmt.Printf("JSON schema written to %s\n", filename)
|
|
return nil
|
|
}
|
|
|
|
func formatFilename(format, kind, group, fullgroup, version string) string {
|
|
r := strings.NewReplacer(
|
|
"{kind}", kind,
|
|
"{group}", group,
|
|
"{fullgroup}", fullgroup,
|
|
"{version}", version,
|
|
)
|
|
return strings.ToLower(r.Replace(format)) + ".json"
|
|
}
|
|
|
|
func openInput(path string) (io.ReadCloser, error) {
|
|
if strings.HasPrefix(path, "http") {
|
|
client := http.DefaultClient
|
|
if os.Getenv("DISABLE_SSL_CERT_VALIDATION") != "" {
|
|
client = &http.Client{Transport: &http.Transport{
|
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
|
|
}}
|
|
}
|
|
resp, err := client.Get(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if resp.StatusCode >= 400 {
|
|
resp.Body.Close()
|
|
return nil, fmt.Errorf("HTTP %d fetching %s", resp.StatusCode, path)
|
|
}
|
|
return resp.Body, nil
|
|
}
|
|
return os.Open(path)
|
|
}
|
|
|
|
// process consumes one CRD source (file or URL) and writes one JSON schema per
|
|
// version found, mirroring the original Python control flow.
|
|
func process(path string, filenameFormat string, denyRoot bool) error {
|
|
r, err := openInput(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer r.Close()
|
|
|
|
dec := yaml.NewDecoder(r)
|
|
var defs []*OrderedMap
|
|
for {
|
|
var node yaml.Node
|
|
if err := dec.Decode(&node); err != nil {
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
return err
|
|
}
|
|
v, err := yamlToData(&node)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m, ok := v.(*OrderedMap)
|
|
if !ok || m == nil {
|
|
continue
|
|
}
|
|
if items, ok := m.Get("items"); ok {
|
|
if list, ok := items.([]any); ok {
|
|
for _, it := range list {
|
|
if im, ok := it.(*OrderedMap); ok {
|
|
defs = append(defs, im)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
kind, ok := m.Get("kind")
|
|
if !ok {
|
|
continue
|
|
}
|
|
if ks, _ := kind.(string); ks != "CustomResourceDefinition" {
|
|
continue
|
|
}
|
|
defs = append(defs, m)
|
|
}
|
|
|
|
for _, y := range defs {
|
|
spec, ok := getMap(y, "spec")
|
|
if !ok {
|
|
continue
|
|
}
|
|
names, _ := getMap(spec, "names")
|
|
kind, _ := getString(names, "kind")
|
|
fullgroup, _ := getString(spec, "group")
|
|
group := strings.SplitN(fullgroup, ".", 2)[0]
|
|
|
|
versions, hasVersions := spec.Get("versions")
|
|
versionList, _ := versions.([]any)
|
|
|
|
if hasVersions && len(versionList) > 0 {
|
|
for _, vAny := range versionList {
|
|
vMap, ok := vAny.(*OrderedMap)
|
|
if !ok {
|
|
continue
|
|
}
|
|
vName, _ := getString(vMap, "name")
|
|
schemaMap, hasSchema := getMap(vMap, "schema")
|
|
if hasSchema {
|
|
if root, ok := schemaMap.Get("openAPIV3Schema"); ok {
|
|
filename := formatFilename(filenameFormat, kind, group, fullgroup, vName)
|
|
if err := writeSchemaFile(root, filename, denyRoot); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
}
|
|
validation, hasValidation := getMap(spec, "validation")
|
|
if hasValidation {
|
|
if root, ok := validation.Get("openAPIV3Schema"); ok {
|
|
filename := formatFilename(filenameFormat, kind, group, fullgroup, vName)
|
|
if err := writeSchemaFile(root, filename, denyRoot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else if validation, hasValidation := getMap(spec, "validation"); hasValidation {
|
|
if root, ok := validation.Get("openAPIV3Schema"); ok {
|
|
vName, _ := getString(spec, "version")
|
|
filename := formatFilename(filenameFormat, kind, group, fullgroup, vName)
|
|
if err := writeSchemaFile(root, filename, denyRoot); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getMap(m *OrderedMap, key string) (*OrderedMap, bool) {
|
|
if m == nil {
|
|
return nil, false
|
|
}
|
|
v, ok := m.Get(key)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
mm, ok := v.(*OrderedMap)
|
|
return mm, ok
|
|
}
|
|
|
|
func getString(m *OrderedMap, key string) (string, bool) {
|
|
if m == nil {
|
|
return "", false
|
|
}
|
|
v, ok := m.Get(key)
|
|
if !ok {
|
|
return "", false
|
|
}
|
|
s, ok := v.(string)
|
|
return s, ok
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
fmt.Printf("Missing FILE parameter.\nUsage: %s [FILE]\n", os.Args[0])
|
|
os.Exit(1)
|
|
}
|
|
filenameFormat := os.Getenv("FILENAME_FORMAT")
|
|
if filenameFormat == "" {
|
|
filenameFormat = "{kind}_{version}"
|
|
}
|
|
denyRoot := os.Getenv("DENY_ROOT_ADDITIONAL_PROPERTIES") != ""
|
|
|
|
for _, arg := range os.Args[1:] {
|
|
if err := process(arg, filenameFormat, denyRoot); err != nil {
|
|
fmt.Fprintln(os.Stderr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
os.Exit(0)
|
|
}
|