Fix: Sanitize v2 schema (#1353)

Signed-off-by: Andrei Kvapil <kvapss@gmail.com>

<!-- Thank you for making a contribution! Here are some tips for you:
- Start the PR title with the [label] of Cozystack component:
- For system components: [platform], [system], [linstor], [cilium],
[kube-ovn], [dashboard], [cluster-api], etc.
- For managed apps: [apps], [tenant], [kubernetes], [postgres],
[virtual-machine] etc.
- For development and maintenance: [tests], [ci], [docs], [maintenance].
- If it's a work in progress, consider creating this PR as a draft.
- Don't hesistate to ask for opinion and review in the community chats,
even if it's still a draft.
- Add the label `backport` if it's a bugfix that needs to be backported
to a previous version.
-->

## What this PR does

fixes
https://github.com/cozystack/cozystack/issues/1352#issuecomment-3210026159

### Release note

<!--  Write a release note:
- Explain what has changed internally and for users.
- Start with the same [label] as in the PR title
- Follow the guidelines at
https://github.com/kubernetes/community/blob/master/contributors/guide/release-notes.md.
-->

```release-note
[cozystack-api] Fix: Sanitize v2 schema
```

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- Bug Fixes
- Improved OpenAPI/Swagger v2 compatibility by normalizing schemas
(handle int-or-string patterns, remove unsupported oneOf/anyOf, and fix
empty additionalProperties), producing more consistent v2-compliant
definitions.

- Refactor
- Added internal v2 post-processing to sanitize schemas across all
definitions without changing public APIs.

- Tests
- Added end-to-end OpenAPI tests validating v2, v3 and protobuf v2
endpoints and integrated them into the test suite.

- CI
- Added an OpenAPI test step to the pull-request workflow so OpenAPI
tests run during CI.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Andrei Kvapil
2025-08-26 14:34:16 +02:00
committed by GitHub
4 changed files with 106 additions and 14 deletions

View File

@@ -254,6 +254,11 @@ jobs:
done
echo "✅ The task completed successfully after $attempt attempts."
- name: Run OpenAPI tests
run: |
cd /tmp/$SANDBOX_NAME
make -C packages/core/testing SANDBOX_NAME=$SANDBOX_NAME test-openapi
detect_test_matrix:
name: "Detect e2e test matrix"
runs-on: ubuntu-latest

View File

@@ -0,0 +1,20 @@
#!/usr/bin/env bats
# -----------------------------------------------------------------------------
# Test OpenAPI endpoints in a Kubernetes cluster
# -----------------------------------------------------------------------------
@test "Test OpenAPI v2 endpoint" {
kubectl get -v7 --raw '/openapi/v2?timeout=32s' > /dev/null
}
@test "Test OpenAPI v3 endpoint" {
kubectl get -v7 --raw '/openapi/v3/apis/apps.cozystack.io/v1alpha1' > /dev/null
}
@test "Test OpenAPI v2 endpoint (protobuf)" {
(
kubectl proxy --port=21234 & sleep 0.5
trap "kill $!" EXIT
curl -sS --fail 'http://localhost:21234/openapi/v2?timeout=32s' -H 'Accept: application/com.github.proto-openapi.spec.v2@v1.0+protobuf' > /dev/null
)
}

View File

@@ -30,7 +30,7 @@ image-e2e-sandbox:
yq -i '.e2e.image = strenv(IMAGE)' values.yaml
rm -f images/e2e-sandbox.json
test: test-cluster test-apps ## Run the end-to-end tests in existing sandbox
test: test-cluster test-openapi test-apps ## Run the end-to-end tests in existing sandbox
copy-nocloud-image:
docker cp ../../../_out/assets/nocloud-amd64.raw.xz "${SANDBOX_NAME}":/workspace/_out/assets/nocloud-amd64.raw.xz
@@ -47,6 +47,9 @@ install-cozystack: copy-installer-manifest
test-cluster: copy-nocloud-image copy-installer-manifest ## Run the end-to-end for creating a cluster
docker exec "${SANDBOX_NAME}" sh -c 'cd /workspace && hack/cozytest.sh hack/e2e-cluster.bats'
test-openapi:
docker exec "${SANDBOX_NAME}" sh -c 'cd /workspace && hack/cozytest.sh hack/e2e-test-openapi.bats'
test-apps-%:
docker exec "${SANDBOX_NAME}" sh -c 'cd /workspace && hack/cozytest.sh hack/e2e-apps/$*.bats'

View File

@@ -65,7 +65,7 @@ func patchSpec(target *spec.Schema, raw string) error {
target.Properties = map[string]spec.Schema{}
}
prop := target.Properties["spec"]
prop.AdditionalProperties = &spec.SchemaOrBool{Allows: true, Schema: &spec.Schema{}}
prop.AdditionalProperties = &spec.SchemaOrBool{Allows: true}
target.Properties["spec"] = prop
return nil
}
@@ -75,7 +75,7 @@ func patchSpec(target *spec.Schema, raw string) error {
return err
}
if custom.AdditionalProperties == nil {
custom.AdditionalProperties = &spec.SchemaOrBool{Allows: true, Schema: &spec.Schema{}}
custom.AdditionalProperties = &spec.SchemaOrBool{Allows: true}
}
if target.Properties == nil {
target.Properties = map[string]spec.Schema{}
@@ -271,13 +271,70 @@ func buildPostProcessV3(kindSchemas map[string]string) func(*spec3.OpenAPI) (*sp
}
}
// hasIntAndStringAnyOf returns true if anyOf is exactly a combination of string and integer.
func hasIntAndStringAnyOf(anyOf []spec.Schema) bool {
seen := map[string]bool{}
for i := range anyOf {
for _, t := range anyOf[i].Type {
seen[t] = true
}
}
return seen["string"] && seen["integer"] && len(seen) <= 2
}
// sanitizeForV2 removes unsupported constructs for Swagger v2 and normalizes common patterns.
func sanitizeForV2(s *spec.Schema) {
if s == nil {
return
}
if len(s.AnyOf) > 0 {
if hasIntAndStringAnyOf(s.AnyOf) {
s.Type = spec.StringOrArray{"string"}
if s.Extensions == nil {
s.Extensions = map[string]interface{}{}
}
s.Extensions["x-kubernetes-int-or-string"] = true
}
s.AnyOf = nil
}
if len(s.OneOf) > 0 {
s.OneOf = nil
}
if s.AdditionalProperties != nil {
ap := s.AdditionalProperties
if ap.Schema != nil {
sanitizeForV2(ap.Schema)
}
}
for k := range s.Properties {
prop := s.Properties[k]
sanitizeForV2(&prop)
s.Properties[k] = prop
}
if s.Items != nil {
if s.Items.Schema != nil {
sanitizeForV2(s.Items.Schema)
}
for i := range s.Items.Schemas {
sanitizeForV2(&s.Items.Schemas[i])
}
}
for i := range s.AllOf {
sanitizeForV2(&s.AllOf[i])
}
}
// -----------------------------------------------------------------------------
// OpenAPI **v2** (swagger) post-processor
// -----------------------------------------------------------------------------
func buildPostProcessV2(kindSchemas map[string]string) func(*spec.Swagger) (*spec.Swagger, error) {
return func(sw *spec.Swagger) (*spec.Swagger, error) {
// Get base schemas
defs := sw.Definitions
base, ok1 := defs[baseRef]
list, ok2 := defs[baseListRef]
@@ -286,28 +343,26 @@ func buildPostProcessV2(kindSchemas map[string]string) func(*spec.Swagger) (*spe
return sw, fmt.Errorf("base Application* schemas not found")
}
// Clone base schemas for each kind
for kind, raw := range kindSchemas {
ref := apiPrefix + "." + kind
statusRef := ref + "Status"
listRef := ref + "List"
obj, status, l := cloneKindSchemas(kind, &base, &stat, &list /*v3=*/, false)
defs[ref] = *obj
defs[statusRef] = *status
defs[listRef] = *l
obj, status, l := cloneKindSchemas(kind, &base, &stat, &list, false)
if err := patchSpec(obj, raw); err != nil {
return nil, fmt.Errorf("kind %s: %w", kind, err)
}
defs[ref] = *obj
defs[statusRef] = *status
defs[listRef] = *l
}
// Delete base schemas
delete(defs, baseRef)
delete(defs, baseListRef)
delete(defs, baseStatusRef)
// Disable strategic-merge-patch+json
for p, op := range sw.Paths.Paths {
if op.Patch != nil && len(op.Patch.Consumes) > 0 {
var out []string
@@ -321,11 +376,20 @@ func buildPostProcessV2(kindSchemas map[string]string) func(*spec.Swagger) (*spe
}
}
// Rewrite all $ref in the document
out, err := rewriteDocRefs(sw)
if err != nil {
return nil, err
}
return sw, json.Unmarshal(out, sw)
if err := json.Unmarshal(out, sw); err != nil {
return nil, err
}
for name := range sw.Definitions {
s := sw.Definitions[name]
sanitizeForV2(&s)
sw.Definitions[name] = s
}
return sw, nil
}
}