From 7bc13970bc6096353a69ba705874f7ca68a88540 Mon Sep 17 00:00:00 2001 From: Joel Speed Date: Fri, 26 Jun 2026 12:39:58 +0100 Subject: [PATCH] Integrate models-schema into codegen and fix openapi verify issues --- Makefile | 2 +- hack/update-openapi.sh | 7 +- openapi/cmd/models-schema/main.go | 76 -------- openapi/openapi.json | 229 ++++++++++++++++++++++++- tools/codegen/pkg/openapi/generator.go | 2 +- tools/codegen/pkg/openapi/openapi.go | 227 ++++++++++++++++++++++-- 6 files changed, 440 insertions(+), 103 deletions(-) delete mode 100644 openapi/cmd/models-schema/main.go diff --git a/Makefile b/Makefile index 8b85144eafa..e0e97ed1ac0 100644 --- a/Makefile +++ b/Makefile @@ -220,7 +220,7 @@ write-available-featuresets: .PHONY: clean clean: - rm -f render write-available-featuresets models-schema + rm -f render write-available-featuresets rm -rf tools/_output VERSION ?= $(shell git describe --always --abbrev=7) diff --git a/hack/update-openapi.sh b/hack/update-openapi.sh index fd35ece5e6f..c2ddebd73b1 100755 --- a/hack/update-openapi.sh +++ b/hack/update-openapi.sh @@ -6,8 +6,5 @@ source "$(dirname "${BASH_SOURCE}")/lib/init.sh" output_path="${OUTPUT_PATH:-openapi}" output_package="${SCRIPT_ROOT}/${output_path}" -GENERATOR=openapi EXTRA_ARGS=--openapi:output-package-path=${output_path}/generated_openapi ${SCRIPT_ROOT}/hack/update-codegen.sh - -go build github.com/openshift/api/openapi/cmd/models-schema - -./models-schema | jq '.' > ${output_package}/openapi.json +# Generate both Go and JSON OpenAPI schemas using the integrated codegen tool +GENERATOR=openapi EXTRA_ARGS=--openapi:output-package-path=${output_path} ${SCRIPT_ROOT}/hack/update-codegen.sh diff --git a/openapi/cmd/models-schema/main.go b/openapi/cmd/models-schema/main.go deleted file mode 100644 index 9c81c715476..00000000000 --- a/openapi/cmd/models-schema/main.go +++ /dev/null @@ -1,76 +0,0 @@ -package main - -import ( - "encoding/json" - "fmt" - "os" - "strings" - - "github.com/openshift/api/openapi/generated_openapi" - "k8s.io/kube-openapi/pkg/common" - "k8s.io/kube-openapi/pkg/validation/spec" -) - -// Outputs openAPI schema JSON containing the schema definitions in zz_generated.openapi.go. -// pulled from model_schema command of k/k -func main() { - err := output() - if err != nil { - os.Stderr.WriteString(fmt.Sprintf("Failed: %v", err)) - os.Exit(1) - } -} - -func output() error { - refFunc := func(name string) spec.Ref { - return spec.MustCreateRef(fmt.Sprintf("#/definitions/%s", name)) - } - defs := generated_openapi.GetOpenAPIDefinitions(refFunc) - schemaDefs := make(map[string]spec.Schema, len(defs)) - for k, v := range defs { - // Replace top-level schema with v2 if a v2 schema is embedded - // so that the output of this program is always in OpenAPI v2. - // This is done by looking up an extension that marks the embedded v2 - // schema, and, if the v2 schema is found, make it the resulting schema for - // the type. - if schema, ok := v.Schema.Extensions[common.ExtensionV2Schema]; ok { - if v2Schema, isOpenAPISchema := schema.(spec.Schema); isOpenAPISchema { - schemaDefs[k] = v2Schema - continue - } - } - - schemaDefs[k] = v.Schema - } - data, err := json.Marshal(&spec.Swagger{ - SwaggerProps: spec.SwaggerProps{ - Definitions: schemaDefs, - Info: &spec.Info{ - InfoProps: spec.InfoProps{ - Title: "Kubernetes", - Version: "unversioned", - }, - }, - Swagger: "2.0", - }, - }) - if err != nil { - return fmt.Errorf("error serializing api definitions: %w", err) - } - os.Stdout.Write(data) - return nil -} - -// From vendor/k8s.io/apiserver/pkg/endpoints/openapi/openapi.go -func friendlyName(name string) string { - nameParts := strings.Split(name, "/") - // Reverse first part. e.g., io.k8s... instead of k8s.io... - if len(nameParts) > 0 && strings.Contains(nameParts[0], ".") { - parts := strings.Split(nameParts[0], ".") - for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { - parts[i], parts[j] = parts[j], parts[i] - } - nameParts[0] = strings.Join(parts, ".") - } - return strings.Join(nameParts, ".") -} diff --git a/openapi/openapi.json b/openapi/openapi.json index b3a2453c2de..abd84cb12bc 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -1,7 +1,7 @@ { "swagger": "2.0", "info": { - "title": "Kubernetes", + "title": "OpenShift API", "version": "unversioned" }, "paths": null, @@ -5271,6 +5271,100 @@ } } }, + "com.github.openshift.api.config.v1.CRIOCredentialProviderConfig": { + "description": "CRIOCredentialProviderConfig holds cluster-wide singleton resource configurations for CRI-O credential provider, the name of this instance is \"cluster\". CRI-O credential provider is a binary shipped with CRI-O that provides a way to obtain container image pull credentials from external sources. For example, it can be used to fetch mirror registry credentials from secrets resources in the cluster within the same namespace the pod will be running in. CRIOCredentialProviderConfig configuration specifies the pod image sources registries that should trigger the CRI-O credential provider execution, which will resolve the CRI-O mirror configurations and obtain the necessary credentials for pod creation. Note: Configuration changes will only take effect after the kubelet restarts, which is automatically managed by the cluster during rollout.\n\nThe resource is a singleton named \"cluster\".\n\nCompatibility level 1: Stable within a major release for a minimum of 12 months or 3 minor releases (whichever is longer).", + "type": "object", + "required": [ + "spec" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "description": "metadata is the standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + "default": {}, + "$ref": "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + }, + "spec": { + "description": "spec defines the desired configuration of the CRI-O Credential Provider. This field is required and must be provided when creating the resource.", + "$ref": "#/definitions/com.github.openshift.api.config.v1.CRIOCredentialProviderConfigSpec" + }, + "status": { + "description": "status represents the current state of the CRIOCredentialProviderConfig. When omitted or nil, it indicates that the status has not yet been set by the controller. The controller will populate this field with validation conditions and operational state.", + "default": {}, + "$ref": "#/definitions/com.github.openshift.api.config.v1.CRIOCredentialProviderConfigStatus" + } + } + }, + "com.github.openshift.api.config.v1.CRIOCredentialProviderConfigList": { + "description": "CRIOCredentialProviderConfigList contains a list of CRIOCredentialProviderConfig resources\n\nCompatibility level 1: Stable within a major release for a minimum of 12 months or 3 minor releases (whichever is longer).", + "type": "object", + "required": [ + "metadata", + "items" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {}, + "$ref": "#/definitions/com.github.openshift.api.config.v1.CRIOCredentialProviderConfig" + } + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "description": "metadata is the standard list's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + "default": {}, + "$ref": "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + } + }, + "com.github.openshift.api.config.v1.CRIOCredentialProviderConfigSpec": { + "description": "CRIOCredentialProviderConfigSpec defines the desired configuration of the CRI-O Credential Provider.", + "type": "object", + "properties": { + "matchImages": { + "description": "matchImages is a list of string patterns used to determine whether the CRI-O credential provider should be invoked for a given image. This list is passed to the kubelet CredentialProviderConfig, and if any pattern matches the requested image, CRI-O credential provider will be invoked to obtain credentials for pulling that image or its mirrors. Depending on the platform, the CRI-O credential provider may be installed alongside an existing platform specific provider. Conflicts between the existing platform specific provider image match configuration and this list will be handled by the following precedence rule: credentials from built-in kubelet providers (e.g., ECR, GCR, ACR) take precedence over those from the CRIOCredentialProviderConfig when both match the same image. To avoid uncertainty, it is recommended to avoid configuring your private image patterns to overlap with existing platform specific provider config(e.g., the entries from https://github.com/openshift/machine-config-operator/blob/main/templates/common/aws/files/etc-kubernetes-credential-providers-ecr-credential-provider.yaml). You can check the resource's Status conditions to see if any entries were ignored due to exact matches with known built-in provider patterns.\n\nThis field is optional, the items of the list must contain between 1 and 50 entries. The list is treated as a set, so duplicate entries are not allowed.\n\nFor more details, see: https://kubernetes.io/docs/tasks/administer-cluster/kubelet-credential-provider/ https://github.com/cri-o/crio-credential-provider#architecture\n\nEach entry in matchImages is a pattern which can optionally contain a port and a path. Each entry must be no longer than 512 characters. Wildcards ('*') are supported for full subdomain labels, such as '*.k8s.io' or 'k8s.*.io', and for top-level domains, such as 'k8s.*' (which matches 'k8s.io' or 'k8s.net'). A global wildcard '*' (matching any domain) is not allowed. Wildcards may replace an entire hostname label (e.g., *.example.com), but they cannot appear within a label (e.g., f*oo.example.com) and are not allowed in the port or path. For example, 'example.*.com' is valid, but 'exa*mple.*.com' is not. Each wildcard matches only a single domain label, so '*.io' does **not** match '*.k8s.io'.\n\nA match exists between an image and a matchImage when all of the below are true: Both contain the same number of domain parts and each part matches. The URL path of an matchImages must be a prefix of the target image URL path. If the matchImages contains a port, then the port must match in the image as well.\n\nExample values of matchImages: - 123456789.dkr.ecr.us-east-1.amazonaws.com - *.azurecr.io - gcr.io - *.*.registry.io - registry.io:8080/path", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "set" + } + } + }, + "com.github.openshift.api.config.v1.CRIOCredentialProviderConfigStatus": { + "description": "CRIOCredentialProviderConfigStatus defines the observed state of CRIOCredentialProviderConfig", + "type": "object", + "properties": { + "conditions": { + "description": "conditions represent the latest available observations of the configuration state. When omitted, it indicates that no conditions have been reported yet. The maximum number of conditions is 16. Conditions are stored as a map keyed by condition type, ensuring uniqueness.\n\nExpected condition types include: \"Validated\": indicates whether the matchImages configuration is valid", + "type": "array", + "items": { + "default": {}, + "$ref": "#/definitions/io.k8s.apimachinery.pkg.apis.meta.v1.Condition" + }, + "x-kubernetes-list-map-keys": [ + "type" + ], + "x-kubernetes-list-type": "map" + } + } + }, "com.github.openshift.api.config.v1.CertInfo": { "description": "CertInfo relates a certificate with a private key", "type": "object", @@ -8418,7 +8512,7 @@ "$ref": "#/definitions/com.github.openshift.api.config.v1.ConfigMapFileReference" }, "controlPlaneTopology": { - "description": "controlPlaneTopology expresses the desired topology configuration for control nodes. The 'HighlyAvailable' mode represents a \"normal\", 3 control node cluster. The 'SingleReplica' mode represents configuration where there is a single control node. If left blank, no change is required and no transitions will be triggered.", + "description": "controlPlaneTopology expresses the desired topology configuration for control nodes.\n\nWhen status.controlPlaneTopology is 'SingleReplica' and spec.controlPlaneTopology is set to 'HighlyAvailable', a transition will be triggered to reconfigure the cluster from SingleReplica to HighlyAvailable.\n\nWhen left blank or status.controlPlaneTopology and spec.controlPlaneTopology are the same value, no changes are required and no transitions will be triggered.\n\nThis value may be set to match status.controlPlaneTopology regardless of the current value.", "type": "string" }, "platformSpec": { @@ -30498,6 +30592,14 @@ }, "x-kubernetes-list-type": "atomic" }, + "protocol": { + "description": "protocol specifies whether the Network Load Balancer uses PROXY protocol to forward connections to the IngressController.\n\nWhen set to \"TCP\", the NLB uses AWS's native client IP preservation. This may cause hairpin connection failures for internal load balancers when connections are made from pods to router pods on the same node.\n\nWhen set to \"PROXY\", the NLB disables native client IP preservation and uses PROXY protocol v2. The IngressController enables PROXY protocol on HAProxy so that it can parse PROXY protocol headers to obtain the original client IP. This avoids hairpin connection failures.\n\nThe following values are valid for this field:\n\n* \"TCP\". * \"PROXY\".\n\nWhen omitted, this means the user has no opinion and the value is left to the platform to choose a reasonable default, which is subject to change over time. The current default is \"PROXY\".\n\nNote that changing this field may cause brief connection failures during the transition as the NLB attribute change and router rollout occur independently.\n\n\nPossible enum values:\n - `\"PROXY\"` instructs the NLB to forward connections using PROXY protocol v2.\n - `\"TCP\"` instructs the NLB to forward connections using TCP without PROXY protocol.", + "type": "string", + "enum": [ + "PROXY", + "TCP" + ] + }, "subnets": { "description": "subnets specifies the subnets to which the load balancer will attach. The subnets may be specified by either their ID or name. The total number of subnets is limited to 10.\n\nIn order for the load balancer to be provisioned with subnets, each subnet must exist, each subnet must be from a different availability zone, and the load balancer service must be recreated to pick up new values.\n\nWhen omitted from the spec, the subnets will be auto-discovered for each availability zone. Auto-discovered subnets are not reported in the status of the IngressController object.", "$ref": "#/definitions/com.github.openshift.api.operator.v1.AWSSubnets" @@ -30903,7 +31005,7 @@ "$ref": "#/definitions/com.github.openshift.api.operator.v1.AzureCSIDriverConfigSpec" }, "driverType": { - "description": "driverType indicates type of CSI driver for which the driverConfig is being applied to. Valid values are: AWS, Azure, GCP, IBMCloud, vSphere and omitted. Consumers should treat unknown values as a NO-OP.", + "description": "driverType indicates type of CSI driver for which the driverConfig is being applied to. Valid values are: AWS, Azure, GCP, IBMCloud, vSphere, SecretsStore and omitted. Consumers should treat unknown values as a NO-OP.", "type": "string", "default": "" }, @@ -30915,6 +31017,11 @@ "description": "ibmcloud is used to configure the IBM Cloud CSI driver.", "$ref": "#/definitions/com.github.openshift.api.operator.v1.IBMCloudCSIDriverConfigSpec" }, + "secretsStore": { + "description": "secretsStore is used to configure the Secrets Store CSI driver.", + "default": {}, + "$ref": "#/definitions/com.github.openshift.api.operator.v1.SecretsStoreCSIDriverConfigSpec" + }, "vSphere": { "description": "vSphere is used to configure the vsphere CSI driver.", "$ref": "#/definitions/com.github.openshift.api.operator.v1.VSphereCSIDriverConfigSpec" @@ -30928,6 +31035,7 @@ "azure": "Azure", "gcp": "GCP", "ibmcloud": "IBMCloud", + "secretsStore": "SecretsStore", "vSphere": "VSphere" } } @@ -31963,6 +32071,17 @@ } } }, + "com.github.openshift.api.operator.v1.CustomSecretRotation": { + "description": "CustomSecretRotation holds configuration for custom secret rotation behavior.", + "type": "object", + "properties": { + "rotationPollIntervalSeconds": { + "description": "rotationPollIntervalSeconds is the minimum time in seconds between secret rotation attempts. The driver skips provider calls if less than this interval has elapsed since the last successful rotation. Must be at least 1 second and no more than 31560000 seconds (~1 year). When omitted, this means no opinion and the platform is left to choose a reasonable default, which is subject to change over time.", + "type": "integer", + "format": "int32" + } + } + }, "com.github.openshift.api.operator.v1.DNS": { "description": "DNS manages the CoreDNS component to provide a name resolution service for pods and services in the cluster.\n\nThis supports the DNS-based service discovery specification: https://github.com/kubernetes/dns/blob/master/docs/specification.md\n\nMore details: https://kubernetes.io/docs/tasks/administer-cluster/coredns\n\nCompatibility level 1: Stable within a major release for a minimum of 12 months or 3 minor releases (whichever is longer).", "type": "object", @@ -34908,6 +35027,24 @@ } } }, + "com.github.openshift.api.operator.v1.ManagedTokenRequests": { + "description": "ManagedTokenRequests holds the configuration for operator-managed service account token requests.", + "type": "object", + "properties": { + "audiences": { + "description": "audiences specifies service account token audiences that kubelet will provide to the CSI driver during NodePublishVolume calls. These tokens enable workload identity federation (WIF) with cloud providers such as AWS, Azure, and GCP. When empty, the operator clears all tokenRequests from the CSIDriver object.", + "type": "array", + "items": { + "default": {}, + "$ref": "#/definitions/com.github.openshift.api.operator.v1.SecretsStoreTokenRequest" + }, + "x-kubernetes-list-map-keys": [ + "audience" + ], + "x-kubernetes-list-type": "map" + } + } + }, "com.github.openshift.api.operator.v1.MyOperatorResource": { "description": "MyOperatorResource is an example operator configuration type\n\nCompatibility level 4: No compatibility is provided, the API can change at any point for any reason. These capabilities should not be used by applications needing long term support.", "type": "object", @@ -36627,6 +36764,92 @@ } } }, + "com.github.openshift.api.operator.v1.SecretsStoreCSIDriverConfigSpec": { + "description": "SecretsStoreCSIDriverConfigSpec defines properties that can be configured for the Secrets Store CSI driver.", + "type": "object", + "properties": { + "secretRotation": { + "description": "secretRotation controls automatic secret rotation behavior. When omitted, secret rotation is enabled with a default poll interval of 2 minutes.", + "default": {}, + "$ref": "#/definitions/com.github.openshift.api.operator.v1.SecretsStoreSecretRotation" + }, + "tokenRequests": { + "description": "tokenRequests controls service account token configuration for workload identity federation (WIF) with cloud providers. When omitted, the operator preserves any existing tokenRequests already configured on the CSIDriver object without modification.", + "default": {}, + "$ref": "#/definitions/com.github.openshift.api.operator.v1.SecretsStoreTokenRequests" + } + } + }, + "com.github.openshift.api.operator.v1.SecretsStoreSecretRotation": { + "description": "SecretsStoreSecretRotation configures the automatic secret rotation behavior for the Secrets Store CSI driver.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "custom": { + "description": "custom holds the custom rotation configuration. Only valid when type is \"Custom\".", + "default": {}, + "$ref": "#/definitions/com.github.openshift.api.operator.v1.CustomSecretRotation" + }, + "type": { + "description": "type determines the secret rotation behavior. When \"None\", secret rotation is disabled and secrets are only fetched at initial pod mount time. When \"Custom\", secret rotation is enabled with the configuration specified in the custom field.", + "type": "string" + } + }, + "x-kubernetes-unions": [ + { + "discriminator": "type", + "fields-to-discriminateBy": { + "custom": "Custom" + } + } + ] + }, + "com.github.openshift.api.operator.v1.SecretsStoreTokenRequest": { + "description": "SecretsStoreTokenRequest specifies a service account token audience configuration for workload identity federation (WIF) with the Secrets Store CSI driver.", + "type": "object", + "required": [ + "audience" + ], + "properties": { + "audience": { + "description": "audience is the intended audience of the service account token. An empty string means the issued token will use the kube-apiserver's default APIAudiences.", + "type": "string" + }, + "expirationSeconds": { + "description": "expirationSeconds is the requested duration of validity of the service account token. The token issuer may return a token with a different validity duration. When omitted, the token expiration is determined by the kube-apiserver. Must be at least 600 seconds (10 minutes) and no more than 315360000 seconds (~10 years).", + "type": "integer", + "format": "int32" + } + } + }, + "com.github.openshift.api.operator.v1.SecretsStoreTokenRequests": { + "description": "SecretsStoreTokenRequests configures how service account tokens are provided to the Secrets Store CSI driver for workload identity federation.", + "type": "object", + "required": [ + "type" + ], + "properties": { + "managed": { + "description": "managed holds configuration for operator-managed tokenRequests. Only valid when type is \"Managed\".", + "default": {}, + "$ref": "#/definitions/com.github.openshift.api.operator.v1.ManagedTokenRequests" + }, + "type": { + "description": "type determines how the operator manages tokenRequests on the CSIDriver object. When \"Unmanaged\", existing tokenRequests on the CSIDriver are preserved and the managed field is not used. When \"Managed\", the operator sets tokenRequests from the audiences specified in the managed field, replacing any previously configured values. Once set to \"Managed\", type cannot be reverted back to \"Unmanaged\".", + "type": "string" + } + }, + "x-kubernetes-unions": [ + { + "discriminator": "type", + "fields-to-discriminateBy": { + "managed": "Managed" + } + } + ] + }, "com.github.openshift.api.operator.v1.Server": { "description": "Server defines the schema for a server that runs per instance of CoreDNS.", "type": "object", diff --git a/tools/codegen/pkg/openapi/generator.go b/tools/codegen/pkg/openapi/generator.go index 3aa6b5b5bf7..cc0888e57c4 100644 --- a/tools/codegen/pkg/openapi/generator.go +++ b/tools/codegen/pkg/openapi/generator.go @@ -18,7 +18,7 @@ const ( var ( // DefaultOutputPackagePath is the default output package path for the generated openapi functions. - DefaultOutputPackagePath = filepath.Join("openapi", "generated_openapi") + DefaultOutputPackagePath = filepath.Join("openapi") // defaultInputPaths contains the default list of input paths for the OpenAPI generator. defaultInputPaths = []string{ diff --git a/tools/codegen/pkg/openapi/openapi.go b/tools/codegen/pkg/openapi/openapi.go index 5246f4e576c..31e3519cf27 100644 --- a/tools/codegen/pkg/openapi/openapi.go +++ b/tools/codegen/pkg/openapi/openapi.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "os/exec" "path/filepath" "github.com/openshift/api/tools/codegen/pkg/generation" @@ -18,11 +19,16 @@ import ( "k8s.io/kube-openapi/pkg/generators" ) -// generateDeepcopyFunctions generates the OpenAPI functions for the given API package paths. +const generatedOpenAPI = "generated_openapi" + +// generateOpenAPIDefinitions generates the OpenAPI functions for the given API package paths. +// It generates both the Go file and the JSON schema output. func generateOpenAPIDefinitions(globalParser *parser.Parser, universe types.Universe, inputPaths []string, outputPackagePath, outputFileName, headerFilePath string, verify bool) error { // This is the expected path to the output file. // This is what we will compare against if verify is true. - outputFile := filepath.Join(outputPackagePath, outputFileName) + originalOutputPackagePath := outputPackagePath + goOutputPackagePath := filepath.Join(outputPackagePath, generatedOpenAPI) + outputFile := filepath.Join(goOutputPackagePath, outputFileName) if verify { outputPackageBase := filepath.Base(outputPackagePath) @@ -36,8 +42,8 @@ func generateOpenAPIDefinitions(globalParser *parser.Parser, universe types.Univ outputPackagePath = filepath.Join(tmpDir, outputPackageBase) } arguments := args.New() - arguments.OutputDir = outputPackagePath - arguments.OutputPkg = outputPackagePath + arguments.OutputDir = goOutputPackagePath + arguments.OutputPkg = goOutputPackagePath arguments.OutputFile = outputFileName arguments.GoHeaderFile = headerFilePath @@ -67,34 +73,221 @@ func generateOpenAPIDefinitions(globalParser *parser.Parser, universe types.Univ return fmt.Errorf("error executing openapi generator: %w", err) } - if verify { - return verifyDiff(outputFile, outputPackagePath, outputFileName) + if !verify { + // For normal generation, write JSON schema to disk + if err := generateJSONSchema(goOutputPackagePath, outputPackagePath); err != nil { + return fmt.Errorf("error generating JSON schema: %w", err) + } + } else { + // For verification, first verify Go files, then verify JSON schema separately + if err := verifyGoFile(outputFile, goOutputPackagePath, outputFileName); err != nil { + return fmt.Errorf("error verifying generated openapi Go file: %w", err) + } + + // After Go verification passes, verify JSON schema, this is all handled in memory + return verifyJSONSchema(goOutputPackagePath, originalOutputPackagePath) } return nil } -// verifyDiff compares the generated file we put in the temporary directory -// with the current file in the expected location. +// verifyGoFile compares the generated Go file in the temporary directory +// with the current Go file in the expected location. // It returns a diff in the error if the files are different. -func verifyDiff(currentFile, outputPackagePath, outputFileName string) error { - verifyFile := filepath.Join(outputPackagePath, outputFileName) +func verifyGoFile(currentFile, tempOutputPackagePath, outputFileName string) error { + verifyGoFile := filepath.Join(tempOutputPackagePath, outputFileName) + verifyGoData, err := os.ReadFile(verifyGoFile) + if err != nil { + return fmt.Errorf("failed to read generated Go file: %w", err) + } + + currentGoData, err := os.ReadFile(currentFile) + if err != nil { + return fmt.Errorf("failed to read current Go file: %w", err) + } + + if !bytes.Equal(currentGoData, verifyGoData) { + diff := utils.Diff(currentGoData, verifyGoData, currentFile) + return fmt.Errorf("OpenAPI Go schema for %s is out of date, please regenerate the OpenAPI schema:\n%s", currentFile, diff) + } - verifyData, err := os.ReadFile(verifyFile) + return nil +} + +// verifyJSONSchema generates JSON schema in memory and compares it with the current JSON file. +// This is run separately after Go file verification passes. +func verifyJSONSchema(schemaSourcePackage, outputPackagePath string) error { + outputFileName := "openapi.json" + klog.V(2).Infof("Verifying JSON schema for %s", outputFileName) + + // Generate JSON schema in memory for verification + jsonSchemaData, err := generateJSONSchemaInMemory(schemaSourcePackage) if err != nil { - return fmt.Errorf("failed to read generated file: %w", err) + return fmt.Errorf("failed to generate JSON schema for verification: %w", err) + } + + // Verify the JSON schema file + currentJsonFile := filepath.Join(outputPackagePath, outputFileName) + currentJsonData, err := os.ReadFile(currentJsonFile) + if err != nil { + return fmt.Errorf("failed to read current JSON schema file: %w", err) + } + + if !bytes.Equal(currentJsonData, jsonSchemaData) { + diff := utils.Diff(currentJsonData, jsonSchemaData, currentJsonFile) + return fmt.Errorf("OpenAPI JSON schema for %s is out of date, please regenerate the OpenAPI schema:\n%s", currentJsonFile, diff) + } + + return nil +} + +// generateJSONSchemaInMemory creates JSON schema data from the generated Go OpenAPI definitions +// and returns it as a byte slice without writing to disk +func generateJSONSchemaInMemory(schemaSourcePackage string) ([]byte, error) { + outputFileName := "openapi.json" + klog.V(2).Infof("Generating JSON schema in memory from %s", outputFileName) + + // Create a temporary Go program that imports the generated package and outputs JSON + tempDir, err := os.MkdirTemp("", "openapi-json-gen-*") + if err != nil { + return nil, fmt.Errorf("failed to create temporary directory: %w", err) + } + defer os.RemoveAll(tempDir) + + // Determine the package name from the output package path + packageName := filepath.Base(schemaSourcePackage) + + // Create temporary main.go that calls the generated function + tempMainContent := fmt.Sprintf(`package main + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + + "github.com/openshift/api/%s" + "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/validation/spec" +) + +func main() { + refFunc := func(name string) spec.Ref { + return spec.MustCreateRef(fmt.Sprintf("#/definitions/%%s", name)) + } + + defs := %s.GetOpenAPIDefinitions(refFunc) + schemaDefs := make(map[string]spec.Schema, len(defs)) + + for k, v := range defs { + // Replace top-level schema with v2 if a v2 schema is embedded + if schema, ok := v.Schema.Extensions[common.ExtensionV2Schema]; ok { + if v2Schema, isOpenAPISchema := schema.(spec.Schema); isOpenAPISchema { + schemaDefs[k] = v2Schema + continue + } + } + schemaDefs[k] = v.Schema + } + + // Use a buffer and encoder to control JSON formatting + var buf bytes.Buffer + encoder := json.NewEncoder(&buf) + encoder.SetEscapeHTML(false) // Don't escape HTML characters like & < > + encoder.SetIndent("", " ") // Pretty print with 2-space indent + + swagger := &spec.Swagger{ + SwaggerProps: spec.SwaggerProps{ + Definitions: schemaDefs, + Info: &spec.Info{ + InfoProps: spec.InfoProps{ + Title: "OpenShift API", + Version: "unversioned", + }, + }, + Swagger: "2.0", + }, } - currentData, err := os.ReadFile(currentFile) + if err := encoder.Encode(swagger); err != nil { + fmt.Fprintf(os.Stderr, "error serializing api definitions: %%v\n", err) + os.Exit(1) + } + os.Stdout.Write(buf.Bytes()) +}`, schemaSourcePackage, packageName) + + tempMainFile := filepath.Join(tempDir, "main.go") + if err := os.WriteFile(tempMainFile, []byte(tempMainContent), 0644); err != nil { + return nil, fmt.Errorf("failed to write temporary main.go: %w", err) + } + + // Get the absolute path to the repository root + repoRoot, err := filepath.Abs(".") if err != nil { - return fmt.Errorf("failed to read current file: %w", err) + return nil, fmt.Errorf("failed to get absolute path: %w", err) } - if !bytes.Equal(currentData, verifyData) { - diff := utils.Diff(currentData, verifyData, currentFile) + // Create go.mod for the temporary module + tempGoModContent := fmt.Sprintf(`module temp-json-gen + +go 1.21 + +replace github.com/openshift/api => %s + +require github.com/openshift/api v0.0.0 +`, repoRoot) + tempGoModFile := filepath.Join(tempDir, "go.mod") + if err := os.WriteFile(tempGoModFile, []byte(tempGoModContent), 0644); err != nil { + return nil, fmt.Errorf("failed to write temporary go.mod: %w", err) + } + + // Run go mod tidy to fix dependencies + tidyCmd := exec.Command("go", "mod", "tidy") + tidyCmd.Dir = tempDir + if err := tidyCmd.Run(); err != nil { + return nil, fmt.Errorf("failed to run go mod tidy: %w", err) + } + + vendorCmd := exec.Command("go", "mod", "vendor") + vendorCmd.Dir = tempDir + if err := vendorCmd.Run(); err != nil { + return nil, fmt.Errorf("failed to run go mod vendor: %w", err) + } + + // Run the temporary program and capture its output + cmd := exec.Command("go", "run", ".") + cmd.Dir = tempDir + + // Capture both stdout and stderr separately for better error handling + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("failed to run temporary JSON generation program: %w\nstderr: %s", err, stderr.String()) + } + + output := stdout.Bytes() + if len(output) == 0 { + return nil, fmt.Errorf("temporary JSON generation program produced no output\nstderr: %s", stderr.String()) + } + + return output, nil +} + +// generateJSONSchema creates a JSON schema file from the generated Go OpenAPI definitions +func generateJSONSchema(schemaSourcePackage, outputPackagePath string) error { + jsonSchemaData, err := generateJSONSchemaInMemory(schemaSourcePackage) + if err != nil { + return err + } - return fmt.Errorf("OpenAPI schema for %s is out of date, please regenerate the OpenAPI schema:\n%s", currentFile, diff) + // Write the JSON schema to the output directory + jsonSchemaFile := filepath.Join(outputPackagePath, "openapi.json") + if err := os.WriteFile(jsonSchemaFile, jsonSchemaData, 0644); err != nil { + return fmt.Errorf("failed to write OpenAPI JSON schema file: %w", err) } + klog.V(1).Infof("Generated OpenAPI JSON schema: %s", jsonSchemaFile) return nil }