diff --git a/.github/workflows/pull-requests.yaml b/.github/workflows/pull-requests.yaml index d8659636..909111b5 100644 --- a/.github/workflows/pull-requests.yaml +++ b/.github/workflows/pull-requests.yaml @@ -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 diff --git a/hack/e2e-test-openapi.bats b/hack/e2e-test-openapi.bats new file mode 100644 index 00000000..7e3cc992 --- /dev/null +++ b/hack/e2e-test-openapi.bats @@ -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 + ) +} diff --git a/packages/core/testing/Makefile b/packages/core/testing/Makefile index f597d5ce..1e7fea3c 100755 --- a/packages/core/testing/Makefile +++ b/packages/core/testing/Makefile @@ -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' diff --git a/pkg/cmd/server/openapi.go b/pkg/cmd/server/openapi.go index 6ba18b50..aade9a80 100644 --- a/pkg/cmd/server/openapi.go +++ b/pkg/cmd/server/openapi.go @@ -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 } }