mirror of
https://github.com/kubernetes-sigs/kustomize.git
synced 2026-09-15 12:18:57 +00:00
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.
This commit is contained in:
@@ -1,62 +1,13 @@
|
||||
# Copyright 2020 The Kubernetes Authors.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
MYGOBIN = $(shell go env GOBIN)
|
||||
ifeq ($(MYGOBIN),)
|
||||
MYGOBIN = $(shell go env GOPATH)/bin
|
||||
endif
|
||||
KIND_VERSION := "v0.11.1"
|
||||
API_VERSION ?= "v1.21.2"
|
||||
|
||||
.PHONY: all
|
||||
all: \
|
||||
kustomizationapi/swagger.go \
|
||||
kubernetesapi/swagger.go \
|
||||
kubernetesapi/openapiinfo.go
|
||||
all: generate
|
||||
|
||||
.PHONY: clean
|
||||
clean:
|
||||
rm kustomizationapi/swagger.go
|
||||
rm kubernetesapi/openapiinfo.go
|
||||
.PHONY: generate
|
||||
generate:
|
||||
go generate .
|
||||
|
||||
# This will remove all currently built-in schema,
|
||||
# so think twice before deleting.
|
||||
# To replace what this will delete typically requires the ability
|
||||
# to contact a live kubernetes API server.
|
||||
.PHONY: nuke
|
||||
nuke: clean
|
||||
rm -r kubernetesapi/*
|
||||
|
||||
$(MYGOBIN)/go-bindata:
|
||||
go install github.com/go-bindata/go-bindata/v3/go-bindata@latest
|
||||
|
||||
$(MYGOBIN)/kind:
|
||||
( \
|
||||
set -e; \
|
||||
d=$(shell mktemp -d); cd $$d; \
|
||||
wget -O ./kind https://github.com/kubernetes-sigs/kind/releases/download/$(KIND_VERSION)/kind-$(shell uname)-amd64; \
|
||||
chmod +x ./kind; \
|
||||
mv ./kind $(MYGOBIN); \
|
||||
rm -rf $$d; \
|
||||
)
|
||||
|
||||
.PHONY: kubernetesapi/openapiinfo.go
|
||||
kubernetesapi/openapiinfo.go:
|
||||
./scripts/makeOpenApiInfoDotGo.sh
|
||||
|
||||
kustomizationapi/swagger.go: $(MYGOBIN)/go-bindata kustomizationapi/swagger.json
|
||||
$(MYGOBIN)/go-bindata \
|
||||
--pkg kustomizationapi \
|
||||
-o kustomizationapi/swagger.go \
|
||||
kustomizationapi/swagger.json
|
||||
|
||||
.PHONY: kubernetesapi/swagger.pb
|
||||
kubernetesapi/swagger.pb: $(MYGOBIN)/kind $(MYGOBIN)/kustomize
|
||||
./scripts/fetchSchemaFromCluster.sh $(API_VERSION)
|
||||
|
||||
.PHONY: kubernetesapi/swagger.go
|
||||
kubernetesapi/swagger.go: $(MYGOBIN)/go-bindata kubernetesapi/swagger.pb
|
||||
./scripts/generateSwaggerDotGo.sh $(API_VERSION)
|
||||
|
||||
$(MYGOBIN)/kustomize:
|
||||
$(shell cd ../.. && MYGOBIN=$(MYGOBIN) make $(MYGOBIN)/kustomize)
|
||||
.PHONY: verify
|
||||
verify:
|
||||
GOWORK=off go test ./...
|
||||
|
||||
@@ -1,84 +1,60 @@
|
||||
# Sampling New OpenAPI Data
|
||||
# Built-in OpenAPI data
|
||||
|
||||
[OpenAPI schema]: ./kubernetesapi/
|
||||
[Kustomization schema]: ./kustomizationapi/
|
||||
[kind]: https://hub.docker.com/r/kindest/node/tags
|
||||
|
||||
This document describes how to fetch OpenAPI data from a
|
||||
live kubernetes API server.
|
||||
The scripts used will create a clean [kind] instance for this purpose.
|
||||
|
||||
## Replacing the default openapi schema version
|
||||
|
||||
### Delete all currently built-in schema
|
||||
|
||||
This will remove both the Kustomization and Kubernetes schemas:
|
||||
Kustomize embeds a compiled OpenAPI bundle for Kubernetes built-in types. The
|
||||
runtime artifact is:
|
||||
|
||||
```
|
||||
make nuke
|
||||
kubernetesapi/data/kubernetes-openapi-union-v1.21.2.bundle-v1.json.gz
|
||||
```
|
||||
|
||||
### Choose the new version to use
|
||||
The Kubernetes suffix identifies the newest Kubernetes schema represented by
|
||||
the bundle. `bundle-v1` is the independent artifact format version. The
|
||||
initial compiler migration uses a single v1.21.2 source, so both the coverage
|
||||
floor and ceiling are v1.21.2.
|
||||
|
||||
The compiled-in schema version should maximize API availability with respect to all actively supported Kubernetes versions. For example, while 1.20, 1.21 and 1.22 are the actively supported versions, 1.21 is the best choice. This is because 1.21 introduces at least one new API and does not remove any, while 1.22 removes a large set of long-deprecated APIs that are still supported in 1.20/1.21.
|
||||
The bundle contains the complete OpenAPI definitions and a compact index from
|
||||
GVK to root definition and resource scope. API paths and other top-level
|
||||
OpenAPI fields are compiler inputs and are not embedded in the runtime binary.
|
||||
|
||||
### Generating additional schema
|
||||
## Regenerating the bundle
|
||||
|
||||
If you'd like to change the default schema version, then in the Makefile in this directory, update the `API_VERSION` to your desired version.
|
||||
|
||||
You may need to update the version of Kind these scripts use by changing `KIND_VERSION` in the Makefile in this directory. You can find compatibility information in the [kind release notes](https://github.com/kubernetes-sigs/kind/releases).
|
||||
|
||||
In this directory, fetch the openapi schema, generate the
|
||||
corresponding swagger.go for the kubernetes api, and update `kubernetesapi/openapiinfo.go`:
|
||||
The checked-in source is the gzip-compressed v1.21.2 OpenAPI protobuf at:
|
||||
|
||||
```
|
||||
make all
|
||||
kubernetesapi/v1_21_2/swagger.pb.gz
|
||||
```
|
||||
|
||||
If you want to run the steps individually instead of using `make all`, you can run
|
||||
the following commands:
|
||||
Its uncompressed SHA-256 is:
|
||||
|
||||
```
|
||||
make kustomizationapi/swagger.go
|
||||
make kubernetesapi/swagger.go
|
||||
make kubernetesapi/openapiinfo.go
|
||||
5d171b55e9601912807a870d73ffe70bb306f5889a00e76986042a0f2d7b6bc2
|
||||
```
|
||||
|
||||
You can optionally delete the old `swagger.pb` and `swagger.go` files if we no longer need to support that kubernetes version of
|
||||
openapi data. Make sure you rerun `make kubernetesapi/openapiinfo.go` after deleting any old schemas.
|
||||
Source acquisition is deliberately separate from compilation. When updating
|
||||
Kubernetes, obtain the protobuf from an API server running the exact release,
|
||||
review its provenance, and use the compiler's `-legacy-proto-output` flag to
|
||||
write the deterministic checked-in `.pb.gz` archive. Update the embedded path,
|
||||
version constants, and generated bundle together. Every source's uncompressed
|
||||
digest is recorded in the bundle metadata.
|
||||
|
||||
|
||||
#### Precomputations
|
||||
|
||||
To avoid expensive schema lookups, some functions have precomputed results based on the schema. Unit tests
|
||||
ensure these are kept in sync with the schema; if these tests fail you will need to follow the suggested diff
|
||||
to update the precomputed results.
|
||||
|
||||
### Run all tests
|
||||
|
||||
At the top of the repository, run the tests.
|
||||
Regenerate the runtime bundle with:
|
||||
|
||||
```
|
||||
make prow-presubmit-check >& /tmp/k.txt; echo $?
|
||||
make -C kyaml/openapi generate
|
||||
```
|
||||
|
||||
The exit code should be zero; if not, examine `/tmp/k.txt`.
|
||||
|
||||
## Partial regeneration
|
||||
|
||||
You can also regenerate the kubernetes api schemas specifically with:
|
||||
The compiler performs the protobuf-to-OpenAPI conversion, constructs the GVK
|
||||
and scope index, validates local references, writes canonical JSON, and uses a
|
||||
deterministic gzip header. The generated artifact must be byte-for-byte
|
||||
reproducible. Verify it and run the OpenAPI tests with:
|
||||
|
||||
```
|
||||
rm kubernetesapi/swagger.go
|
||||
make kubernetesapi/swagger.go
|
||||
make -C kyaml/openapi verify
|
||||
```
|
||||
|
||||
To fetch the schema without generating the swagger.go, you can
|
||||
run:
|
||||
The protobuf archive is retained only as the compiler input and to preserve
|
||||
the legacy public asset API. The normal Kustomize runtime does not import that
|
||||
compatibility package.
|
||||
|
||||
```
|
||||
rm kubernetesapi/swagger.pb
|
||||
make kubernetesapi/swagger.pb
|
||||
```
|
||||
|
||||
Note that generating the swagger.go will re-fetch the schema.
|
||||
The small Kustomization schema remains as source JSON at
|
||||
`kustomizationapi/swagger.json` and is embedded directly with `go:embed`.
|
||||
|
||||
27
kyaml/openapi/builtin_schema.go
Normal file
27
kyaml/openapi/builtin_schema.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package openapi
|
||||
|
||||
import _ "embed"
|
||||
|
||||
//go:generate go run ./cmd/openapi-bundle -input kubernetesapi/v1_21_2/swagger.pb.gz -output kubernetesapi/data/kubernetes-openapi-union-v1.21.2.bundle-v1.json.gz -kubernetes-version v1.21.2
|
||||
|
||||
const (
|
||||
// DefaultOpenAPI is the Kubernetes version represented by the built-in
|
||||
// schema. It remains v1.21.2 during the initial artifact-format migration.
|
||||
DefaultOpenAPI = "v1.21.2"
|
||||
|
||||
// BuiltinSchemaInfo is the value printed by `kustomize openapi info`.
|
||||
BuiltinSchemaInfo = "{title:Kubernetes,version:" + DefaultOpenAPI + "}"
|
||||
)
|
||||
|
||||
//go:embed kubernetesapi/data/kubernetes-openapi-union-v1.21.2.bundle-v1.json.gz
|
||||
var builtinKubernetesOpenAPIBundle []byte
|
||||
|
||||
//go:embed kustomizationapi/swagger.json
|
||||
var builtinKustomizationOpenAPI []byte
|
||||
|
||||
func hasBuiltinOpenAPIVersion(version string) bool {
|
||||
return version == DefaultOpenAPI
|
||||
}
|
||||
186
kyaml/openapi/builtin_schema_test.go
Normal file
186
kyaml/openapi/builtin_schema_test.go
Normal file
@@ -0,0 +1,186 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"testing"
|
||||
|
||||
openapi_v2 "github.com/google/gnostic-models/openapiv2"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
"sigs.k8s.io/kustomize/kyaml/openapi/internal/builtinopenapi"
|
||||
"sigs.k8s.io/kustomize/kyaml/openapi/kubernetesapi/v1_21_2"
|
||||
"sigs.k8s.io/kustomize/kyaml/yaml"
|
||||
)
|
||||
|
||||
func TestBuiltinOpenAPIBundle(t *testing.T) {
|
||||
bundle, err := decodeBuiltinBundle(builtinKubernetesOpenAPIBundle)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, builtinopenapi.FormatVersion, bundle.FormatVersion)
|
||||
require.Equal(t, builtinopenapi.Coverage{Floor: "v1.21.2", Ceiling: "v1.21.2"}, bundle.Coverage)
|
||||
require.Len(t, bundle.Definitions, 618)
|
||||
require.Len(t, bundle.Resources, 275)
|
||||
|
||||
definitionResources := make(map[yaml.TypeMeta]string)
|
||||
scopes := 0
|
||||
for _, resource := range bundle.Resources {
|
||||
typeMeta := yaml.TypeMeta{APIVersion: resource.APIVersion, Kind: resource.Kind}
|
||||
if resource.Definition != "" {
|
||||
definitionResources[typeMeta] = resource.Definition
|
||||
}
|
||||
if resource.Scope != builtinopenapi.ScopeUnknown {
|
||||
scopes++
|
||||
}
|
||||
}
|
||||
|
||||
// A single-source bundle must carry the same GVK-to-definition mapping in
|
||||
// both its definitions and its resource inventory. The runtime indexes
|
||||
// schemas exclusively from the definitions' GVK extensions.
|
||||
ResetOpenAPI()
|
||||
t.Cleanup(ResetOpenAPI)
|
||||
AddDefinitions(bundle.Definitions)
|
||||
require.Len(t, globalSchema.schemaByResourceType, len(definitionResources))
|
||||
for typeMeta, definitionName := range definitionResources {
|
||||
indexed := globalSchema.schemaByResourceType[typeMeta]
|
||||
require.NotNil(t, indexed, "%v", typeMeta)
|
||||
require.Equal(t, bundle.Definitions[definitionName], *indexed, "%v", typeMeta)
|
||||
}
|
||||
|
||||
ResetOpenAPI()
|
||||
require.NoError(t, parseBuiltinBundle(builtinKubernetesOpenAPIBundle))
|
||||
require.Len(t, globalSchema.schema.Definitions, len(bundle.Definitions))
|
||||
require.Len(t, globalSchema.schemaByResourceType, len(definitionResources))
|
||||
require.Len(t, globalSchema.namespaceabilityByResourceType, scopes)
|
||||
for _, resource := range bundle.Resources {
|
||||
if resource.Scope == builtinopenapi.ScopeUnknown {
|
||||
continue
|
||||
}
|
||||
typeMeta := yaml.TypeMeta{APIVersion: resource.APIVersion, Kind: resource.Kind}
|
||||
_, found := globalSchema.namespaceabilityByResourceType[typeMeta]
|
||||
require.True(t, found, "%v", typeMeta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuiltinOpenAPIBundleMatchesLegacySchema(t *testing.T) {
|
||||
document := &openapi_v2.Document{}
|
||||
require.NoError(t, proto.Unmarshal(v1_21_2.MustAsset(
|
||||
"kubernetesapi/v1_21_2/swagger.pb"), document))
|
||||
var swagger spec.Swagger
|
||||
ok, err := swagger.FromGnostic(document)
|
||||
require.NoError(t, err)
|
||||
require.True(t, ok)
|
||||
|
||||
bundle, err := decodeBuiltinBundle(builtinKubernetesOpenAPIBundle)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, swagger.Definitions, bundle.Definitions)
|
||||
|
||||
ResetOpenAPI()
|
||||
t.Cleanup(ResetOpenAPI)
|
||||
AddDefinitions(swagger.Definitions)
|
||||
findNamespaceability(swagger.Paths)
|
||||
legacySchemas := make(map[yaml.TypeMeta]spec.Schema, len(globalSchema.schemaByResourceType))
|
||||
for typeMeta, schema := range globalSchema.schemaByResourceType {
|
||||
legacySchemas[typeMeta] = *schema
|
||||
}
|
||||
legacyScopes := make(map[yaml.TypeMeta]bool, len(globalSchema.namespaceabilityByResourceType))
|
||||
for typeMeta, namespaced := range globalSchema.namespaceabilityByResourceType {
|
||||
legacyScopes[typeMeta] = namespaced
|
||||
}
|
||||
|
||||
ResetOpenAPI()
|
||||
require.NoError(t, parseBuiltinBundle(builtinKubernetesOpenAPIBundle))
|
||||
require.Len(t, globalSchema.schemaByResourceType, len(legacySchemas))
|
||||
for typeMeta, schema := range legacySchemas {
|
||||
require.Equal(t, schema, *globalSchema.schemaByResourceType[typeMeta], "%v", typeMeta)
|
||||
}
|
||||
require.Equal(t, legacyScopes, globalSchema.namespaceabilityByResourceType)
|
||||
}
|
||||
|
||||
func TestDecodeBuiltinOpenAPIBundleRejectsInvalidData(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data []byte
|
||||
alreadyCompressed bool
|
||||
corruptChecksum bool
|
||||
errorContains string
|
||||
}{
|
||||
{
|
||||
name: "invalid gzip",
|
||||
data: []byte("not gzip"),
|
||||
alreadyCompressed: true,
|
||||
},
|
||||
{
|
||||
name: "invalid JSON",
|
||||
data: []byte(`{"formatVersion":`),
|
||||
},
|
||||
{
|
||||
name: "multiple JSON values",
|
||||
data: []byte(`{} {}`),
|
||||
errorContains: "multiple JSON values",
|
||||
},
|
||||
{
|
||||
name: "invalid trailing data",
|
||||
data: []byte(`{} trailing`),
|
||||
},
|
||||
{
|
||||
name: "invalid checksum",
|
||||
data: builtinKubernetesOpenAPIBundle,
|
||||
alreadyCompressed: true,
|
||||
corruptChecksum: true,
|
||||
errorContains: "gzip: invalid checksum",
|
||||
},
|
||||
{
|
||||
name: "invalid bundle",
|
||||
data: []byte(`{"formatVersion":2}`),
|
||||
errorContains: "unsupported built-in OpenAPI bundle format",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
compressed := bytes.Clone(test.data)
|
||||
if !test.alreadyCompressed {
|
||||
compressed = gzipBytes(t, test.data)
|
||||
}
|
||||
if test.corruptChecksum {
|
||||
compressed[len(compressed)-8] ^= 0xff
|
||||
}
|
||||
_, err := decodeBuiltinBundle(compressed)
|
||||
if test.errorContains == "" {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.ErrorContains(t, err, test.errorContains)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func gzipBytes(t *testing.T, data []byte) []byte {
|
||||
t.Helper()
|
||||
var compressed bytes.Buffer
|
||||
writer := gzip.NewWriter(&compressed)
|
||||
_, err := writer.Write(data)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
return compressed.Bytes()
|
||||
}
|
||||
|
||||
func TestBuiltinKustomizationSchema(t *testing.T) {
|
||||
ResetOpenAPI()
|
||||
t.Cleanup(ResetOpenAPI)
|
||||
schema := SchemaForResourceType(yaml.TypeMeta{
|
||||
APIVersion: "kustomize.config.k8s.io/v1beta1",
|
||||
Kind: "Kustomization",
|
||||
})
|
||||
require.NotNil(t, schema)
|
||||
strategy, key := schema.Field("configMapGenerator").PatchStrategyAndKey()
|
||||
require.Equal(t, "merge", strategy)
|
||||
require.Equal(t, "name", key)
|
||||
strategy, key = schema.Field("secretGenerator").PatchStrategyAndKey()
|
||||
require.Equal(t, "merge", strategy)
|
||||
require.Equal(t, "name", key)
|
||||
}
|
||||
387
kyaml/openapi/cmd/openapi-bundle/main.go
Normal file
387
kyaml/openapi/cmd/openapi-bundle/main.go
Normal file
@@ -0,0 +1,387 @@
|
||||
// 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
|
||||
}
|
||||
149
kyaml/openapi/cmd/openapi-bundle/main_test.go
Normal file
149
kyaml/openapi/cmd/openapi-bundle/main_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sigs.k8s.io/kustomize/kyaml/openapi/internal/builtinopenapi"
|
||||
)
|
||||
|
||||
func TestGeneratedBundleIsCurrentAndDeterministic(t *testing.T) {
|
||||
source := filepath.Join("..", "..", "kubernetesapi", "v1_21_2", "swagger.pb.gz")
|
||||
checkedIn := filepath.Join("..", "..", "kubernetesapi", "data",
|
||||
"kubernetes-openapi-union-v1.21.2.bundle-v1.json.gz")
|
||||
tempDir := t.TempDir()
|
||||
first := filepath.Join(tempDir, "first.json.gz")
|
||||
second := filepath.Join(tempDir, "second.json.gz")
|
||||
legacy := filepath.Join(tempDir, "swagger.pb.gz")
|
||||
|
||||
for i, output := range []string{first, second} {
|
||||
legacyOutput := ""
|
||||
if i == 0 {
|
||||
legacyOutput = legacy
|
||||
}
|
||||
require.NoError(t, run(options{
|
||||
input: source,
|
||||
output: output,
|
||||
legacyProtoOutput: legacyOutput,
|
||||
kubernetesVersion: "v1.21.2",
|
||||
}))
|
||||
}
|
||||
|
||||
want, err := os.ReadFile(checkedIn)
|
||||
require.NoError(t, err)
|
||||
got, err := os.ReadFile(first)
|
||||
require.NoError(t, err)
|
||||
again, err := os.ReadFile(second)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, want, got, "checked-in bundle is stale")
|
||||
require.Equal(t, got, again, "bundle generation is not deterministic")
|
||||
sourceArchive, err := os.ReadFile(source)
|
||||
require.NoError(t, err)
|
||||
legacyArchive, err := os.ReadFile(legacy)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, sourceArchive, legacyArchive, "compiler input archive is not deterministic")
|
||||
|
||||
reader, err := gzip.NewReader(bytes.NewReader(got))
|
||||
require.NoError(t, err)
|
||||
require.True(t, reader.ModTime.IsZero())
|
||||
require.Empty(t, reader.Name)
|
||||
require.Empty(t, reader.Comment)
|
||||
decoder := json.NewDecoder(reader)
|
||||
var bundle builtinopenapi.Bundle
|
||||
require.NoError(t, decoder.Decode(&bundle))
|
||||
var trailing interface{}
|
||||
require.ErrorIs(t, decoder.Decode(&trailing), io.EOF)
|
||||
require.NoError(t, reader.Close())
|
||||
require.NoError(t, bundle.Validate())
|
||||
require.Len(t, bundle.Definitions, 618)
|
||||
require.Len(t, bundle.Resources, 275)
|
||||
require.Equal(t, "5d171b55e9601912807a870d73ffe70bb306f5889a00e76986042a0f2d7b6bc2",
|
||||
bundle.Sources[0].SHA256)
|
||||
}
|
||||
|
||||
func TestWriteGzipUsesStableHeader(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "data.gz")
|
||||
require.NoError(t, writeGzip(path, []byte("data")))
|
||||
b, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
reader, err := gzip.NewReader(bytes.NewReader(b))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, time.Time{}, reader.ModTime)
|
||||
require.Empty(t, reader.Name)
|
||||
require.Empty(t, reader.Comment)
|
||||
require.Equal(t, byte(255), reader.OS)
|
||||
require.NoError(t, reader.Close())
|
||||
}
|
||||
|
||||
func TestReadInputWithLimit(t *testing.T) {
|
||||
const limit = int64(4)
|
||||
testDir := t.TempDir()
|
||||
|
||||
write := func(t *testing.T, name string, contents []byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(testDir, name)
|
||||
require.NoError(t, os.WriteFile(path, contents, 0o600))
|
||||
return path
|
||||
}
|
||||
gzipContents := func(t *testing.T, contents []byte) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
writer := gzip.NewWriter(&buffer)
|
||||
_, err := writer.Write(contents)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
return buffer.Bytes()
|
||||
}
|
||||
|
||||
t.Run("raw at limit", func(t *testing.T) {
|
||||
path := write(t, "raw", []byte("data"))
|
||||
got, err := readInputWithLimit(path, limit)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("data"), got)
|
||||
})
|
||||
|
||||
t.Run("raw exceeds limit", func(t *testing.T) {
|
||||
path := write(t, "raw-large", []byte("large"))
|
||||
_, err := readInputWithLimit(path, limit)
|
||||
require.ErrorContains(t, err, "input")
|
||||
require.ErrorContains(t, err, "exceeds 4 bytes")
|
||||
})
|
||||
|
||||
t.Run("gzip at limit", func(t *testing.T) {
|
||||
path := write(t, "gzip", gzipContents(t, []byte("data")))
|
||||
got, err := readInputWithLimit(path, limit)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []byte("data"), got)
|
||||
})
|
||||
|
||||
t.Run("gzip exceeds limit", func(t *testing.T) {
|
||||
path := write(t, "gzip-large", gzipContents(t, []byte("large")))
|
||||
_, err := readInputWithLimit(path, limit)
|
||||
require.ErrorContains(t, err, "decompressed input")
|
||||
require.ErrorContains(t, err, "exceeds 4 bytes")
|
||||
})
|
||||
|
||||
t.Run("invalid gzip header", func(t *testing.T) {
|
||||
path := write(t, "gzip-invalid-header", []byte{0x1f, 0x8b})
|
||||
_, err := readInputWithLimit(path, limit)
|
||||
require.ErrorContains(t, err, "open gzip input")
|
||||
})
|
||||
|
||||
t.Run("invalid gzip body", func(t *testing.T) {
|
||||
contents := gzipContents(t, []byte("data"))
|
||||
contents[len(contents)-1]++
|
||||
path := write(t, "gzip-invalid-body", contents)
|
||||
_, err := readInputWithLimit(path, limit)
|
||||
require.ErrorContains(t, err, "read decompressed input")
|
||||
})
|
||||
}
|
||||
223
kyaml/openapi/internal/builtinopenapi/bundle.go
Normal file
223
kyaml/openapi/internal/builtinopenapi/bundle.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package builtinopenapi defines the on-disk format of the compiled built-in
|
||||
// Kubernetes OpenAPI bundle.
|
||||
package builtinopenapi
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
)
|
||||
|
||||
const (
|
||||
// FormatVersion is the version of the bundle's JSON representation.
|
||||
FormatVersion = 1
|
||||
|
||||
// SelectionPolicy identifies how schemas from Kubernetes releases are
|
||||
// selected. A single-release bundle is the degenerate case of this policy.
|
||||
SelectionPolicy = "latest-wins-fill-missing"
|
||||
|
||||
gvkExtension = "x-kubernetes-group-version-kind"
|
||||
)
|
||||
|
||||
// Scope describes whether a Kubernetes resource is namespace or cluster
|
||||
// scoped. An empty Scope means that the source OpenAPI document did not expose
|
||||
// a resource path from which scope could be determined.
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
ScopeUnknown Scope = ""
|
||||
ScopeNamespaced Scope = "Namespaced"
|
||||
ScopeCluster Scope = "Cluster"
|
||||
)
|
||||
|
||||
// Coverage identifies the Kubernetes release range represented by a bundle.
|
||||
type Coverage struct {
|
||||
Floor string `json:"floor"`
|
||||
Ceiling string `json:"ceiling"`
|
||||
}
|
||||
|
||||
// Source identifies one OpenAPI input used to compile a bundle.
|
||||
type Source struct {
|
||||
KubernetesVersion string `json:"kubernetesVersion"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// Resource maps a GVK to its root definition and, when known, its scope.
|
||||
// Definition may be empty for a GVK that was present in an API path but not in
|
||||
// the OpenAPI definitions.
|
||||
type Resource struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Definition string `json:"definition,omitempty"`
|
||||
Scope Scope `json:"scope,omitempty"`
|
||||
}
|
||||
|
||||
// Bundle is the compiled representation consumed by kyaml at runtime.
|
||||
type Bundle struct {
|
||||
FormatVersion int `json:"formatVersion"`
|
||||
Coverage Coverage `json:"coverage"`
|
||||
SelectionPolicy string `json:"selectionPolicy"`
|
||||
Sources []Source `json:"sources"`
|
||||
Definitions spec.Definitions `json:"definitions"`
|
||||
Resources []Resource `json:"resources"`
|
||||
}
|
||||
|
||||
// Validate checks invariants required by the runtime loader.
|
||||
func (b *Bundle) Validate() error {
|
||||
if b.FormatVersion != FormatVersion {
|
||||
return fmt.Errorf("unsupported built-in OpenAPI bundle format %d", b.FormatVersion)
|
||||
}
|
||||
if b.Coverage.Floor == "" || b.Coverage.Ceiling == "" {
|
||||
return fmt.Errorf("built-in OpenAPI bundle coverage is incomplete")
|
||||
}
|
||||
if b.SelectionPolicy != SelectionPolicy {
|
||||
return fmt.Errorf("unsupported built-in OpenAPI selection policy %q", b.SelectionPolicy)
|
||||
}
|
||||
if len(b.Sources) == 0 {
|
||||
return fmt.Errorf("built-in OpenAPI bundle has no sources")
|
||||
}
|
||||
for _, source := range b.Sources {
|
||||
if source.KubernetesVersion == "" {
|
||||
return fmt.Errorf("built-in OpenAPI bundle has a source without a Kubernetes version")
|
||||
}
|
||||
if len(source.SHA256) != 64 {
|
||||
return fmt.Errorf("built-in OpenAPI source %q has an invalid SHA-256", source.KubernetesVersion)
|
||||
}
|
||||
if _, err := hex.DecodeString(source.SHA256); err != nil {
|
||||
return fmt.Errorf("built-in OpenAPI source %q has an invalid SHA-256", source.KubernetesVersion)
|
||||
}
|
||||
}
|
||||
if len(b.Definitions) == 0 {
|
||||
return fmt.Errorf("built-in OpenAPI bundle has no definitions")
|
||||
}
|
||||
if len(b.Resources) == 0 {
|
||||
return fmt.Errorf("built-in OpenAPI bundle has no resources")
|
||||
}
|
||||
|
||||
resourcesByGVK := make(map[string]Resource, len(b.Resources))
|
||||
for i, resource := range b.Resources {
|
||||
if resource.APIVersion == "" || resource.Kind == "" {
|
||||
return fmt.Errorf("built-in OpenAPI resource %d has an incomplete GVK", i)
|
||||
}
|
||||
switch resource.Scope {
|
||||
case ScopeUnknown, ScopeNamespaced, ScopeCluster:
|
||||
default:
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s has invalid scope %q",
|
||||
resource.APIVersion, resource.Kind, resource.Scope)
|
||||
}
|
||||
if resource.Definition != "" {
|
||||
if _, found := b.Definitions[resource.Definition]; !found {
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s references missing definition %q",
|
||||
resource.APIVersion, resource.Kind, resource.Definition)
|
||||
}
|
||||
}
|
||||
key := resourceKey(resource.APIVersion, resource.Kind)
|
||||
if _, found := resourcesByGVK[key]; found {
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s is duplicated",
|
||||
resource.APIVersion, resource.Kind)
|
||||
}
|
||||
resourcesByGVK[key] = resource
|
||||
if i > 0 && lessResource(resource, b.Resources[i-1]) {
|
||||
return fmt.Errorf("built-in OpenAPI resources are not sorted")
|
||||
}
|
||||
}
|
||||
return validateDefinitionResources(b.Definitions, resourcesByGVK)
|
||||
}
|
||||
|
||||
func validateDefinitionResources(definitions spec.Definitions, resourcesByGVK map[string]Resource) error {
|
||||
definitionsByGVK := make(map[string]string)
|
||||
for definitionName, definition := range definitions {
|
||||
extension, found := definition.Extensions[gvkExtension]
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
entries, ok := extension.([]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("built-in OpenAPI definition %q has malformed %s extension: expected an array",
|
||||
definitionName, gvkExtension)
|
||||
}
|
||||
for i, entry := range entries {
|
||||
apiVersion, kind, err := parseGVK(entry)
|
||||
if err != nil {
|
||||
return fmt.Errorf("built-in OpenAPI definition %q has malformed %s extension entry %d: %w",
|
||||
definitionName, gvkExtension, i, err)
|
||||
}
|
||||
key := resourceKey(apiVersion, kind)
|
||||
if previousDefinition, found := definitionsByGVK[key]; found && previousDefinition != definitionName {
|
||||
return fmt.Errorf("built-in OpenAPI GVK %s/%s is advertised by definitions %q and %q",
|
||||
apiVersion, kind, previousDefinition, definitionName)
|
||||
}
|
||||
definitionsByGVK[key] = definitionName
|
||||
|
||||
resource, found := resourcesByGVK[key]
|
||||
if !found {
|
||||
return fmt.Errorf("built-in OpenAPI definition %q advertises GVK %s/%s without a resource mapping",
|
||||
definitionName, apiVersion, kind)
|
||||
}
|
||||
if resource.Definition != definitionName {
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s references definition %q, but definition %q advertises it",
|
||||
apiVersion, kind, resource.Definition, definitionName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, resource := range resourcesByGVK {
|
||||
if resource.Definition == "" {
|
||||
continue
|
||||
}
|
||||
definitionName, found := definitionsByGVK[resourceKey(resource.APIVersion, resource.Kind)]
|
||||
if !found {
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s references definition %q, but that definition does not advertise the GVK",
|
||||
resource.APIVersion, resource.Kind, resource.Definition)
|
||||
}
|
||||
if definitionName != resource.Definition {
|
||||
return fmt.Errorf("built-in OpenAPI resource %s/%s references definition %q, but definition %q advertises it",
|
||||
resource.APIVersion, resource.Kind, resource.Definition, definitionName)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseGVK(value interface{}) (string, string, error) {
|
||||
entry, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("expected an object")
|
||||
}
|
||||
version, versionOK := entry["version"].(string)
|
||||
kind, kindOK := entry["kind"].(string)
|
||||
if !versionOK || version == "" || !kindOK || kind == "" {
|
||||
return "", "", fmt.Errorf("version and kind must be non-empty strings")
|
||||
}
|
||||
groupValue, hasGroup := entry["group"]
|
||||
group, groupOK := groupValue.(string)
|
||||
if hasGroup && !groupOK {
|
||||
return "", "", fmt.Errorf("group must be a string")
|
||||
}
|
||||
if group != "" {
|
||||
return group + "/" + version, kind, nil
|
||||
}
|
||||
return version, kind, nil
|
||||
}
|
||||
|
||||
func resourceKey(apiVersion, kind string) string {
|
||||
return apiVersion + "\x00" + kind
|
||||
}
|
||||
|
||||
// SortResources orders resources deterministically for serialization.
|
||||
func SortResources(resources []Resource) {
|
||||
sort.Slice(resources, func(i, j int) bool {
|
||||
return lessResource(resources[i], resources[j])
|
||||
})
|
||||
}
|
||||
|
||||
func lessResource(left, right Resource) bool {
|
||||
if left.APIVersion != right.APIVersion {
|
||||
return left.APIVersion < right.APIVersion
|
||||
}
|
||||
return left.Kind < right.Kind
|
||||
}
|
||||
128
kyaml/openapi/internal/builtinopenapi/bundle_test.go
Normal file
128
kyaml/openapi/internal/builtinopenapi/bundle_test.go
Normal file
@@ -0,0 +1,128 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package builtinopenapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"k8s.io/kube-openapi/pkg/validation/spec"
|
||||
)
|
||||
|
||||
func TestBundleValidate(t *testing.T) {
|
||||
valid := func() Bundle {
|
||||
definition := spec.Schema{}
|
||||
definition.Extensions = spec.Extensions{
|
||||
gvkExtension: []interface{}{
|
||||
map[string]interface{}{"group": "apps", "version": "v1", "kind": "Deployment"},
|
||||
},
|
||||
}
|
||||
return Bundle{
|
||||
FormatVersion: FormatVersion,
|
||||
Coverage: Coverage{Floor: "v1.21.2", Ceiling: "v1.21.2"},
|
||||
SelectionPolicy: SelectionPolicy,
|
||||
Sources: []Source{{
|
||||
KubernetesVersion: "v1.21.2",
|
||||
SHA256: "5d171b55e9601912807a870d73ffe70bb306f5889a00e76986042a0f2d7b6bc2",
|
||||
}},
|
||||
Definitions: spec.Definitions{"definition": definition},
|
||||
Resources: []Resource{{
|
||||
APIVersion: "apps/v1",
|
||||
Kind: "Deployment",
|
||||
Definition: "definition",
|
||||
Scope: ScopeNamespaced,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
tests := map[string]func(*Bundle){
|
||||
"format": func(bundle *Bundle) { bundle.FormatVersion++ },
|
||||
"coverage": func(bundle *Bundle) { bundle.Coverage.Floor = "" },
|
||||
"policy": func(bundle *Bundle) { bundle.SelectionPolicy = "unknown" },
|
||||
"source": func(bundle *Bundle) { bundle.Sources[0].SHA256 = "short" },
|
||||
"source hex": func(bundle *Bundle) {
|
||||
bundle.Sources[0].SHA256 = "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
|
||||
},
|
||||
"definition": func(bundle *Bundle) { bundle.Resources[0].Definition = "missing" },
|
||||
"no definitions": func(bundle *Bundle) {
|
||||
bundle.Definitions = nil
|
||||
},
|
||||
"no resources": func(bundle *Bundle) {
|
||||
bundle.Resources = nil
|
||||
},
|
||||
"scope": func(bundle *Bundle) { bundle.Resources[0].Scope = "invalid" },
|
||||
"duplicate": func(bundle *Bundle) {
|
||||
bundle.Resources = append(bundle.Resources, bundle.Resources[0])
|
||||
},
|
||||
"duplicate with different definition": func(bundle *Bundle) {
|
||||
secondDefinition := bundle.Definitions["definition"]
|
||||
bundle.Definitions["second-definition"] = secondDefinition
|
||||
duplicate := bundle.Resources[0]
|
||||
duplicate.Definition = "second-definition"
|
||||
bundle.Resources = append(bundle.Resources, duplicate)
|
||||
},
|
||||
"GVK advertised by different definitions": func(bundle *Bundle) {
|
||||
secondDefinition := bundle.Definitions["definition"]
|
||||
bundle.Definitions["second-definition"] = secondDefinition
|
||||
},
|
||||
"order": func(bundle *Bundle) {
|
||||
bundle.Resources = append([]Resource{{APIVersion: "v1", Kind: "Pod"}}, bundle.Resources...)
|
||||
},
|
||||
"malformed definition extension type": func(bundle *Bundle) {
|
||||
definition := bundle.Definitions["definition"]
|
||||
definition.Extensions[gvkExtension] = map[string]interface{}{}
|
||||
bundle.Definitions["definition"] = definition
|
||||
},
|
||||
"malformed definition extension entry": func(bundle *Bundle) {
|
||||
definition := bundle.Definitions["definition"]
|
||||
definition.Extensions[gvkExtension] = []interface{}{map[string]interface{}{"version": "v1"}}
|
||||
bundle.Definitions["definition"] = definition
|
||||
},
|
||||
"malformed definition extension group": func(bundle *Bundle) {
|
||||
definition := bundle.Definitions["definition"]
|
||||
definition.Extensions[gvkExtension] = []interface{}{
|
||||
map[string]interface{}{"group": 1, "version": "v1", "kind": "Deployment"},
|
||||
}
|
||||
bundle.Definitions["definition"] = definition
|
||||
},
|
||||
"resource GVK absent from definition extension": func(bundle *Bundle) {
|
||||
definition := bundle.Definitions["definition"]
|
||||
definition.Extensions[gvkExtension] = []interface{}{}
|
||||
bundle.Definitions["definition"] = definition
|
||||
},
|
||||
"definition GVK absent from resources": func(bundle *Bundle) {
|
||||
definition := bundle.Definitions["definition"]
|
||||
definition.Extensions[gvkExtension] = []interface{}{
|
||||
map[string]interface{}{"group": "apps", "version": "v1", "kind": "Deployment"},
|
||||
map[string]interface{}{"group": "apps", "version": "v1", "kind": "StatefulSet"},
|
||||
}
|
||||
bundle.Definitions["definition"] = definition
|
||||
},
|
||||
}
|
||||
|
||||
require.NoError(t, func() error { bundle := valid(); return bundle.Validate() }())
|
||||
for name, mutate := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
bundle := valid()
|
||||
mutate(&bundle)
|
||||
require.Error(t, bundle.Validate())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLessResourceUsesOnlyGVK(t *testing.T) {
|
||||
left := Resource{APIVersion: "apps/v1", Kind: "Deployment", Definition: "z"}
|
||||
right := Resource{APIVersion: "apps/v1", Kind: "Deployment", Definition: "a"}
|
||||
require.False(t, lessResource(left, right))
|
||||
require.False(t, lessResource(right, left))
|
||||
|
||||
require.True(t, lessResource(
|
||||
Resource{APIVersion: "apps/v1", Kind: "Deployment"},
|
||||
Resource{APIVersion: "apps/v1", Kind: "StatefulSet"},
|
||||
))
|
||||
require.True(t, lessResource(
|
||||
Resource{APIVersion: "apps/v1", Kind: "Deployment"},
|
||||
Resource{APIVersion: "batch/v1", Kind: "CronJob"},
|
||||
))
|
||||
}
|
||||
147
kyaml/openapi/internal/embeddedasset/asset.go
Normal file
147
kyaml/openapi/internal/embeddedasset/asset.go
Normal file
@@ -0,0 +1,147 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Package embeddedasset provides helpers that preserve the legacy generated
|
||||
// asset API exposed by the OpenAPI packages.
|
||||
package embeddedasset
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// File describes one embedded asset.
|
||||
type File struct {
|
||||
// Path is the full slash-separated virtual path used to look up the asset.
|
||||
Path string
|
||||
Data []byte
|
||||
Compressed bool
|
||||
Size int64
|
||||
Mode os.FileMode
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// Asset returns a fresh copy of the uncompressed asset data.
|
||||
func Asset(file File, name string) ([]byte, error) {
|
||||
if canonical(name) != file.Path {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
if !file.Compressed {
|
||||
return bytes.Clone(file.Data), nil
|
||||
}
|
||||
reader, err := gzip.NewReader(bytes.NewReader(file.Data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %q: %w", name, err)
|
||||
}
|
||||
b, readErr := io.ReadAll(io.LimitReader(reader, file.Size+1))
|
||||
closeErr := reader.Close()
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("read %q: %w", name, readErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return nil, fmt.Errorf("read %q: %w", name, closeErr)
|
||||
}
|
||||
if int64(len(b)) != file.Size {
|
||||
return nil, fmt.Errorf("read %q: expected %d bytes, got %d", name, file.Size, len(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// MustAsset is like Asset but panics on error.
|
||||
func MustAsset(file File, name string) []byte {
|
||||
b, err := Asset(file, name)
|
||||
if err != nil {
|
||||
panic("asset: Asset(" + name + "): " + err.Error())
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// AssetInfo returns metadata for an embedded asset.
|
||||
func AssetInfo(file File, name string) (os.FileInfo, error) {
|
||||
if canonical(name) != file.Path {
|
||||
return nil, fmt.Errorf("AssetInfo %s not found", name)
|
||||
}
|
||||
return fileInfo{file: file}, nil
|
||||
}
|
||||
|
||||
// AssetDir returns the immediate children of a directory in the asset path.
|
||||
func AssetDir(file File, name string) ([]string, error) {
|
||||
name = canonical(name)
|
||||
parts := strings.Split(file.Path, "/")
|
||||
if name == "" {
|
||||
return []string{parts[0]}, nil
|
||||
}
|
||||
dirParts := strings.Split(name, "/")
|
||||
if len(dirParts) >= len(parts) {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
for i := range dirParts {
|
||||
if dirParts[i] != parts[i] {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
}
|
||||
return []string{parts[len(dirParts)]}, nil
|
||||
}
|
||||
|
||||
// RestoreAsset restores an asset under dir.
|
||||
func RestoreAsset(file File, dir, name string) error {
|
||||
b, err := Asset(file, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := AssetInfo(file, name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
target := filePath(dir, name)
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return fmt.Errorf("create asset directory: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(target, b, info.Mode()); err != nil {
|
||||
return fmt.Errorf("write asset %q: %w", name, err)
|
||||
}
|
||||
if err := os.Chtimes(target, info.ModTime(), info.ModTime()); err != nil {
|
||||
return fmt.Errorf("restore timestamps for asset %q: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RestoreAssets restores an asset or directory recursively under dir.
|
||||
func RestoreAssets(file File, dir, name string) error {
|
||||
children, err := AssetDir(file, name)
|
||||
if err != nil {
|
||||
return RestoreAsset(file, dir, name)
|
||||
}
|
||||
for _, child := range children {
|
||||
if err := RestoreAssets(file, dir, filepath.Join(name, child)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fileInfo struct {
|
||||
file File
|
||||
}
|
||||
|
||||
func (info fileInfo) Name() string { return path.Base(info.file.Path) }
|
||||
func (info fileInfo) Size() int64 { return info.file.Size }
|
||||
func (info fileInfo) Mode() os.FileMode { return info.file.Mode }
|
||||
func (info fileInfo) ModTime() time.Time { return info.file.ModTime }
|
||||
func (info fileInfo) IsDir() bool { return false }
|
||||
func (info fileInfo) Sys() interface{} { return nil }
|
||||
|
||||
func canonical(name string) string {
|
||||
return strings.ReplaceAll(name, "\\", "/")
|
||||
}
|
||||
|
||||
func filePath(dir, name string) string {
|
||||
return filepath.Join(append([]string{dir}, strings.Split(canonical(name), "/")...)...)
|
||||
}
|
||||
202
kyaml/openapi/internal/embeddedasset/asset_test.go
Normal file
202
kyaml/openapi/internal/embeddedasset/asset_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package embeddedasset
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testAssetPath = "root/nested/asset.txt"
|
||||
|
||||
var testModTime = time.Unix(1700000000, 0) //nolint:gochecknoglobals
|
||||
|
||||
func TestAsset(t *testing.T) {
|
||||
contents := []byte("asset contents")
|
||||
for name, compressed := range map[string]bool{
|
||||
"raw": false,
|
||||
"compressed": true,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
data := contents
|
||||
if compressed {
|
||||
data = gzipData(t, contents)
|
||||
}
|
||||
file := newTestFile(data, int64(len(contents)), compressed)
|
||||
|
||||
got, err := Asset(file, testAssetPath)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, contents, got)
|
||||
|
||||
got[0] ^= 0xff
|
||||
again, err := Asset(file, "root\\nested\\asset.txt")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, contents, again, "Asset must return a fresh byte slice")
|
||||
})
|
||||
}
|
||||
|
||||
_, err := Asset(newTestFile(nil, 0, false), "missing")
|
||||
require.ErrorContains(t, err, "Asset missing not found")
|
||||
}
|
||||
|
||||
func TestAssetRejectsInvalidCompressedData(t *testing.T) {
|
||||
t.Run("invalid gzip", func(t *testing.T) {
|
||||
file := newTestFile([]byte("not gzip"), 1, true)
|
||||
_, err := Asset(file, testAssetPath)
|
||||
require.ErrorContains(t, err, "read \"root/nested/asset.txt\"")
|
||||
})
|
||||
|
||||
t.Run("invalid gzip body", func(t *testing.T) {
|
||||
data := gzipData(t, []byte("asset contents"))
|
||||
data[len(data)-1] ^= 0xff
|
||||
file := newTestFile(data, int64(len("asset contents")), true)
|
||||
_, err := Asset(file, testAssetPath)
|
||||
require.ErrorContains(t, err, "read \"root/nested/asset.txt\"")
|
||||
})
|
||||
|
||||
t.Run("declared size too small", func(t *testing.T) {
|
||||
contents := []byte("asset contents")
|
||||
file := newTestFile(gzipData(t, contents), int64(len(contents)-1), true)
|
||||
_, err := Asset(file, testAssetPath)
|
||||
require.ErrorContains(t, err, "expected 13 bytes, got 14")
|
||||
})
|
||||
|
||||
t.Run("declared size too large", func(t *testing.T) {
|
||||
contents := []byte("asset contents")
|
||||
file := newTestFile(gzipData(t, contents), int64(len(contents)+1), true)
|
||||
_, err := Asset(file, testAssetPath)
|
||||
require.ErrorContains(t, err, "expected 15 bytes, got 14")
|
||||
})
|
||||
}
|
||||
|
||||
func TestMustAsset(t *testing.T) {
|
||||
contents := []byte("asset contents")
|
||||
file := newTestFile(contents, int64(len(contents)), false)
|
||||
require.Equal(t, contents, MustAsset(file, testAssetPath))
|
||||
require.Panics(t, func() {
|
||||
MustAsset(file, "missing")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAssetInfo(t *testing.T) {
|
||||
contents := []byte("asset contents")
|
||||
file := newTestFile(contents, int64(len(contents)), false)
|
||||
|
||||
info, err := AssetInfo(file, "root\\nested\\asset.txt")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "asset.txt", info.Name())
|
||||
require.Equal(t, int64(len(contents)), info.Size())
|
||||
require.Equal(t, os.FileMode(0o640), info.Mode())
|
||||
require.Equal(t, testModTime, info.ModTime())
|
||||
require.False(t, info.IsDir())
|
||||
require.Nil(t, info.Sys())
|
||||
|
||||
_, err = AssetInfo(file, "missing")
|
||||
require.ErrorContains(t, err, "AssetInfo missing not found")
|
||||
}
|
||||
|
||||
func TestAssetDir(t *testing.T) {
|
||||
file := newTestFile(nil, 0, false)
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected []string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "root", path: "", expected: []string{"root"}},
|
||||
{name: "first level", path: "root", expected: []string{"nested"}},
|
||||
{name: "nested", path: "root/nested", expected: []string{"asset.txt"}},
|
||||
{name: "backslashes", path: "root\\nested", expected: []string{"asset.txt"}},
|
||||
{name: "file", path: testAssetPath, wantErr: true},
|
||||
{name: "missing", path: "missing", wantErr: true},
|
||||
{name: "trailing slash", path: "root/nested/", wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
children, err := AssetDir(file, test.path)
|
||||
if test.wantErr {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.expected, children)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreAsset(t *testing.T) {
|
||||
contents := []byte("asset contents")
|
||||
file := newTestFile(contents, int64(len(contents)), false)
|
||||
dir := t.TempDir()
|
||||
|
||||
require.NoError(t, RestoreAsset(file, dir, "root\\nested\\asset.txt"))
|
||||
restored, err := os.ReadFile(filepath.Join(dir, "root", "nested", "asset.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, contents, restored)
|
||||
|
||||
require.Error(t, RestoreAsset(file, dir, "missing"))
|
||||
}
|
||||
|
||||
func TestRestoreAssets(t *testing.T) {
|
||||
contents := []byte("asset contents")
|
||||
file := newTestFile(contents, int64(len(contents)), false)
|
||||
|
||||
for _, name := range []string{"", "root", testAssetPath} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, RestoreAssets(file, dir, name))
|
||||
restored, err := os.ReadFile(filepath.Join(dir, "root", "nested", "asset.txt"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, contents, restored)
|
||||
})
|
||||
}
|
||||
|
||||
require.Error(t, RestoreAssets(file, t.TempDir(), "missing"))
|
||||
}
|
||||
|
||||
func TestCanonical(t *testing.T) {
|
||||
tests := map[string]string{
|
||||
"": "",
|
||||
"root/nested/asset.txt": "root/nested/asset.txt",
|
||||
"root\\nested\\asset.txt": "root/nested/asset.txt",
|
||||
"root/nested\\asset.txt": "root/nested/asset.txt",
|
||||
}
|
||||
for input, expected := range tests {
|
||||
require.Equal(t, expected, canonical(input))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilePath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
expected := filepath.Join(dir, "root", "nested", "asset.txt")
|
||||
require.Equal(t, expected, filePath(dir, "root/nested/asset.txt"))
|
||||
require.Equal(t, expected, filePath(dir, "root\\nested\\asset.txt"))
|
||||
}
|
||||
|
||||
func newTestFile(data []byte, size int64, compressed bool) File {
|
||||
return File{
|
||||
Path: testAssetPath,
|
||||
Data: data,
|
||||
Compressed: compressed,
|
||||
Size: size,
|
||||
Mode: 0o640,
|
||||
ModTime: testModTime,
|
||||
}
|
||||
}
|
||||
|
||||
func gzipData(t *testing.T, data []byte) []byte {
|
||||
t.Helper()
|
||||
var buffer bytes.Buffer
|
||||
writer := gzip.NewWriter(&buffer)
|
||||
_, err := writer.Write(data)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, writer.Close())
|
||||
return buffer.Bytes()
|
||||
}
|
||||
Binary file not shown.
@@ -1,18 +1,22 @@
|
||||
// Copyright 2020 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Code generated by ./scripts/makeOpenApiInfoDotGo.sh; DO NOT EDIT.
|
||||
|
||||
// Package kubernetesapi preserves access to the legacy built-in Kubernetes
|
||||
// OpenAPI assets. New code should use the parent openapi package.
|
||||
package kubernetesapi
|
||||
|
||||
import (
|
||||
"sigs.k8s.io/kustomize/kyaml/openapi/kubernetesapi/v1_21_2"
|
||||
)
|
||||
|
||||
// Info describes the Kubernetes release represented by the legacy asset map.
|
||||
const Info = "{title:Kubernetes,version:v1.21.2}"
|
||||
|
||||
var OpenAPIMustAsset = map[string]func(string) []byte{
|
||||
// OpenAPIMustAsset maps supported Kubernetes releases to their legacy asset
|
||||
// loaders.
|
||||
var OpenAPIMustAsset = map[string]func(string) []byte{ //nolint:gochecknoglobals // Retained for API compatibility.
|
||||
"v1.21.2": v1_21_2.MustAsset,
|
||||
}
|
||||
|
||||
// DefaultOpenAPI is the default release exposed by the legacy asset map.
|
||||
const DefaultOpenAPI = "v1.21.2"
|
||||
|
||||
17
kyaml/openapi/kubernetesapi/openapiinfo_test.go
Normal file
17
kyaml/openapi/kubernetesapi/openapiinfo_test.go
Normal file
@@ -0,0 +1,17 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package kubernetesapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"sigs.k8s.io/kustomize/kyaml/openapi"
|
||||
)
|
||||
|
||||
func TestLegacyMetadataMatchesBuiltinBundle(t *testing.T) {
|
||||
require.Equal(t, openapi.DefaultOpenAPI, DefaultOpenAPI)
|
||||
require.Equal(t, openapi.BuiltinSchemaInfo, Info)
|
||||
require.Contains(t, OpenAPIMustAsset, DefaultOpenAPI)
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
BIN
kyaml/openapi/kubernetesapi/v1_21_2/swagger.pb.gz
Normal file
BIN
kyaml/openapi/kubernetesapi/v1_21_2/swagger.pb.gz
Normal file
Binary file not shown.
43
kyaml/openapi/kubernetesapi/v1_21_2/swagger_test.go
Normal file
43
kyaml/openapi/kubernetesapi/v1_21_2/swagger_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package v1_21_2 //nolint:revive // The package name is part of the public API.
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAssetCompatibility(t *testing.T) {
|
||||
b, err := Asset(assetName)
|
||||
require.NoError(t, err)
|
||||
digest := sha256.Sum256(b)
|
||||
require.Equal(t, "5d171b55e9601912807a870d73ffe70bb306f5889a00e76986042a0f2d7b6bc2",
|
||||
hex.EncodeToString(digest[:]))
|
||||
info, err := AssetInfo(assetName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(3469475), info.Size())
|
||||
require.Equal(t, "swagger.pb", info.Name())
|
||||
|
||||
backslashName := "kubernetesapi\\v1_21_2\\swagger.pb"
|
||||
_, err = Asset(backslashName)
|
||||
require.NoError(t, err)
|
||||
children, err := AssetDir("kubernetesapi/v1_21_2")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"swagger.pb"}, children)
|
||||
_, err = AssetDir(assetName)
|
||||
require.Error(t, err)
|
||||
|
||||
b[0] ^= 0xff
|
||||
again := MustAsset(assetName)
|
||||
require.NotEqual(t, b[0], again[0], "Asset must return a fresh byte slice")
|
||||
_, err = Asset("missing")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAssetNames(t *testing.T) {
|
||||
require.Equal(t, []string{assetName}, AssetNames())
|
||||
}
|
||||
@@ -1,248 +1,57 @@
|
||||
// Copyright 2019 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Code generated for package kustomizationapi by go-bindata DO NOT EDIT. (@generated)
|
||||
// sources:
|
||||
// kustomizationapi/swagger.json
|
||||
// Package kustomizationapi exposes the embedded Kustomization OpenAPI schema.
|
||||
package kustomizationapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"fmt"
|
||||
"io"
|
||||
_ "embed"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sigs.k8s.io/kustomize/kyaml/openapi/internal/embeddedasset"
|
||||
)
|
||||
|
||||
func bindataRead(data []byte, name string) ([]byte, error) {
|
||||
gz, err := gzip.NewReader(bytes.NewBuffer(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
const assetName = "kustomizationapi/swagger.json"
|
||||
|
||||
var buf bytes.Buffer
|
||||
_, err = io.Copy(&buf, gz)
|
||||
clErr := gz.Close()
|
||||
//go:embed swagger.json
|
||||
var swaggerJSON []byte
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Read %q: %v", name, err)
|
||||
}
|
||||
if clErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
type asset struct {
|
||||
bytes []byte
|
||||
info os.FileInfo
|
||||
}
|
||||
|
||||
type bindataFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
mode os.FileMode
|
||||
modTime time.Time
|
||||
}
|
||||
|
||||
// Name return file name
|
||||
func (fi bindataFileInfo) Name() string {
|
||||
return fi.name
|
||||
}
|
||||
|
||||
// Size return file size
|
||||
func (fi bindataFileInfo) Size() int64 {
|
||||
return fi.size
|
||||
}
|
||||
|
||||
// Mode return file mode
|
||||
func (fi bindataFileInfo) Mode() os.FileMode {
|
||||
return fi.mode
|
||||
}
|
||||
|
||||
// ModTime return file modify time
|
||||
func (fi bindataFileInfo) ModTime() time.Time {
|
||||
return fi.modTime
|
||||
}
|
||||
|
||||
// IsDir return file whether a directory
|
||||
func (fi bindataFileInfo) IsDir() bool {
|
||||
return fi.mode&os.ModeDir != 0
|
||||
}
|
||||
|
||||
// Sys return file is sys mode
|
||||
func (fi bindataFileInfo) Sys() interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
var _kustomizationapiSwaggerJson = []byte("\x1f\x8b\x08\x00\x00\x00\x00\x00\x00\xff\xe4\x56\xc1\x6e\xdb\x30\x0c\xbd\xe7\x2b\x04\x6d\xc7\xd8\x45\x6e\x43\x6e\xc3\x0e\x3b\x14\x05\x0a\x74\xb7\xa1\x07\xc6\xa1\x5d\xce\x8e\xa4\x51\xb4\xb1\x6c\xc8\xbf\x0f\xd6\x62\xd7\x4a\xec\x75\x0b\x1a\xac\x4b\x0f\x06\x0c\x99\x7c\x4f\xe4\x7b\x24\xfc\x63\xa6\x94\x5e\x63\x4e\x86\x84\xac\xf1\x7a\xa9\xda\x23\xa5\x34\xd9\xb4\x7c\xe7\x53\x70\x94\x82\x73\x3e\x6d\x16\xe9\x07\x6b\x72\x2a\x6e\xc0\xbd\xe7\xe2\x31\x52\x29\xed\xd8\x3a\x64\x21\x1c\x9e\x2a\xa5\x3f\xa2\x41\x06\xb1\x7c\x90\x10\x3e\xbe\x65\xcc\xf5\x52\xe9\x37\x57\x03\xfe\xab\x11\xda\x18\xa5\x87\xd8\xed\xdf\x76\xf3\xee\x1a\xb0\x5e\x07\x14\xa8\x6e\x87\x17\xca\xa1\xf2\xd8\x07\xc9\xd6\x61\x4b\x6b\x57\x5f\x30\x13\xdd\x9f\x7f\x4b\xca\x7a\x85\x6c\x50\xd0\x27\x05\xdb\xda\x25\x0d\xb2\x27\x6b\x92\x92\xcc\x5a\x2f\xd5\xe7\x9e\x3a\xaa\x23\xc4\xb6\x88\x65\xed\xc5\x6e\xe8\x3b\xa6\x59\x68\x54\x28\x84\x6c\x4f\x11\xa2\xf7\x58\x3a\xee\x65\x14\xb2\xa7\x6d\xa3\x9a\xc5\x0a\x05\x16\xc7\x45\xdf\xcf\x06\xa5\x8f\x69\x75\x87\x19\xa3\xbc\x0c\xa1\x1e\xab\xeb\xba\x1f\xe1\x77\x8a\x78\x61\x32\xc5\xa5\x08\x3c\x10\xe0\xf9\xd5\x9d\xd2\x6b\x52\x60\x03\x1b\xf4\x0e\xb2\x3f\x6f\xfe\x3c\x4e\x3e\x25\x6f\x85\x0f\xd0\x90\xe5\x53\x72\xaf\x9b\x5b\x20\xbe\xb3\x35\x67\x78\xba\x23\x63\x94\x0b\x71\x56\x2c\xfe\xf3\x9b\xeb\x7a\x7f\x19\x90\x5f\x50\xbd\xb9\x18\xbf\xd6\xc4\x18\x17\xa4\x3f\x6d\x1d\xde\xa0\x40\xc7\x74\x3f\x7f\xca\x8c\x59\xb7\xfb\xfa\x4a\x0e\x05\x26\xc1\xcd\xa1\xea\x7f\xa3\x7b\xbc\x5d\x07\x20\xbb\xf9\x98\x11\x81\x19\xb6\x71\x27\x23\x4d\x1d\x48\xf6\x90\x6c\x90\x0b\x4c\x4a\xdc\xb6\x29\x61\x26\x9e\xca\xf0\xc2\x20\x58\x84\x84\x90\x3d\xee\x75\x1f\x56\xc5\xd9\x9a\x31\xd8\x44\x2f\xb2\x13\xff\xf5\x30\xc6\xc3\x72\x86\x61\x9c\xd8\x83\x93\xc3\x55\x91\x20\x43\x75\xb4\x33\x27\x5c\x34\xb5\x8b\x7f\x6f\x90\x51\x1b\xe7\x54\x1d\xaf\xea\xf3\xd3\xa2\x69\xfe\x0d\xeb\xeb\xf8\x8f\x89\x0d\x78\xaa\xc1\x67\xed\xb3\xfb\x19\x00\x00\xff\xff\x2f\x39\x79\xd0\x6e\x0c\x00\x00")
|
||||
|
||||
func kustomizationapiSwaggerJsonBytes() ([]byte, error) {
|
||||
return bindataRead(
|
||||
_kustomizationapiSwaggerJson,
|
||||
"kustomizationapi/swagger.json",
|
||||
)
|
||||
}
|
||||
|
||||
func kustomizationapiSwaggerJson() (*asset, error) {
|
||||
bytes, err := kustomizationapiSwaggerJsonBytes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := bindataFileInfo{name: "kustomizationapi/swagger.json", size: 3182, mode: os.FileMode(420), modTime: time.Unix(1615228558, 0)}
|
||||
a := &asset{bytes: bytes, info: info}
|
||||
return a, nil
|
||||
var asset = embeddedasset.File{ //nolint:gochecknoglobals
|
||||
Path: assetName,
|
||||
Data: swaggerJSON,
|
||||
Size: int64(len(swaggerJSON)),
|
||||
Mode: 0o644,
|
||||
ModTime: time.Unix(1615228558, 0),
|
||||
}
|
||||
|
||||
// Asset loads and returns the asset for the given name.
|
||||
// It returns an error if the asset could not be found or
|
||||
// could not be loaded.
|
||||
func Asset(name string) ([]byte, error) {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
if f, ok := _bindata[cannonicalName]; ok {
|
||||
a, err := f()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err)
|
||||
}
|
||||
return a.bytes, nil
|
||||
}
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
return embeddedasset.Asset(asset, name) //nolint:wrapcheck // Preserve legacy error text.
|
||||
}
|
||||
|
||||
// MustAsset is like Asset but panics when Asset would return an error.
|
||||
// It simplifies safe initialization of global variables.
|
||||
func MustAsset(name string) []byte {
|
||||
a, err := Asset(name)
|
||||
if err != nil {
|
||||
panic("asset: Asset(" + name + "): " + err.Error())
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
func MustAsset(name string) []byte { return embeddedasset.MustAsset(asset, name) }
|
||||
|
||||
// AssetInfo loads and returns the asset info for the given name.
|
||||
// It returns an error if the asset could not be found or
|
||||
// could not be loaded.
|
||||
func AssetInfo(name string) (os.FileInfo, error) {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
if f, ok := _bindata[cannonicalName]; ok {
|
||||
a, err := f()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err)
|
||||
}
|
||||
return a.info, nil
|
||||
}
|
||||
return nil, fmt.Errorf("AssetInfo %s not found", name)
|
||||
return embeddedasset.AssetInfo(asset, name) //nolint:wrapcheck // Preserve legacy error text.
|
||||
}
|
||||
|
||||
// AssetNames returns the names of the assets.
|
||||
func AssetNames() []string {
|
||||
names := make([]string, 0, len(_bindata))
|
||||
for name := range _bindata {
|
||||
names = append(names, name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
func AssetNames() []string { return []string{assetName} }
|
||||
|
||||
// _bindata is a table, holding each asset generator, mapped to its name.
|
||||
var _bindata = map[string]func() (*asset, error){
|
||||
"kustomizationapi/swagger.json": kustomizationapiSwaggerJson,
|
||||
}
|
||||
|
||||
// AssetDir returns the file names below a certain
|
||||
// directory embedded in the file by go-bindata.
|
||||
// For example if you run go-bindata on data/... and data contains the
|
||||
// following hierarchy:
|
||||
// data/
|
||||
// foo.txt
|
||||
// img/
|
||||
// a.png
|
||||
// b.png
|
||||
// then AssetDir("data") would return []string{"foo.txt", "img"}
|
||||
// AssetDir("data/img") would return []string{"a.png", "b.png"}
|
||||
// AssetDir("foo.txt") and AssetDir("notexist") would return an error
|
||||
// AssetDir("") will return []string{"data"}.
|
||||
// AssetDir returns the file names below an embedded directory.
|
||||
func AssetDir(name string) ([]string, error) {
|
||||
node := _bintree
|
||||
if len(name) != 0 {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
pathList := strings.Split(cannonicalName, "/")
|
||||
for _, p := range pathList {
|
||||
node = node.Children[p]
|
||||
if node == nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if node.Func != nil {
|
||||
return nil, fmt.Errorf("Asset %s not found", name)
|
||||
}
|
||||
rv := make([]string, 0, len(node.Children))
|
||||
for childName := range node.Children {
|
||||
rv = append(rv, childName)
|
||||
}
|
||||
return rv, nil
|
||||
return embeddedasset.AssetDir(asset, name) //nolint:wrapcheck // Preserve legacy error text.
|
||||
}
|
||||
|
||||
type bintree struct {
|
||||
Func func() (*asset, error)
|
||||
Children map[string]*bintree
|
||||
}
|
||||
|
||||
var _bintree = &bintree{nil, map[string]*bintree{
|
||||
"kustomizationapi": &bintree{nil, map[string]*bintree{
|
||||
"swagger.json": &bintree{kustomizationapiSwaggerJson, map[string]*bintree{}},
|
||||
}},
|
||||
}}
|
||||
|
||||
// RestoreAsset restores an asset under the given directory
|
||||
// RestoreAsset restores an asset under the given directory.
|
||||
func RestoreAsset(dir, name string) error {
|
||||
data, err := Asset(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
info, err := AssetInfo(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.WriteFile(_filePath(dir, name), data, info.Mode())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
return embeddedasset.RestoreAsset(asset, dir, name) //nolint:wrapcheck // Preserve the compatibility API.
|
||||
}
|
||||
|
||||
// RestoreAssets restores an asset under the given directory recursively
|
||||
// RestoreAssets restores an asset under the given directory recursively.
|
||||
func RestoreAssets(dir, name string) error {
|
||||
children, err := AssetDir(name)
|
||||
// File
|
||||
if err != nil {
|
||||
return RestoreAsset(dir, name)
|
||||
}
|
||||
// Dir
|
||||
for _, child := range children {
|
||||
err = RestoreAssets(dir, filepath.Join(name, child))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func _filePath(dir, name string) string {
|
||||
cannonicalName := strings.Replace(name, "\\", "/", -1)
|
||||
return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...)
|
||||
return embeddedasset.RestoreAssets(asset, dir, name) //nolint:wrapcheck // Preserve the compatibility API.
|
||||
}
|
||||
|
||||
45
kyaml/openapi/kustomizationapi/swagger_test.go
Normal file
45
kyaml/openapi/kustomizationapi/swagger_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package kustomizationapi
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAssetCompatibility(t *testing.T) {
|
||||
b, err := Asset(assetName)
|
||||
require.NoError(t, err)
|
||||
if runtime.GOOS != "windows" {
|
||||
digest := sha256.Sum256(b)
|
||||
require.Equal(t, "65c5ffdbaa33a1a6db954c0e9524d5ae3ec5ef5af9d2ca17da0577868d887531",
|
||||
hex.EncodeToString(digest[:]))
|
||||
}
|
||||
info, err := AssetInfo(assetName)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(len(b)), info.Size())
|
||||
require.Equal(t, "swagger.json", info.Name())
|
||||
|
||||
_, err = Asset("kustomizationapi\\swagger.json")
|
||||
require.NoError(t, err)
|
||||
children, err := AssetDir("kustomizationapi")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"swagger.json"}, children)
|
||||
_, err = AssetDir(assetName)
|
||||
require.Error(t, err)
|
||||
|
||||
b[0] ^= 0xff
|
||||
again := MustAsset(assetName)
|
||||
require.NotEqual(t, b[0], again[0], "Asset must return a fresh byte slice")
|
||||
_, err = Asset("missing")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAssetNames(t *testing.T) {
|
||||
require.Equal(t, []string{assetName}, AssetNames())
|
||||
}
|
||||
@@ -4,20 +4,19 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
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/errors"
|
||||
"sigs.k8s.io/kustomize/kyaml/openapi/kubernetesapi"
|
||||
"sigs.k8s.io/kustomize/kyaml/openapi/kustomizationapi"
|
||||
"sigs.k8s.io/kustomize/kyaml/openapi/internal/builtinopenapi"
|
||||
"sigs.k8s.io/kustomize/kyaml/yaml"
|
||||
k8syaml "sigs.k8s.io/yaml"
|
||||
)
|
||||
@@ -80,11 +79,23 @@ type openapiData struct {
|
||||
defaultBuiltInSchemaParseStatus schemaParseStatus
|
||||
}
|
||||
|
||||
// format is retained for compatibility with the exported JsonOrYaml and Proto
|
||||
// constants. Built-in schemas are now loaded from compiled bundles instead of
|
||||
// selecting a parser with this type.
|
||||
type format string
|
||||
|
||||
const (
|
||||
// JsonOrYaml identifies the legacy JSON-or-YAML OpenAPI input format.
|
||||
//
|
||||
// Deprecated: Use AddSchema to load a custom JSON or YAML schema. Built-in
|
||||
// schemas are loaded from compiled bundles.
|
||||
JsonOrYaml format = "jsonOrYaml"
|
||||
Proto format = "proto"
|
||||
// Proto identifies the legacy protobuf OpenAPI input format.
|
||||
//
|
||||
// Deprecated: Built-in schemas are loaded from compiled bundles and no
|
||||
// longer select a protobuf parser. Use AddSchema with JSON or YAML for a
|
||||
// custom schema.
|
||||
Proto format = "proto"
|
||||
)
|
||||
|
||||
// precomputedIsNamespaceScoped precomputes IsNamespaceScoped for known types. This avoids Schema creation,
|
||||
@@ -303,7 +314,7 @@ func schemaUsingField(object *yaml.RNode, field string) (*spec.Schema, error) {
|
||||
|
||||
// AddSchema parses s, and adds definitions from s to the global schema.
|
||||
func AddSchema(s []byte) error {
|
||||
return parse(s, JsonOrYaml)
|
||||
return parse(s)
|
||||
}
|
||||
|
||||
// ResetOpenAPI resets the openapi data to empty
|
||||
@@ -579,11 +590,7 @@ func (rs *ResourceSchema) PatchStrategyAndKey() (string, string) {
|
||||
const (
|
||||
// kubernetesOpenAPIDefaultVersion is the latest version number of the statically compiled in
|
||||
// OpenAPI schema for kubernetes built-in types
|
||||
kubernetesOpenAPIDefaultVersion = kubernetesapi.DefaultOpenAPI
|
||||
|
||||
// kustomizationAPIAssetName is the name of the asset containing the statically compiled in
|
||||
// OpenAPI definitions for Kustomization built-in types
|
||||
kustomizationAPIAssetName = "kustomizationapi/swagger.json"
|
||||
kubernetesOpenAPIDefaultVersion = DefaultOpenAPI
|
||||
|
||||
// kubernetesGVKExtensionKey is the key to lookup the kubernetes group version kind extension
|
||||
// -- the extension is an array of objects containing a gvk
|
||||
@@ -639,7 +646,7 @@ func SetSchema(openAPIField map[string]string, schema []byte, reset bool) error
|
||||
if kubernetesOpenAPIVersion == "" {
|
||||
return nil
|
||||
}
|
||||
if _, ok := kubernetesapi.OpenAPIMustAsset[kubernetesOpenAPIVersion]; !ok {
|
||||
if !hasBuiltinOpenAPIVersion(kubernetesOpenAPIVersion) {
|
||||
return fmt.Errorf("the specified OpenAPI version is not built in")
|
||||
}
|
||||
|
||||
@@ -676,7 +683,7 @@ func initSchema() {
|
||||
|
||||
// TODO(natasha41575): Accept proto-formatted schema files
|
||||
if customSchema != nil {
|
||||
err := parse(customSchema, JsonOrYaml)
|
||||
err := parse(customSchema)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("invalid schema file: %w", err))
|
||||
}
|
||||
@@ -694,57 +701,99 @@ func initSchema() {
|
||||
globalSchema.defaultBuiltInSchemaParseStatus = schemaParsed
|
||||
}
|
||||
|
||||
if err := parse(kustomizationapi.MustAsset(kustomizationAPIAssetName), JsonOrYaml); err != nil {
|
||||
if err := parse(builtinKustomizationOpenAPI); err != nil {
|
||||
// this should never happen
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// parseBuiltinSchema calls parse to parse the json or proto schemas
|
||||
// parseBuiltinSchema decodes and indexes the compiled built-in schema bundle.
|
||||
func parseBuiltinSchema(version string) {
|
||||
if globalSchema.noUseBuiltInSchema {
|
||||
// don't parse the built in schema
|
||||
return
|
||||
}
|
||||
// parse the swagger, this should never fail
|
||||
assetName := filepath.Join(
|
||||
"kubernetesapi",
|
||||
strings.ReplaceAll(version, ".", "_"),
|
||||
"swagger.pb")
|
||||
|
||||
if err := parse(kubernetesapi.OpenAPIMustAsset[version](assetName), Proto); err != nil {
|
||||
if !hasBuiltinOpenAPIVersion(version) {
|
||||
panic(fmt.Errorf("the specified OpenAPI version is not built in"))
|
||||
}
|
||||
if err := parseBuiltinBundle(builtinKubernetesOpenAPIBundle); err != nil {
|
||||
// this should never happen
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// parse parses and indexes a single json or proto schema
|
||||
func parse(b []byte, format format) error {
|
||||
var swagger spec.Swagger
|
||||
switch {
|
||||
case format == Proto:
|
||||
doc := &openapi_v2.Document{}
|
||||
// We parse protobuf and get an openapi_v2.Document here.
|
||||
if err := proto.Unmarshal(b, doc); err != nil {
|
||||
return fmt.Errorf("openapi proto unmarshalling failed: %w", err)
|
||||
// parseBuiltinBundle parses and indexes the compiled built-in schema bundle.
|
||||
func parseBuiltinBundle(compressed []byte) error {
|
||||
bundle, err := decodeBuiltinBundle(compressed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
AddDefinitions(bundle.Definitions)
|
||||
if globalSchema.namespaceabilityByResourceType == nil {
|
||||
globalSchema.namespaceabilityByResourceType = make(map[yaml.TypeMeta]bool)
|
||||
}
|
||||
for _, resource := range bundle.Resources {
|
||||
typeMeta := yaml.TypeMeta{APIVersion: resource.APIVersion, Kind: resource.Kind}
|
||||
switch resource.Scope {
|
||||
case builtinopenapi.ScopeUnknown:
|
||||
case builtinopenapi.ScopeNamespaced:
|
||||
globalSchema.namespaceabilityByResourceType[typeMeta] = true
|
||||
case builtinopenapi.ScopeCluster:
|
||||
globalSchema.namespaceabilityByResourceType[typeMeta] = false
|
||||
}
|
||||
// convert the openapi_v2.Document back to Swagger
|
||||
_, err := swagger.FromGnostic(doc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func decodeBuiltinBundle(compressed []byte) (result *builtinopenapi.Bundle, retErr error) {
|
||||
reader, err := gzip.NewReader(bytes.NewReader(compressed))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err)
|
||||
}
|
||||
defer func() {
|
||||
if closeErr := reader.Close(); closeErr != nil {
|
||||
result = nil
|
||||
closeErr = fmt.Errorf("close gzip reader: %w", closeErr)
|
||||
if retErr != nil {
|
||||
retErr = fmt.Errorf("%w; %w", retErr, closeErr)
|
||||
} else {
|
||||
retErr = closeErr
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
decoder := json.NewDecoder(reader)
|
||||
var bundle builtinopenapi.Bundle
|
||||
if err := decoder.Decode(&bundle); err != nil {
|
||||
return nil, errors.Wrap(err)
|
||||
}
|
||||
// Force the gzip reader to EOF so its checksum is verified, and reject any
|
||||
// second JSON value in the artifact.
|
||||
var trailing interface{}
|
||||
if err := decoder.Decode(&trailing); err == nil {
|
||||
return nil, fmt.Errorf("built-in OpenAPI bundle contains multiple JSON values")
|
||||
} else if err != io.EOF {
|
||||
return nil, errors.Wrap(err)
|
||||
}
|
||||
if err := bundle.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("validate built-in OpenAPI bundle: %w", err)
|
||||
}
|
||||
return &bundle, nil
|
||||
}
|
||||
|
||||
// parse parses and indexes a single JSON or YAML OpenAPI schema.
|
||||
func parse(b []byte) error {
|
||||
var swagger spec.Swagger
|
||||
if len(b) > 0 && b[0] != byte('{') {
|
||||
var err error
|
||||
b, err = k8syaml.YAMLToJSON(b)
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
|
||||
case format == JsonOrYaml:
|
||||
if len(b) > 0 && b[0] != byte('{') {
|
||||
var err error
|
||||
b, err = k8syaml.YAMLToJSON(b)
|
||||
if err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
}
|
||||
if err := swagger.UnmarshalJSON(b); err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
}
|
||||
if err := swagger.UnmarshalJSON(b); err != nil {
|
||||
return errors.Wrap(err)
|
||||
}
|
||||
|
||||
AddDefinitions(swagger.Definitions)
|
||||
|
||||
@@ -4,35 +4,32 @@
|
||||
package openapi
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
openapi_v2 "github.com/google/gnostic-models/openapiv2"
|
||||
"google.golang.org/protobuf/proto"
|
||||
"sigs.k8s.io/kustomize/kyaml/openapi/kubernetesapi"
|
||||
"sigs.k8s.io/kustomize/kyaml/yaml"
|
||||
)
|
||||
|
||||
func BenchmarkProtoUnmarshal(t *testing.B) {
|
||||
version := kubernetesOpenAPIDefaultVersion
|
||||
|
||||
// parse the swagger, this should never fail
|
||||
assetName := filepath.Join(
|
||||
"kubernetesapi",
|
||||
strings.ReplaceAll(version, ".", "_"),
|
||||
"swagger.pb")
|
||||
|
||||
b := kubernetesapi.OpenAPIMustAsset[version](assetName)
|
||||
|
||||
for i := 0; i < t.N; i++ {
|
||||
// We parse protobuf and get an openapiv2.Document here.
|
||||
if err := proto.Unmarshal(b, &openapi_v2.Document{}); err != nil {
|
||||
t.Fatalf("proto.Unmarshal failed: %v", err)
|
||||
func BenchmarkBuiltinBundleDecode(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := decodeBuiltinBundle(builtinKubernetesOpenAPIBundle); err != nil {
|
||||
b.Fatalf("decodeBuiltinBundle failed: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkFirstSchemaLookup(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
typeMeta := yaml.TypeMeta{APIVersion: "apps/v1", Kind: "Deployment"}
|
||||
for i := 0; i < b.N; i++ {
|
||||
ResetOpenAPI()
|
||||
if schema := SchemaForResourceType(typeMeta); schema == nil {
|
||||
b.Fatal("Deployment schema was not found")
|
||||
}
|
||||
}
|
||||
ResetOpenAPI()
|
||||
}
|
||||
|
||||
func BenchmarkPrecomputedIsNamespaceScoped(b *testing.B) {
|
||||
testcases := map[string]yaml.TypeMeta{
|
||||
"namespace scoped": {APIVersion: "apps/v1", Kind: "ControllerRevision"},
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Copyright 2020 The Kubernetes Authors.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
MYGOBIN=$(go env GOBIN)
|
||||
MYGOBIN="${MYGOBIN:-$(go env GOPATH)/bin}"
|
||||
VERSION=$1
|
||||
|
||||
cp $HOME/.kube/config /tmp/kubeconfig.txt | true
|
||||
$MYGOBIN/kind create cluster --image kindest/node:$VERSION --name=getopenapidata
|
||||
|
||||
# TODO (natasha41575) Add a `kustomize openapi fetch --proto` option
|
||||
kubectl proxy &
|
||||
sleep 5
|
||||
curl -k -H "Accept: application/com.github.proto-openapi.spec.v2@v1.0+protobuf" http://localhost:8001/openapi/v2 > /tmp/new_swagger.pb
|
||||
|
||||
$MYGOBIN/kind delete cluster --name=getopenapidata
|
||||
cp /tmp/kubeconfig.txt $HOME/.kube/config | true
|
||||
mkdir -p kubernetesapi/"${VERSION//./_}"
|
||||
cp /tmp/new_swagger.pb kubernetesapi/"${VERSION//./_}"/swagger.pb
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Copyright 2020 The Kubernetes Authors.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
MYGOBIN=$(go env GOBIN)
|
||||
MYGOBIN="${MYGOBIN:-$(go env GOPATH)/bin}"
|
||||
VERSION=$1
|
||||
|
||||
$MYGOBIN/go-bindata \
|
||||
--pkg "${VERSION//./_}" \
|
||||
-o kubernetesapi/"${VERSION//./_}"/swagger.go \
|
||||
kubernetesapi/"${VERSION//./_}"/swagger.pb
|
||||
@@ -1,81 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Copyright 2020 The Kubernetes Authors.
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
# This will read from the directory kubernetesapi
|
||||
# and use subdirectory names to generate
|
||||
# kubernetesapi/openapiinfo.go
|
||||
#
|
||||
# This script should only be run after the
|
||||
# swagger.json and swagger.go files are generated.
|
||||
|
||||
set -e
|
||||
|
||||
if ! command -v jq &> /dev/null ; then
|
||||
echo Please install jq
|
||||
echo on ubuntu: sudo apt-get install jq
|
||||
exit 1
|
||||
fi
|
||||
|
||||
info_list=()
|
||||
version_list=()
|
||||
|
||||
V=`ls kubernetesapi | grep v.*`
|
||||
for VERSION in $V
|
||||
do
|
||||
openapiinfo="{title:Kubernetes,version:${VERSION//_/.}}"
|
||||
info_list+=( $openapiinfo )
|
||||
version_list+=( ${VERSION} )
|
||||
done
|
||||
|
||||
|
||||
# add imports to openapiinfo.go
|
||||
cat <<EOF >kubernetesapi/openapiinfo.go
|
||||
// Copyright 2020 The Kubernetes Authors.
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
// Code generated by $0; DO NOT EDIT.
|
||||
|
||||
package kubernetesapi
|
||||
|
||||
import (
|
||||
EOF
|
||||
|
||||
for version in ${version_list[@]}
|
||||
do
|
||||
cat <<EOF >>kubernetesapi/openapiinfo.go
|
||||
"sigs.k8s.io/kustomize/kyaml/openapi/kubernetesapi/$version"
|
||||
EOF
|
||||
done
|
||||
|
||||
# add info string for `kustomize openapi info` command
|
||||
OPEN_API_INFO=`echo ${info_list[@]} | sed 's/ /\\\n/g'`
|
||||
cat <<EOF >>kubernetesapi/openapiinfo.go
|
||||
)
|
||||
|
||||
const Info = "$OPEN_API_INFO"
|
||||
EOF
|
||||
|
||||
# add map for `initSchema` in openapi.go to use
|
||||
cat <<EOF >>kubernetesapi/openapiinfo.go
|
||||
|
||||
var OpenAPIMustAsset = map[string]func(string)[]byte{
|
||||
EOF
|
||||
|
||||
latest=""
|
||||
for version in ${version_list[@]}
|
||||
do
|
||||
latest=$version
|
||||
cat <<EOF >>kubernetesapi/openapiinfo.go
|
||||
"${version//_/.}": $version.MustAsset,
|
||||
EOF
|
||||
done
|
||||
|
||||
# add latest version to be used as a default
|
||||
cat <<EOF >>kubernetesapi/openapiinfo.go
|
||||
}
|
||||
|
||||
const DefaultOpenAPI = "${latest//_/.}"
|
||||
EOF
|
||||
|
||||
gofmt -s -w kubernetesapi/openapiinfo.go
|
||||
Reference in New Issue
Block a user