mirror of
https://github.com/kubernetes-sigs/kustomize.git
synced 2026-09-15 12:18:57 +00:00
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.
388 lines
11 KiB
Go
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
|
|
}
|