Files
kustomize/kyaml/openapi/cmd/openapi-bundle/main.go
koba1t 9216c6f573 chore: refactor read scheme to openapi from kubernetes api definition
Previously, only a single embedded Kubernetes API version could be specified, resulting in a lack of support for certain GVKs.
To address this, the goal is to create and utilize a unified scheme that consolidates Kubernetes API definitions.
As a preliminary step, the current method of loading API definitions will be improved.
2026-08-02 22:39:33 +09:00

388 lines
11 KiB
Go

// Copyright 2026 The Kubernetes Authors.
// SPDX-License-Identifier: Apache-2.0
// openapi-bundle compiles a Kubernetes OpenAPI v2 protobuf document into the
// compact, deterministic bundle embedded by kyaml.
package main
import (
"bufio"
"compress/gzip"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
openapi_v2 "github.com/google/gnostic-models/openapiv2"
"google.golang.org/protobuf/proto"
"k8s.io/kube-openapi/pkg/validation/spec"
"sigs.k8s.io/kustomize/kyaml/openapi/internal/builtinopenapi"
)
const gvkExtension = "x-kubernetes-group-version-kind"
const maxInputSize = 64 << 20
type options struct {
input string
output string
legacyProtoOutput string
kubernetesVersion string
}
func main() {
var opts options
flag.StringVar(&opts.input, "input", "", "path to a Kubernetes OpenAPI v2 protobuf document (optionally gzip-compressed)")
flag.StringVar(&opts.output, "output", "", "path to the generated .json.gz bundle")
flag.StringVar(&opts.legacyProtoOutput, "legacy-proto-output", "", "optional path to a deterministic gzip archive of the input protobuf")
flag.StringVar(&opts.kubernetesVersion, "kubernetes-version", "", "Kubernetes version represented by the input")
flag.Parse()
if err := run(opts); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func run(opts options) error {
if opts.input == "" || opts.output == "" || opts.kubernetesVersion == "" {
return errors.New("-input, -output, and -kubernetes-version are required")
}
input, err := readInput(opts.input)
if err != nil {
return fmt.Errorf("read input: %w", err)
}
bundle, err := compile(input, opts.kubernetesVersion)
if err != nil {
return fmt.Errorf("compile bundle: %w", err)
}
if err := writeBundle(opts.output, bundle); err != nil {
return fmt.Errorf("write bundle: %w", err)
}
if opts.legacyProtoOutput != "" {
if err := writeGzip(opts.legacyProtoOutput, input); err != nil {
return fmt.Errorf("write legacy protobuf archive: %w", err)
}
}
return nil
}
func readInput(path string) ([]byte, error) {
return readInputWithLimit(path, maxInputSize)
}
func readInputWithLimit(path string, limit int64) (result []byte, resultErr error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %q: %w", path, err)
}
defer func() {
if err := file.Close(); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("close input %q: %w", path, err))
}
}()
reader := bufio.NewReader(file)
magic, err := reader.Peek(2)
if err != nil && !errors.Is(err, io.EOF) {
return nil, fmt.Errorf("inspect input %q: %w", path, err)
}
if len(magic) < 2 || magic[0] != 0x1f || magic[1] != 0x8b {
return readLimitedInput(reader, limit, "input", path)
}
gzipReader, err := gzip.NewReader(reader)
if err != nil {
return nil, fmt.Errorf("open gzip input %q: %w", path, err)
}
uncompressed, readErr := readLimitedInput(gzipReader, limit, "decompressed input", path)
closeErr := gzipReader.Close()
if readErr != nil {
return nil, readErr
}
if closeErr != nil {
return nil, fmt.Errorf("close gzip input %q: %w", path, closeErr)
}
return uncompressed, nil
}
func readLimitedInput(reader io.Reader, limit int64, description, path string) ([]byte, error) {
contents, err := io.ReadAll(io.LimitReader(reader, limit+1))
if err != nil {
return nil, fmt.Errorf("read %s %q: %w", description, path, err)
}
if int64(len(contents)) > limit {
return nil, fmt.Errorf("%s %q exceeds %d bytes", description, path, limit)
}
return contents, nil
}
func compile(input []byte, kubernetesVersion string) (*builtinopenapi.Bundle, error) {
document := &openapi_v2.Document{}
if err := proto.Unmarshal(input, document); err != nil {
return nil, fmt.Errorf("unmarshal OpenAPI protobuf: %w", err)
}
var swagger spec.Swagger
ok, err := swagger.FromGnostic(document)
if err != nil {
return nil, fmt.Errorf("convert gnostic document: %w", err)
}
if !ok {
return nil, errors.New("gnostic document cannot be converted without data loss")
}
resources, err := collectResources(&swagger)
if err != nil {
return nil, err
}
if err := validateDefinitionReferences(swagger.Definitions); err != nil {
return nil, err
}
digest := sha256.Sum256(input)
bundle := &builtinopenapi.Bundle{
FormatVersion: builtinopenapi.FormatVersion,
Coverage: builtinopenapi.Coverage{
Floor: kubernetesVersion,
Ceiling: kubernetesVersion,
},
SelectionPolicy: builtinopenapi.SelectionPolicy,
Sources: []builtinopenapi.Source{{
KubernetesVersion: kubernetesVersion,
SHA256: hex.EncodeToString(digest[:]),
}},
Definitions: swagger.Definitions,
Resources: resources,
}
if err := bundle.Validate(); err != nil {
return nil, fmt.Errorf("validate compiled bundle: %w", err)
}
return bundle, nil
}
func collectResources(swagger *spec.Swagger) ([]builtinopenapi.Resource, error) {
resources := map[string]builtinopenapi.Resource{}
for definitionName, definition := range swagger.Definitions {
extension, found := definition.Extensions[gvkExtension]
if !found {
continue
}
entries, ok := extension.([]interface{})
if !ok {
return nil, fmt.Errorf("definition %q has a malformed %s extension", definitionName, gvkExtension)
}
for _, entry := range entries {
apiVersion, kind, err := parseGVK(entry)
if err != nil {
return nil, fmt.Errorf("definition %q: %w", definitionName, err)
}
key := resourceKey(apiVersion, kind)
resource := resources[key]
if resource.Definition != "" && resource.Definition != definitionName {
return nil, fmt.Errorf("GVK %s/%s is advertised by definitions %q and %q",
apiVersion, kind, resource.Definition, definitionName)
}
resource.APIVersion = apiVersion
resource.Kind = kind
resource.Definition = definitionName
resources[key] = resource
}
}
if err := collectPathResources(swagger.Paths, resources); err != nil {
return nil, err
}
result := make([]builtinopenapi.Resource, 0, len(resources))
for _, resource := range resources {
result = append(result, resource)
}
builtinopenapi.SortResources(result)
return result, nil
}
func collectPathResources(paths *spec.Paths, resources map[string]builtinopenapi.Resource) error {
if paths == nil {
return nil
}
for path, pathInfo := range paths.Paths {
if pathInfo.Get == nil {
continue
}
extension, found := pathInfo.Get.Extensions[gvkExtension]
if !found {
continue
}
apiVersion, kind, err := parseGVK(extension)
if err != nil {
return fmt.Errorf("path %q: %w", path, err)
}
key := resourceKey(apiVersion, kind)
resource := resources[key]
resource.APIVersion = apiVersion
resource.Kind = kind
if strings.Contains(path, "namespaces/{namespace}") {
resource.Scope = builtinopenapi.ScopeNamespaced
} else if resource.Scope == builtinopenapi.ScopeUnknown {
resource.Scope = builtinopenapi.ScopeCluster
}
resources[key] = resource
}
return nil
}
func parseGVK(value interface{}) (string, string, error) {
entry, ok := value.(map[string]interface{})
if !ok {
return "", "", fmt.Errorf("malformed %s extension entry", gvkExtension)
}
version, versionOK := entry["version"].(string)
kind, kindOK := entry["kind"].(string)
if !versionOK || version == "" || !kindOK || kind == "" {
return "", "", fmt.Errorf("incomplete %s extension entry", gvkExtension)
}
group, groupOK := entry["group"].(string)
if groupOK && group != "" {
return group + "/" + version, kind, nil
}
return version, kind, nil
}
func resourceKey(apiVersion, kind string) string {
return apiVersion + "\x00" + kind
}
func validateDefinitionReferences(definitions spec.Definitions) error {
b, err := json.Marshal(definitions)
if err != nil {
return fmt.Errorf("marshal definitions for reference validation: %w", err)
}
var value interface{}
if err := json.Unmarshal(b, &value); err != nil {
return fmt.Errorf("unmarshal definitions for reference validation: %w", err)
}
return walkReferences(value, definitions)
}
func walkReferences(value interface{}, definitions spec.Definitions) error {
switch typed := value.(type) {
case []interface{}:
for _, item := range typed {
if err := walkReferences(item, definitions); err != nil {
return err
}
}
case map[string]interface{}:
for key, item := range typed {
if key == "$ref" {
if err := validateReference(item, definitions); err != nil {
return err
}
}
if err := walkReferences(item, definitions); err != nil {
return err
}
}
}
return nil
}
func validateReference(value interface{}, definitions spec.Definitions) error {
ref, ok := value.(string)
// A schema may itself describe an object with a property named "$ref".
// Such a property has a schema object as its value and is not an OpenAPI
// reference keyword.
if !ok {
return nil
}
const prefix = "#/definitions/"
if !strings.HasPrefix(ref, prefix) {
return fmt.Errorf("OpenAPI definition contains unsupported reference %q", ref)
}
name := strings.TrimPrefix(ref, prefix)
name = strings.ReplaceAll(strings.ReplaceAll(name, "~1", "/"), "~0", "~")
if _, found := definitions[name]; !found {
return fmt.Errorf("OpenAPI definition references missing definition %q", name)
}
return nil
}
func writeBundle(path string, bundle *builtinopenapi.Bundle) error {
jsonBytes, err := json.Marshal(bundle)
if err != nil {
return fmt.Errorf("marshal bundle: %w", err)
}
return writeGzip(path, jsonBytes)
}
func writeGzip(path string, contents []byte) (resultErr error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("create output directory %q: %w", dir, err)
}
tmp, err := os.CreateTemp(dir, ".openapi-bundle-*")
if err != nil {
return fmt.Errorf("create temporary output: %w", err)
}
tmpName := tmp.Name()
tmpClosed := false
defer func() {
if !tmpClosed {
if err := tmp.Close(); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("close temporary output: %w", err))
}
}
if err := os.Remove(tmpName); err != nil && !errors.Is(err, os.ErrNotExist) {
resultErr = errors.Join(resultErr, fmt.Errorf("remove temporary output: %w", err))
}
}()
writer, err := gzip.NewWriterLevel(tmp, gzip.BestCompression)
if err != nil {
return fmt.Errorf("create gzip writer: %w", err)
}
writerClosed := false
defer func() {
if !writerClosed {
if err := writer.Close(); err != nil {
resultErr = errors.Join(resultErr, fmt.Errorf("close gzip writer: %w", err))
}
}
}()
writer.Header.ModTime = time.Time{}
writer.Header.Name = ""
writer.Header.Comment = ""
writer.Header.Extra = nil
writer.Header.OS = 255
if _, err := writer.Write(contents); err != nil {
return fmt.Errorf("write compressed output: %w", err)
}
closeWriterErr := writer.Close()
writerClosed = true
if closeWriterErr != nil {
return fmt.Errorf("close gzip writer: %w", closeWriterErr)
}
if err := tmp.Chmod(0o644); err != nil {
return fmt.Errorf("set output permissions: %w", err)
}
closeTempErr := tmp.Close()
tmpClosed = true
if closeTempErr != nil {
return fmt.Errorf("close temporary output: %w", closeTempErr)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("replace output %q: %w", path, err)
}
return nil
}