diff --git a/.mise.toml b/.mise.toml index 3aafa4e938..5ea97d338b 100644 --- a/.mise.toml +++ b/.mise.toml @@ -1,6 +1,25 @@ [settings.github] github_attestations = false +################################################################################ +# Tools + +[tools."github:kubernetes-sigs/controller-tools"] +# renovate: datasource=github-releases depName=kubernetes-sigs/controller-tools +version = "0.20.1" + +[tools."github:golangci/golangci-lint"] +# renovate: datasource=github-releases depName=golangci/golangci-lint +version = "2.11.3" + +[tools."github:kubernetes-sigs/controller-runtime"] +# renovate: datasource=github-releases depName=kubernetes-sigs/controller-runtime +version = "0.23.3" +[tools."github:kubernetes-sigs/controller-runtime".platforms] +darwin-arm64 = { asset_pattern = "setup-envtest-darwin-arm64" } +linux-arm64 = { asset_pattern = "setup-envtest-linux-arm64" } +linux-amd64 = { asset_pattern = "setup-envtest-linux-amd64" } + # NOTE: The reason for tool version being set here and not in .tools-versions.toml # is that mise requires the version to be specified when using version_prefix # and we need to set the latter for kustomize because its tags on GitHub are prefixed @@ -48,3 +67,66 @@ macos-arm64 = { url = "https://get.helm.sh/helm-v{{version}}-darwin-arm64.tar.gz [tools."go:sigs.k8s.io/kube-api-linter/cmd/golangci-lint-kube-api-linter"] # renovate: datasource=git-refs packageName=https://github.com/kubernetes-sigs/kube-api-linter.git version = "39e3d06a2850e38a8e9e82918bab14ce84e608de" + + +################################################################################ +# Tasks + +[env] +DIR = "crd-from-oas" + +[tasks.build-crd-from-oas] +description = "Build the {{env.DIR}} binary" +dir = "./{{env.DIR}}" +run = "go build -o ../bin/{{env.DIR}} main.go" + +[tasks.clean] +# TODO: This hardcodes the x-konnect API group. Removing this hardcode would cause +# the removal of other non generated APIs to be removed. +description = "Clean generated api/x-konnect directory" +run = "rm -rf ./api/x-konnect/" + +[tasks.generate-api] +description = "Generate Kubernetes Go API from OpenAPI spec" +dir = "./{{env.DIR}}" +# NOTE we get the openapi.yaml file from the sdk-konnect-go module which is expected +# to be present in the local Go module cache. +# This ensures that we use the same OpenAPI spec as the one used to generate the +# SDK which should help avoid potential discrepancies between the API and the SDK. +run = ''' +export INPUT_FILE="$(go list -m -f '{{`{{.Dir}}`}}' github.com/Kong/sdk-konnect-go)/openapi.yaml" +export OUTPUT_DIR="../api/" +export CONFIG_FILE="config.yaml" +go run . +''' +depends = [ "clean" ] +depends_post = [ "generate-deepcopy" ] + +[tasks.generate-deepcopy] +description = "Generate DeepCopy methods for the API types" +dir = "./{{env.DIR}}" +run = "controller-gen object:headerFile='../hack/generators/boilerplate.go.txt' paths=../api/..." + +[tasks.lint-api] +description = "Run golangci-lint" +# TODO: This hardcodes the x-konnect API group. Removing this hardcode would cause the linter +# to run against all the API groups which might create unwanted issues being reported here. +run = "golangci-lint-kube-api-linter run -v --config .golangci-kube-api.yaml ./api/x-konnect/..." + +[tasks.test-crdsvalidation] +description = "Run CRD validation tests" +dir = "./{{env.DIR}}" +# Update test paths below if more API groups are added +run = ''' +export KUBEBUILDER_ASSETS=$(setup-envtest use -p path) +go test -v -count 1 ${GOTESTFLAGS} ./test/crdsvalidation/x-konnect.konghq.com/... +''' + +[tasks.all] +description = "Generate API, CRDs, and run all tests and linters" +run = [ + { task = "generate-api" }, + { task = "lint" }, + { tasks = [ "lint-api", "test-unit", "gofix"] }, + { task = "test-crdsvalidation" }, +] diff --git a/Makefile b/Makefile index a3fc36cc2e..530da1e608 100644 --- a/Makefile +++ b/Makefile @@ -323,7 +323,7 @@ API_DIR ?= api # make generate && make manifests && make test.charts.golden.update # into a single command: make generate # Note: manifests is placed near the end to preserve the prior ordering (docs are generated from CRDs first). -generate: generate.crds generate.crd-kustomize generate.k8sio-gomod-replace generate.deepcopy generate.apitypes-funcs generate.docs generate.lint-fix manifests test.charts.golden.update generate.cli-arguments-docs +generate: generate.api generate.api-from-oas generate.crds generate.crd-kustomize generate.k8sio-gomod-replace generate.apitypes-funcs generate.docs generate.lint-fix manifests test.charts.golden.update generate.cli-arguments-docs .PHONY: generate.crds generate.crds: controller-gen ## Generate WebhookConfiguration and CustomResourceDefinition objects. @@ -338,8 +338,9 @@ generate.crd-kustomize: generate.api: controller-gen $(CONTROLLER_GEN) object:headerFile="hack/generators/boilerplate.go.txt" paths="./$(API_DIR)/..." -.PHONY: generate.deepcopy -generate.deepcopy: generate.api +.PHONY: generate.api-from-oas +generate.api-from-oas: + mise r generate-api .PHONY: generate.apitypes-funcs generate.apitypes-funcs: @@ -566,6 +567,7 @@ KONG_CONTROLLER_FEATURE_GATES ?= GatewayAlpha=true test: test.unit UNIT_TEST_PATHS := ./api/... ./controller/... ./internal/... ./pkg/... ./modules/... ./ingress-controller/internal/... ./ingress-controller/pkg/... +UNIT_TEST_PATHS_CRD_GEN := ./pkg/... .PHONY: _test.unit _test.unit: gotestsum @@ -575,6 +577,11 @@ _test.unit: gotestsum -coverprofile=coverage.unit.out \ -ldflags "$(LDFLAGS_COMMON) $(LDFLAGS)" \ $(UNIT_TEST_PATHS) + cd crd-from-oas && \ + GOTESTSUM_FORMAT=$(GOTESTSUM_FORMAT) \ + $(GOTESTSUM) -- $(GOTESTFLAGS) \ + -race \ + $(UNIT_TEST_PATHS_CRD_GEN) .PHONY: test.unit test.unit: @@ -1135,3 +1142,4 @@ lint.api: download.kube-api-linter ./api/konnect/v1alpha1/... \ ./api/konnect/v1alpha2/... \ ./api/common/v1alpha1/... + mise r lint-api diff --git a/api/x-konnect/v1alpha1/common_types.go b/api/x-konnect/v1alpha1/common_types.go new file mode 100644 index 0000000000..68b5015c33 --- /dev/null +++ b/api/x-konnect/v1alpha1/common_types.go @@ -0,0 +1,80 @@ +// Code generated by CRD generation pipeline. DO NOT EDIT. + +package v1alpha1 + +// SecretKeyRef is a reference to a key in a Secret +type SecretKeyRef struct { + // Name is the name of the Secret + // + // +required + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:MinLength=1 + Name string `json:"name,omitempty"` + + // Key is the key within the Secret + // + // +required + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:MinLength=1 + Key string `json:"key,omitempty"` + + // Namespace is the namespace of the Secret + // + // +optional + // +kubebuilder:validation:MaxLength=63 + Namespace string `json:"namespace,omitempty"` +} + +// ConfigMapKeyRef is a reference to a key in a ConfigMap +type ConfigMapKeyRef struct { + // Name is the name of the ConfigMap + // + // +required + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:MinLength=1 + Name string `json:"name,omitempty"` + + // Key is the key within the ConfigMap + // + // +required + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:MinLength=1 + Key string `json:"key,omitempty"` + + // Namespace is the namespace of the ConfigMap + // + // +optional + // +kubebuilder:validation:MaxLength=63 + Namespace string `json:"namespace,omitempty"` +} + +// KonnectEntityStatus represents the status of a Konnect entity. +type KonnectEntityStatus struct { + // ID is the unique identifier of the Konnect entity as assigned by Konnect API. + // If it's unset (empty string), it means the Konnect entity hasn't been created yet. + // + // +optional + // +kubebuilder:validation:MaxLength=256 + ID string `json:"id,omitempty"` + + // ServerURL is the URL of the Konnect server in which the entity exists. + // + // +optional + // +kubebuilder:validation:MaxLength=512 + ServerURL string `json:"serverURL,omitempty"` + + // OrgID is ID of Konnect Org that this entity has been created in. + // + // +optional + // +kubebuilder:validation:MaxLength=256 + OrgID string `json:"organizationID,omitempty"` +} + +// KonnectEntityRef is a reference to a Konnect entity. +type KonnectEntityRef struct { + // ID is the unique identifier of the Konnect entity as assigned by Konnect API. + // + // +optional + // +kubebuilder:validation:MaxLength=256 + ID string `json:"id,omitempty"` +} diff --git a/api/x-konnect/v1alpha1/doc.go b/api/x-konnect/v1alpha1/doc.go new file mode 100644 index 0000000000..2c720e5f7f --- /dev/null +++ b/api/x-konnect/v1alpha1/doc.go @@ -0,0 +1,5 @@ +// Package v1alpha1 contains API types for the x-konnect.konghq.com API group. +// +// +kubebuilder:object:generate=true +// +groupName=x-konnect.konghq.com +package v1alpha1 diff --git a/api/x-konnect/v1alpha1/eventgateway_types.go b/api/x-konnect/v1alpha1/eventgateway_types.go new file mode 100644 index 0000000000..aef1d4c1b8 --- /dev/null +++ b/api/x-konnect/v1alpha1/eventgateway_types.go @@ -0,0 +1,112 @@ +// Code generated by CRD generation pipeline. DO NOT EDIT. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// EventGateway is the Schema for the eventgateways API. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced +// +kubebuilder:printcolumn:name="ID",description="Konnect ID",type="string",JSONPath=".status.id" +// +kubebuilder:printcolumn:name="Programmed",description="The Resource is Programmed on Konnect",type=string,JSONPath=`.status.conditions[?(@.type=='Programmed')].status` +// +kubebuilder:printcolumn:name="OrgID",description="Konnect Organization ID this resource belongs to.",type=string,JSONPath=`.status.organizationID` +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:storageversion +// +apireference:kgo:include +// +kong:channels=kong-operator +type EventGateway struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitzero"` + + // +optional + Spec EventGatewaySpec `json:"spec,omitzero"` + + // +optional + Status EventGatewayStatus `json:"status,omitzero"` +} + +// EventGatewayList contains a list of EventGateway. +// +// +kubebuilder:object:root=true +type EventGatewayList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []EventGateway `json:"items"` +} + +// EventGatewaySpec defines the desired state of EventGateway. +type EventGatewaySpec struct { + // APISpec defines the desired state of the resource's API spec fields. + // + // +optional + APISpec EventGatewayAPISpec `json:"apiSpec,omitzero"` +} + +// EventGatewayAPISpec defines the API spec fields for EventGateway. +type EventGatewayAPISpec struct { + // A human-readable description of the Gateway. + // + // +optional + // +kubebuilder:validation:MaxLength=512 + Description GatewayDescription `json:"description,omitempty"` + + // Labels store metadata of an entity that can be used for filtering an entity + // list or for searching across entity types. + // + // Keys must be of length 1-63 characters, and cannot start with "kong", + // "konnect", "mesh", "kic", or "_". + // + // + // +optional + Labels Labels `json:"labels,omitempty"` + + // The minimum runtime version supported by the API. + // This is the lowest version of the data plane + // release that can be used with the entity model. + // When not specified, the minimum runtime version will be pinned to the latest + // available release. + // + // + // +optional + // +kubebuilder:validation:MaxLength=256 + // +kubebuilder:validation:Pattern=`^\d+\.\d+$` + MinRuntimeVersion MinRuntimeVersion `json:"min_runtime_version,omitempty"` + + // The name of the Gateway. + // + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=255 + Name GatewayName `json:"name,omitempty"` +} + +// EventGatewayStatus defines the observed state of EventGateway. +type EventGatewayStatus struct { + // Conditions represent the current state of the resource. + // + // +optional + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +kubebuilder:validation:MaxItems=8 + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // Konnect contains the Konnect entity status. + // + // +optional + KonnectEntityStatus `json:",inline"` + + // ObservedGeneration is the most recent generation observed + // + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +func init() { + SchemeBuilder.Register(&EventGateway{}, &EventGatewayList{}) +} diff --git a/api/x-konnect/v1alpha1/portal_sdkops.go b/api/x-konnect/v1alpha1/portal_sdkops.go new file mode 100644 index 0000000000..cf511c7e19 --- /dev/null +++ b/api/x-konnect/v1alpha1/portal_sdkops.go @@ -0,0 +1,42 @@ +// Code generated by CRD generation pipeline. DO NOT EDIT. + +package v1alpha1 + +import ( + "encoding/json" + "fmt" + + sdkkonnectcomp "github.com/Kong/sdk-konnect-go/models/components" +) + +// ToCreatePortal converts the PortalAPISpec to the SDK type +// sdkkonnectcomp.CreatePortal using JSON marshal/unmarshal. +// Fields that exist in the CRD spec but not in the SDK type (e.g., Kubernetes +// object references) are naturally excluded because they have different JSON names. +func (s *PortalAPISpec) ToCreatePortal() (*sdkkonnectcomp.CreatePortal, error) { + data, err := json.Marshal(s) + if err != nil { + return nil, fmt.Errorf("failed to marshal PortalAPISpec: %w", err) + } + var target sdkkonnectcomp.CreatePortal + if err := json.Unmarshal(data, &target); err != nil { + return nil, fmt.Errorf("failed to unmarshal into CreatePortal: %w", err) + } + return &target, nil +} + +// ToUpdatePortal converts the PortalAPISpec to the SDK type +// sdkkonnectcomp.UpdatePortal using JSON marshal/unmarshal. +// Fields that exist in the CRD spec but not in the SDK type (e.g., Kubernetes +// object references) are naturally excluded because they have different JSON names. +func (s *PortalAPISpec) ToUpdatePortal() (*sdkkonnectcomp.UpdatePortal, error) { + data, err := json.Marshal(s) + if err != nil { + return nil, fmt.Errorf("failed to marshal PortalAPISpec: %w", err) + } + var target sdkkonnectcomp.UpdatePortal + if err := json.Unmarshal(data, &target); err != nil { + return nil, fmt.Errorf("failed to unmarshal into UpdatePortal: %w", err) + } + return &target, nil +} diff --git a/api/x-konnect/v1alpha1/portal_sdkops_test.go b/api/x-konnect/v1alpha1/portal_sdkops_test.go new file mode 100644 index 0000000000..7424a41d33 --- /dev/null +++ b/api/x-konnect/v1alpha1/portal_sdkops_test.go @@ -0,0 +1,43 @@ +// Code generated by CRD generation pipeline. DO NOT EDIT. + +package v1alpha1 + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPortalAPISpec_ToCreatePortal(t *testing.T) { + spec := &PortalAPISpec{ + AuthenticationEnabled: true, + AutoApproveApplications: true, + AutoApproveDevelopers: true, + DefaultAPIVisibility: "test-value", + DefaultPageVisibility: "test-value", + Description: new("test-value"), + DisplayName: "test-value", + Name: "test-value", + RBACEnabled: true, + } + result, err := spec.ToCreatePortal() + require.NoError(t, err) + require.NotNil(t, result) +} + +func TestPortalAPISpec_ToUpdatePortal(t *testing.T) { + spec := &PortalAPISpec{ + AuthenticationEnabled: true, + AutoApproveApplications: true, + AutoApproveDevelopers: true, + DefaultAPIVisibility: "test-value", + DefaultPageVisibility: "test-value", + Description: new("test-value"), + DisplayName: "test-value", + Name: "test-value", + RBACEnabled: true, + } + result, err := spec.ToUpdatePortal() + require.NoError(t, err) + require.NotNil(t, result) +} diff --git a/api/x-konnect/v1alpha1/portal_types.go b/api/x-konnect/v1alpha1/portal_types.go new file mode 100644 index 0000000000..b1774dec8c --- /dev/null +++ b/api/x-konnect/v1alpha1/portal_types.go @@ -0,0 +1,176 @@ +// Code generated by CRD generation pipeline. DO NOT EDIT. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + commonv1alpha1 "github.com/kong/kong-operator/v2/api/common/v1alpha1" +) + +// Portal is the Schema for the portals API. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced +// +kubebuilder:printcolumn:name="ID",description="Konnect ID",type="string",JSONPath=".status.id" +// +kubebuilder:printcolumn:name="Programmed",description="The Resource is Programmed on Konnect",type=string,JSONPath=`.status.conditions[?(@.type=='Programmed')].status` +// +kubebuilder:printcolumn:name="OrgID",description="Konnect Organization ID this resource belongs to.",type=string,JSONPath=`.status.organizationID` +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:storageversion +// +apireference:kgo:include +// +kong:channels=kong-operator +type Portal struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitzero"` + + // +optional + Spec PortalSpec `json:"spec,omitzero"` + + // +optional + Status PortalStatus `json:"status,omitzero"` +} + +// PortalList contains a list of Portal. +// +// +kubebuilder:object:root=true +type PortalList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []Portal `json:"items"` +} + +// PortalSpec defines the desired state of Portal. +type PortalSpec struct { + // APISpec defines the desired state of the resource's API spec fields. + // + // +optional + APISpec PortalAPISpec `json:"apiSpec,omitzero"` +} + +// PortalAPISpec defines the API spec fields for Portal. +type PortalAPISpec struct { + // Whether the portal supports developer authentication. + // If disabled, developers cannot register for accounts or create applications. + // + // +optional + // +kubebuilder:default=true + AuthenticationEnabled bool `json:"authentication_enabled,omitempty"` + + // Whether requests from applications to register for APIs will be + // automatically approved, or if they will be set to pending until approved by + // an admin. + // + // +optional + // +kubebuilder:default=false + AutoApproveApplications bool `json:"auto_approve_applications,omitempty"` + + // Whether developer account registrations will be automatically approved, or + // if they will be set to pending until approved by an admin. + // + // +optional + // +kubebuilder:default=false + AutoApproveDevelopers bool `json:"auto_approve_developers,omitempty"` + + // The default visibility of APIs in the portal. + // If set to `public`, newly published APIs are visible to unauthenticated + // developers. + // If set to `private`, newly published APIs are hidden from unauthenticated + // developers. + // + // +optional + // +kubebuilder:validation:MaxLength=256 + // +kubebuilder:validation:Enum=public;private + DefaultAPIVisibility string `json:"default_api_visibility,omitempty"` + + // The default authentication strategy for APIs published to the portal. + // Newly published APIs will use this authentication strategy unless overridden + // during publication. + // If set to `null`, API publications will not use an authentication strategy + // unless set during publication. + // + // +optional + DefaultApplicationAuthStrategyIDRef *commonv1alpha1.ObjectRef `json:"default_application_auth_strategy_id_ref,omitempty"` + + // The default visibility of pages in the portal. + // If set to `public`, newly created pages are visible to unauthenticated + // developers. + // If set to `private`, newly created pages are hidden from unauthenticated + // developers. + // + // +optional + // +kubebuilder:validation:MaxLength=256 + // +kubebuilder:validation:Enum=public;private + DefaultPageVisibility string `json:"default_page_visibility,omitempty"` + + // A description of the portal. + // + // +optional + // +kubebuilder:validation:MaxLength=512 + Description *string `json:"description,omitempty"` + + // The display name of the portal. + // This value will be the portal's `name` in Portal API. + // + // +optional + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=255 + DisplayName string `json:"display_name,omitempty"` + + // Labels store metadata of an entity that can be used for filtering an entity + // list or for searching across entity types. + // + // Labels are intended to store **INTERNAL** metadata. + // + // Keys must be of length 1-63 characters, and cannot start with "kong", + // "konnect", "mesh", "kic", or "_". + // + // + // +optional + Labels LabelsUpdate `json:"labels,omitempty"` + + // The name of the portal, used to distinguish it from other portals. + // Name must be unique. + // + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=255 + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="name is immutable" + Name string `json:"name,omitempty"` + + // Whether the portal resources are protected by Role Based Access Control + // (RBAC). + // If enabled, developers view or register for APIs until unless assigned to + // teams with access to view and consume specific APIs. + // Authentication must be enabled to use RBAC. + // + // +optional + // +kubebuilder:default=false + RBACEnabled bool `json:"rbac_enabled,omitempty"` +} + +// PortalStatus defines the observed state of Portal. +type PortalStatus struct { + // Conditions represent the current state of the resource. + // + // +optional + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +kubebuilder:validation:MaxItems=8 + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // Konnect contains the Konnect entity status. + // + // +optional + KonnectEntityStatus `json:",inline"` + + // ObservedGeneration is the most recent generation observed + // + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +func init() { + SchemeBuilder.Register(&Portal{}, &PortalList{}) +} diff --git a/api/x-konnect/v1alpha1/portalteam_types.go b/api/x-konnect/v1alpha1/portalteam_types.go new file mode 100644 index 0000000000..9de0f0f1aa --- /dev/null +++ b/api/x-konnect/v1alpha1/portalteam_types.go @@ -0,0 +1,102 @@ +// Code generated by CRD generation pipeline. DO NOT EDIT. + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + commonv1alpha1 "github.com/kong/kong-operator/v2/api/common/v1alpha1" +) + +// PortalTeam is the Schema for the portalteams API. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced +// +kubebuilder:printcolumn:name="ID",description="Konnect ID",type="string",JSONPath=".status.id" +// +kubebuilder:printcolumn:name="Programmed",description="The Resource is Programmed on Konnect",type=string,JSONPath=`.status.conditions[?(@.type=='Programmed')].status` +// +kubebuilder:printcolumn:name="OrgID",description="Konnect Organization ID this resource belongs to.",type=string,JSONPath=`.status.organizationID` +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:storageversion +// +apireference:kgo:include +// +kong:channels=kong-operator +type PortalTeam struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitzero"` + + // +optional + Spec PortalTeamSpec `json:"spec,omitzero"` + + // +optional + Status PortalTeamStatus `json:"status,omitzero"` +} + +// PortalTeamList contains a list of PortalTeam. +// +// +kubebuilder:object:root=true +type PortalTeamList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitzero"` + Items []PortalTeam `json:"items"` +} + +// PortalTeamSpec defines the desired state of PortalTeam. +type PortalTeamSpec struct { + // PortalRef is the reference to the parent Portal object. + // + // +required + PortalRef commonv1alpha1.ObjectRef `json:"portal_ref,omitzero"` + + // APISpec defines the desired state of the resource's API spec fields. + // + // +optional + APISpec PortalTeamAPISpec `json:"apiSpec,omitzero"` +} + +// PortalTeamAPISpec defines the API spec fields for PortalTeam. +type PortalTeamAPISpec struct { + // + // + // +optional + // +kubebuilder:validation:MaxLength=250 + Description string `json:"description,omitempty"` + + // + // + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=256 + // +kubebuilder:validation:Pattern=`^[\w \W]+$` + Name string `json:"name,omitempty"` +} + +// PortalTeamStatus defines the observed state of PortalTeam. +type PortalTeamStatus struct { + // Conditions represent the current state of the resource. + // + // +optional + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +kubebuilder:validation:MaxItems=8 + Conditions []metav1.Condition `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + + // Konnect contains the Konnect entity status. + // + // +optional + KonnectEntityStatus `json:",inline"` + + // PortalID is the Konnect ID of the parent Portal. + // + // +optional + PortalID *KonnectEntityRef `json:"portalID,omitempty"` + + // ObservedGeneration is the most recent generation observed + // + // +optional + ObservedGeneration int64 `json:"observedGeneration,omitempty"` +} + +func init() { + SchemeBuilder.Register(&PortalTeam{}, &PortalTeamList{}) +} diff --git a/api/x-konnect/v1alpha1/register.go b/api/x-konnect/v1alpha1/register.go new file mode 100644 index 0000000000..705788602a --- /dev/null +++ b/api/x-konnect/v1alpha1/register.go @@ -0,0 +1,19 @@ +// Code generated by CRD generation pipeline. DO NOT EDIT. + +package v1alpha1 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "x-konnect.konghq.com", Version: "v1alpha1"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/x-konnect/v1alpha1/schema_types.go b/api/x-konnect/v1alpha1/schema_types.go new file mode 100644 index 0000000000..4156b647d0 --- /dev/null +++ b/api/x-konnect/v1alpha1/schema_types.go @@ -0,0 +1,30 @@ +package v1alpha1 + +// GatewayDescription A human-readable description of the Gateway. +type GatewayDescription string + +// GatewayName The name of the Gateway. +type GatewayName string + +// Labels store metadata of an entity that can be used for filtering an +// entity list or for searching across entity types. +// +// Keys must be of length 1-63 characters, and cannot start with "kong", +// "konnect", "mesh", "kic", or "_". +type Labels map[string]string + +// LabelsUpdate Labels store metadata of an entity that can be used for +// filtering an entity list or for searching across entity types. +// +// Labels are intended to store **INTERNAL** metadata. +// +// Keys must be of length 1-63 characters, and cannot start with "kong", +// "konnect", "mesh", "kic", or "_". +type LabelsUpdate map[string]string + +// MinRuntimeVersion The minimum runtime version supported by the API. +// This is the lowest version of the data plane +// release that can be used with the entity model. +// When not specified, the minimum runtime version will be pinned to the latest +// available release. +type MinRuntimeVersion string diff --git a/api/x-konnect/v1alpha1/zz_generated.deepcopy.go b/api/x-konnect/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000000..166eb2108d --- /dev/null +++ b/api/x-konnect/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,498 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2022 Kong Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + commonv1alpha1 "github.com/kong/kong-operator/v2/api/common/v1alpha1" + "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ConfigMapKeyRef) DeepCopyInto(out *ConfigMapKeyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ConfigMapKeyRef. +func (in *ConfigMapKeyRef) DeepCopy() *ConfigMapKeyRef { + if in == nil { + return nil + } + out := new(ConfigMapKeyRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventGateway) DeepCopyInto(out *EventGateway) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventGateway. +func (in *EventGateway) DeepCopy() *EventGateway { + if in == nil { + return nil + } + out := new(EventGateway) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventGateway) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventGatewayAPISpec) DeepCopyInto(out *EventGatewayAPISpec) { + *out = *in + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(Labels, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventGatewayAPISpec. +func (in *EventGatewayAPISpec) DeepCopy() *EventGatewayAPISpec { + if in == nil { + return nil + } + out := new(EventGatewayAPISpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventGatewayList) DeepCopyInto(out *EventGatewayList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]EventGateway, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventGatewayList. +func (in *EventGatewayList) DeepCopy() *EventGatewayList { + if in == nil { + return nil + } + out := new(EventGatewayList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *EventGatewayList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventGatewaySpec) DeepCopyInto(out *EventGatewaySpec) { + *out = *in + in.APISpec.DeepCopyInto(&out.APISpec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventGatewaySpec. +func (in *EventGatewaySpec) DeepCopy() *EventGatewaySpec { + if in == nil { + return nil + } + out := new(EventGatewaySpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *EventGatewayStatus) DeepCopyInto(out *EventGatewayStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.KonnectEntityStatus = in.KonnectEntityStatus +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new EventGatewayStatus. +func (in *EventGatewayStatus) DeepCopy() *EventGatewayStatus { + if in == nil { + return nil + } + out := new(EventGatewayStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KonnectEntityRef) DeepCopyInto(out *KonnectEntityRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KonnectEntityRef. +func (in *KonnectEntityRef) DeepCopy() *KonnectEntityRef { + if in == nil { + return nil + } + out := new(KonnectEntityRef) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *KonnectEntityStatus) DeepCopyInto(out *KonnectEntityStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new KonnectEntityStatus. +func (in *KonnectEntityStatus) DeepCopy() *KonnectEntityStatus { + if in == nil { + return nil + } + out := new(KonnectEntityStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in Labels) DeepCopyInto(out *Labels) { + { + in := &in + *out = make(Labels, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Labels. +func (in Labels) DeepCopy() Labels { + if in == nil { + return nil + } + out := new(Labels) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in LabelsUpdate) DeepCopyInto(out *LabelsUpdate) { + { + in := &in + *out = make(LabelsUpdate, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LabelsUpdate. +func (in LabelsUpdate) DeepCopy() LabelsUpdate { + if in == nil { + return nil + } + out := new(LabelsUpdate) + in.DeepCopyInto(out) + return *out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Portal) DeepCopyInto(out *Portal) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Portal. +func (in *Portal) DeepCopy() *Portal { + if in == nil { + return nil + } + out := new(Portal) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Portal) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PortalAPISpec) DeepCopyInto(out *PortalAPISpec) { + *out = *in + if in.DefaultApplicationAuthStrategyIDRef != nil { + in, out := &in.DefaultApplicationAuthStrategyIDRef, &out.DefaultApplicationAuthStrategyIDRef + *out = new(commonv1alpha1.ObjectRef) + (*in).DeepCopyInto(*out) + } + if in.Description != nil { + in, out := &in.Description, &out.Description + *out = new(string) + **out = **in + } + if in.Labels != nil { + in, out := &in.Labels, &out.Labels + *out = make(LabelsUpdate, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortalAPISpec. +func (in *PortalAPISpec) DeepCopy() *PortalAPISpec { + if in == nil { + return nil + } + out := new(PortalAPISpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PortalList) DeepCopyInto(out *PortalList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Portal, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortalList. +func (in *PortalList) DeepCopy() *PortalList { + if in == nil { + return nil + } + out := new(PortalList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PortalList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PortalSpec) DeepCopyInto(out *PortalSpec) { + *out = *in + in.APISpec.DeepCopyInto(&out.APISpec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortalSpec. +func (in *PortalSpec) DeepCopy() *PortalSpec { + if in == nil { + return nil + } + out := new(PortalSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PortalStatus) DeepCopyInto(out *PortalStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.KonnectEntityStatus = in.KonnectEntityStatus +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortalStatus. +func (in *PortalStatus) DeepCopy() *PortalStatus { + if in == nil { + return nil + } + out := new(PortalStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PortalTeam) DeepCopyInto(out *PortalTeam) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortalTeam. +func (in *PortalTeam) DeepCopy() *PortalTeam { + if in == nil { + return nil + } + out := new(PortalTeam) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PortalTeam) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PortalTeamAPISpec) DeepCopyInto(out *PortalTeamAPISpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortalTeamAPISpec. +func (in *PortalTeamAPISpec) DeepCopy() *PortalTeamAPISpec { + if in == nil { + return nil + } + out := new(PortalTeamAPISpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PortalTeamList) DeepCopyInto(out *PortalTeamList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]PortalTeam, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortalTeamList. +func (in *PortalTeamList) DeepCopy() *PortalTeamList { + if in == nil { + return nil + } + out := new(PortalTeamList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *PortalTeamList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PortalTeamSpec) DeepCopyInto(out *PortalTeamSpec) { + *out = *in + in.PortalRef.DeepCopyInto(&out.PortalRef) + out.APISpec = in.APISpec +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortalTeamSpec. +func (in *PortalTeamSpec) DeepCopy() *PortalTeamSpec { + if in == nil { + return nil + } + out := new(PortalTeamSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PortalTeamStatus) DeepCopyInto(out *PortalTeamStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]v1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + out.KonnectEntityStatus = in.KonnectEntityStatus + if in.PortalID != nil { + in, out := &in.PortalID, &out.PortalID + *out = new(KonnectEntityRef) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PortalTeamStatus. +func (in *PortalTeamStatus) DeepCopy() *PortalTeamStatus { + if in == nil { + return nil + } + out := new(PortalTeamStatus) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *SecretKeyRef) DeepCopyInto(out *SecretKeyRef) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SecretKeyRef. +func (in *SecretKeyRef) DeepCopy() *SecretKeyRef { + if in == nil { + return nil + } + out := new(SecretKeyRef) + in.DeepCopyInto(out) + return out +} diff --git a/config/crd/kong-operator/kustomization.yaml b/config/crd/kong-operator/kustomization.yaml index 749f91afee..d3e4dd1428 100644 --- a/config/crd/kong-operator/kustomization.yaml +++ b/config/crd/kong-operator/kustomization.yaml @@ -40,4 +40,7 @@ resources: - konnect.konghq.com_konnectcloudgatewaynetworks.yaml - konnect.konghq.com_konnectcloudgatewaytransitgateways.yaml - konnect.konghq.com_konnectextensions.yaml - - konnect.konghq.com_konnectgatewaycontrolplanes.yaml \ No newline at end of file + - konnect.konghq.com_konnectgatewaycontrolplanes.yaml + - x-konnect.konghq.com_eventgateways.yaml + - x-konnect.konghq.com_portals.yaml + - x-konnect.konghq.com_portalteams.yaml \ No newline at end of file diff --git a/config/crd/kong-operator/x-konnect.konghq.com_eventgateways.yaml b/config/crd/kong-operator/x-konnect.konghq.com_eventgateways.yaml new file mode 100644 index 0000000000..8edb3c771e --- /dev/null +++ b/config/crd/kong-operator/x-konnect.konghq.com_eventgateways.yaml @@ -0,0 +1,187 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + kubernetes-configuration.konghq.com/channels: kong-operator + kubernetes-configuration.konghq.com/version: v2.1.0 + name: eventgateways.x-konnect.konghq.com +spec: + group: x-konnect.konghq.com + names: + kind: EventGateway + listKind: EventGatewayList + plural: eventgateways + singular: eventgateway + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Konnect ID + jsonPath: .status.id + name: ID + type: string + - description: The Resource is Programmed on Konnect + jsonPath: .status.conditions[?(@.type=='Programmed')].status + name: Programmed + type: string + - description: Konnect Organization ID this resource belongs to. + jsonPath: .status.organizationID + name: OrgID + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: EventGateway is the Schema for the eventgateways API. + 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: + type: object + spec: + description: EventGatewaySpec defines the desired state of EventGateway. + properties: + apiSpec: + description: APISpec defines the desired state of the resource's API + spec fields. + properties: + description: + description: A human-readable description of the Gateway. + maxLength: 512 + type: string + labels: + additionalProperties: + type: string + description: |- + Labels store metadata of an entity that can be used for filtering an entity + list or for searching across entity types. + + Keys must be of length 1-63 characters, and cannot start with "kong", + "konnect", "mesh", "kic", or "_". + type: object + min_runtime_version: + description: |- + The minimum runtime version supported by the API. + This is the lowest version of the data plane + release that can be used with the entity model. + When not specified, the minimum runtime version will be pinned to the latest + available release. + maxLength: 256 + pattern: ^\d+\.\d+$ + type: string + name: + description: The name of the Gateway. + maxLength: 255 + minLength: 1 + type: string + required: + - name + type: object + type: object + status: + description: EventGatewayStatus defines the observed state of EventGateway. + properties: + conditions: + description: Conditions represent the current state of the resource. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + id: + description: |- + ID is the unique identifier of the Konnect entity as assigned by Konnect API. + If it's unset (empty string), it means the Konnect entity hasn't been created yet. + maxLength: 256 + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + format: int64 + type: integer + organizationID: + description: OrgID is ID of Konnect Org that this entity has been + created in. + maxLength: 256 + type: string + serverURL: + description: ServerURL is the URL of the Konnect server in which the + entity exists. + maxLength: 512 + type: string + type: object + required: + - metadata + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kong-operator/x-konnect.konghq.com_portals.yaml b/config/crd/kong-operator/x-konnect.konghq.com_portals.yaml new file mode 100644 index 0000000000..4d40d960be --- /dev/null +++ b/config/crd/kong-operator/x-konnect.konghq.com_portals.yaml @@ -0,0 +1,305 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + kubernetes-configuration.konghq.com/channels: kong-operator + kubernetes-configuration.konghq.com/version: v2.1.0 + name: portals.x-konnect.konghq.com +spec: + group: x-konnect.konghq.com + names: + kind: Portal + listKind: PortalList + plural: portals + singular: portal + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Konnect ID + jsonPath: .status.id + name: ID + type: string + - description: The Resource is Programmed on Konnect + jsonPath: .status.conditions[?(@.type=='Programmed')].status + name: Programmed + type: string + - description: Konnect Organization ID this resource belongs to. + jsonPath: .status.organizationID + name: OrgID + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: Portal is the Schema for the portals API. + 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: + type: object + spec: + description: PortalSpec defines the desired state of Portal. + properties: + apiSpec: + description: APISpec defines the desired state of the resource's API + spec fields. + properties: + authentication_enabled: + default: true + description: |- + Whether the portal supports developer authentication. + If disabled, developers cannot register for accounts or create applications. + type: boolean + auto_approve_applications: + default: false + description: |- + Whether requests from applications to register for APIs will be + automatically approved, or if they will be set to pending until approved by + an admin. + type: boolean + auto_approve_developers: + default: false + description: |- + Whether developer account registrations will be automatically approved, or + if they will be set to pending until approved by an admin. + type: boolean + default_api_visibility: + description: |- + The default visibility of APIs in the portal. + If set to `public`, newly published APIs are visible to unauthenticated + developers. + If set to `private`, newly published APIs are hidden from unauthenticated + developers. + enum: + - public + - private + maxLength: 256 + type: string + default_application_auth_strategy_id_ref: + description: |- + The default authentication strategy for APIs published to the portal. + Newly published APIs will use this authentication strategy unless overridden + during publication. + If set to `null`, API publications will not use an authentication strategy + unless set during publication. + properties: + konnectID: + description: |- + KonnectID is the schema for the KonnectID type. + This field is required when the Type is konnectID. + maxLength: 36 + type: string + namespacedRef: + description: |- + NamespacedRef is a reference to a KeySet entity inside the cluster. + This field is required when the Type is namespacedRef. + properties: + name: + description: Name is the name of the referred resource. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referred resource. + + For namespace-scoped resources if no Namespace is provided then the + namespace of the parent object MUST be used. + + This field MUST not be set when referring to cluster-scoped resources. + maxLength: 253 + type: string + required: + - name + type: object + type: + description: |- + Type defines type of the object which is referenced. It can be one of: + + - konnectID + - namespacedRef + enum: + - konnectID + - namespacedRef + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: when type is namespacedRef, namespacedRef must be set + rule: 'self.type == ''namespacedRef'' ? has(self.namespacedRef) + : true' + - message: when type is namespacedRef, konnectID must not be set + rule: 'self.type == ''namespacedRef'' ? !has(self.konnectID) + : true' + - message: when type is konnectID, konnectID must be set + rule: 'self.type == ''konnectID'' ? has(self.konnectID) : true' + - message: when type is konnectID, namespacedRef must not be set + rule: 'self.type == ''konnectID'' ? !has(self.namespacedRef) + : true' + default_page_visibility: + description: |- + The default visibility of pages in the portal. + If set to `public`, newly created pages are visible to unauthenticated + developers. + If set to `private`, newly created pages are hidden from unauthenticated + developers. + enum: + - public + - private + maxLength: 256 + type: string + description: + description: A description of the portal. + maxLength: 512 + type: string + display_name: + description: |- + The display name of the portal. + This value will be the portal's `name` in Portal API. + maxLength: 255 + minLength: 1 + type: string + labels: + additionalProperties: + type: string + description: |- + Labels store metadata of an entity that can be used for filtering an entity + list or for searching across entity types. + + Labels are intended to store **INTERNAL** metadata. + + Keys must be of length 1-63 characters, and cannot start with "kong", + "konnect", "mesh", "kic", or "_". + type: object + name: + description: |- + The name of the portal, used to distinguish it from other portals. + Name must be unique. + maxLength: 255 + minLength: 1 + type: string + x-kubernetes-validations: + - message: name is immutable + rule: self == oldSelf + rbac_enabled: + default: false + description: |- + Whether the portal resources are protected by Role Based Access Control + (RBAC). + If enabled, developers view or register for APIs until unless assigned to + teams with access to view and consume specific APIs. + Authentication must be enabled to use RBAC. + type: boolean + required: + - name + type: object + type: object + status: + description: PortalStatus defines the observed state of Portal. + properties: + conditions: + description: Conditions represent the current state of the resource. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + id: + description: |- + ID is the unique identifier of the Konnect entity as assigned by Konnect API. + If it's unset (empty string), it means the Konnect entity hasn't been created yet. + maxLength: 256 + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + format: int64 + type: integer + organizationID: + description: OrgID is ID of Konnect Org that this entity has been + created in. + maxLength: 256 + type: string + serverURL: + description: ServerURL is the URL of the Konnect server in which the + entity exists. + maxLength: 512 + type: string + type: object + required: + - metadata + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/kong-operator/x-konnect.konghq.com_portalteams.yaml b/config/crd/kong-operator/x-konnect.konghq.com_portalteams.yaml new file mode 100644 index 0000000000..fdeeb1b37c --- /dev/null +++ b/config/crd/kong-operator/x-konnect.konghq.com_portalteams.yaml @@ -0,0 +1,232 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + kubernetes-configuration.konghq.com/channels: kong-operator + kubernetes-configuration.konghq.com/version: v2.1.0 + name: portalteams.x-konnect.konghq.com +spec: + group: x-konnect.konghq.com + names: + kind: PortalTeam + listKind: PortalTeamList + plural: portalteams + singular: portalteam + scope: Namespaced + versions: + - additionalPrinterColumns: + - description: Konnect ID + jsonPath: .status.id + name: ID + type: string + - description: The Resource is Programmed on Konnect + jsonPath: .status.conditions[?(@.type=='Programmed')].status + name: Programmed + type: string + - description: Konnect Organization ID this resource belongs to. + jsonPath: .status.organizationID + name: OrgID + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: PortalTeam is the Schema for the portalteams API. + 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: + type: object + spec: + description: PortalTeamSpec defines the desired state of PortalTeam. + properties: + apiSpec: + description: APISpec defines the desired state of the resource's API + spec fields. + properties: + description: + maxLength: 250 + type: string + name: + maxLength: 256 + minLength: 1 + pattern: ^[\w \W]+$ + type: string + required: + - name + type: object + portal_ref: + description: PortalRef is the reference to the parent Portal object. + properties: + konnectID: + description: |- + KonnectID is the schema for the KonnectID type. + This field is required when the Type is konnectID. + maxLength: 36 + type: string + namespacedRef: + description: |- + NamespacedRef is a reference to a KeySet entity inside the cluster. + This field is required when the Type is namespacedRef. + properties: + name: + description: Name is the name of the referred resource. + maxLength: 253 + minLength: 1 + type: string + namespace: + description: |- + Namespace is the namespace of the referred resource. + + For namespace-scoped resources if no Namespace is provided then the + namespace of the parent object MUST be used. + + This field MUST not be set when referring to cluster-scoped resources. + maxLength: 253 + type: string + required: + - name + type: object + type: + description: |- + Type defines type of the object which is referenced. It can be one of: + + - konnectID + - namespacedRef + enum: + - konnectID + - namespacedRef + type: string + required: + - type + type: object + x-kubernetes-validations: + - message: when type is namespacedRef, namespacedRef must be set + rule: 'self.type == ''namespacedRef'' ? has(self.namespacedRef) + : true' + - message: when type is namespacedRef, konnectID must not be set + rule: 'self.type == ''namespacedRef'' ? !has(self.konnectID) : true' + - message: when type is konnectID, konnectID must be set + rule: 'self.type == ''konnectID'' ? has(self.konnectID) : true' + - message: when type is konnectID, namespacedRef must not be set + rule: 'self.type == ''konnectID'' ? !has(self.namespacedRef) : true' + required: + - portal_ref + type: object + status: + description: PortalTeamStatus defines the observed state of PortalTeam. + properties: + conditions: + description: Conditions represent the current state of the resource. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + maxItems: 8 + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + id: + description: |- + ID is the unique identifier of the Konnect entity as assigned by Konnect API. + If it's unset (empty string), it means the Konnect entity hasn't been created yet. + maxLength: 256 + type: string + observedGeneration: + description: ObservedGeneration is the most recent generation observed + format: int64 + type: integer + organizationID: + description: OrgID is ID of Konnect Org that this entity has been + created in. + maxLength: 256 + type: string + portalID: + description: PortalID is the Konnect ID of the parent Portal. + properties: + id: + description: ID is the unique identifier of the Konnect entity + as assigned by Konnect API. + maxLength: 256 + type: string + type: object + serverURL: + description: ServerURL is the URL of the Konnect server in which the + entity exists. + maxLength: 512 + type: string + type: object + required: + - metadata + type: object + served: true + storage: true + subresources: + status: {} diff --git a/crd-from-oas/.gitattributes b/crd-from-oas/.gitattributes new file mode 100644 index 0000000000..9dfd33fd3f --- /dev/null +++ b/crd-from-oas/.gitattributes @@ -0,0 +1 @@ +**/zz_generated.*.go linguist-generated=true diff --git a/crd-from-oas/.gitignore b/crd-from-oas/.gitignore new file mode 100644 index 0000000000..1d74e21965 --- /dev/null +++ b/crd-from-oas/.gitignore @@ -0,0 +1 @@ +.vscode/ diff --git a/crd-from-oas/.golangci-kube-api.yml b/crd-from-oas/.golangci-kube-api.yml new file mode 100644 index 0000000000..51bb199042 --- /dev/null +++ b/crd-from-oas/.golangci-kube-api.yml @@ -0,0 +1,73 @@ +version: "2" +linters: + default: none + enable: + - kubeapilinter + settings: + custom: + kubeapilinter: + type: module + description: Kube API LInter lints Kube like APIs based on API conventions and best practices. + settings: + linters: + disable: + - "*" + enable: + - arrayofstruct + - conditions + - defaultorrequired + - duplicatemarkers + - integers + - jsontags + - maxlength + # NOTE: We disable nobools as we are directly generating CRDs from OAS + # and those bools will be used directly in API calls to Konnect. + # - nobools + - nodurations + - nofloats + - nomaps + - nonullable + - nophase + - notimestamp + - optionalorrequired + - preferredmarkers + - requiredfields + - statusoptional + - statussubresource + - uniquemarkers + lintersConfig: + # https://github.com/kubernetes-sigs/kube-api-linter/issues/195 + # requiredfields: + # omitempty: + # policy: Ignore + preferredmarkers: + markers: + - preferredIdentifier: "k8s:optional" + equivalentIdentifiers: + - identifier: "kubebuilder:validation:Optional" + - preferredIdentifier: "k8s:required" + equivalentIdentifiers: + - identifier: "kubebuilder:validation:Required" + conditions: + isFirstField: Warn + useProtobuf: Warn + usePatchStrategy: Warn + nomaps: + policy: AllowStringToStringMaps + # We allow underscores as that's what many Konnect related fields use. + jsonTags: + jsonTagRegex: "^[a-z][a-z0-9_]*(?:[A-Z][a-z0-9_]*)*$" + optionalOrRequired: + preferredOptionalMarker: optional + preferredRequiredMarker: required + exclusions: + rules: + - linters: + - kubeapilinter + path: .* + text: "(optionalorrequired: embedded field .*.metav1.ObjectMeta must be marked as optional or required)" + # NOTE: schema_types.go contains generated type aliases for referenced schemas + # that need additional work to add proper markers. + - linters: + - kubeapilinter + path: "schema_types.go" diff --git a/crd-from-oas/.golangci.yml b/crd-from-oas/.golangci.yml new file mode 100644 index 0000000000..d085f40c24 --- /dev/null +++ b/crd-from-oas/.golangci.yml @@ -0,0 +1,145 @@ +version: "2" +run: + timeout: 8m +linters: + enable: + - asciicheck + - bodyclose + - copyloopvar + - dogsled + - durationcheck + - errorlint + - exhaustive + - forbidigo + - gocritic + - gomodguard + - gosec + - importas + - loggercheck + - misspell + - nakedret + - nilerr + - nolintlint + - predeclared + - revive + # Uncomment or remove - TODO: https://github.com/Kong/kong-operator/issues/1847 + # - testifylint + - unconvert + - unused + - unparam + - usetesting + - wastedassign + settings: + staticcheck: + checks: + - all + # Incorrect or missing package comment. + # https://staticcheck.dev/docs/checks/#ST1000 + - -ST1000 + # Incorrectly formatted error string. + # https://staticcheck.dev/docs/checks/#ST1005 + - -ST1005 + # Underscore in the name of a package. + # https://staticcheck.dev/docs/checks/#ST1003 + - -ST1003 + exhaustive: + default-signifies-exhaustive: true + gomodguard: + blocked: + modules: + - golang.org/x/exp: + recommendations: + - maps + - slices + - github.com/samber/lo + - github.com/pkg/errors: + recommendations: + - fmt + - errors + - github.com/sirupsen/logrus: + recommendations: + - sigs.k8s.io/controller-runtime/pkg/log + - go.uber.org/zap/zapcore + govet: + disable: + - fieldalignment + - shadow + enable-all: true + importas: + no-unaliased: true + revive: + rules: + - name: errorf + severity: warning + disabled: false + - name: error-strings + severity: warning + disabled: false + - name: error-naming + severity: warning + disabled: false + - name: duplicated-imports + severity: warning + disabled: false + - name: empty-block + severity: warning + disabled: false + - name: exported + arguments: + - checkPrivateReceivers + - disableStutteringCheck + severity: warning + disabled: false + - name: context-as-argument + disabled: true + testifylint: + enable-all: true + disable: + - error-is-as + usetesting: + os-temp-dir: true + exclusions: + generated: lax + presets: + - common-false-positives + - legacy + - std-error-handling + rules: + - linters: + - revive + path: internal/|pkg/|test/ + text: 'exported: exported' + - linters: + - gosec + path: .*_test\.go + text: Use of weak random number generator + - linters: + - gosec + path: .*_test\.go + text: Potential hardcoded credentials + - linters: + - gosec + path: .*_test\.go + text: Potential HTTP request made with variable url + - linters: + - gosec + text: integer overflow conversion + paths: + - pkg/clientset + - config/ + - examples$ +issues: + max-same-issues: 0 + fix: true +formatters: + enable: + - gci + - gofmt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/kong/kong-operator) + custom-order: true diff --git a/crd-from-oas/AGENTS.md b/crd-from-oas/AGENTS.md new file mode 100644 index 0000000000..d7e204a2c9 --- /dev/null +++ b/crd-from-oas/AGENTS.md @@ -0,0 +1,44 @@ +# AGENTS.md + +This file provides guidance to Claude Code (claude.ai/code) and other AI agents when working with code in this repository. + +## Project Overview + +This project generates Kubernetes Custom Resource Definitions (CRDs) from OpenAPI Specifications (OAS). +It includes tools for code generation, linting, and testing to ensure high-quality code and maintainability. + +## Build commands + +```bash +mise r build # Build the manager binary (includes code generation) +``` + +## Generation + +```bash +mise r generate-api # Generate Kubernetes CRDs from OpenAPI Specifications +``` + +## Linting + +```bash +mise r lint # Run Go linters (modules, golangci-lint, modernize) +mise r lint.api # Lint Kubernetes API types +``` + +## Testing + +### Unit Tests + +```bash +mise r test-unit # Run unit tests with verbose output +``` + +## Instructions + +- Prefer to use `mise r generate-api` to generate Kubernetes CRDs from OpenAPI spec + rather than building the project with `mise r build` to avoid unnecessary compilation of the manager binary. +- To run the whole pipeline from generation, through linting and testing, use: + ``` + mise r all + ``` diff --git a/crd-from-oas/CLAUDE.md b/crd-from-oas/CLAUDE.md new file mode 100644 index 0000000000..43c994c2d3 --- /dev/null +++ b/crd-from-oas/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/crd-from-oas/README.md b/crd-from-oas/README.md new file mode 100644 index 0000000000..c1e5d07ba7 --- /dev/null +++ b/crd-from-oas/README.md @@ -0,0 +1,7 @@ +## TODO + +### TODOs + +- Generated conversion functions unit tests have to check the actual conversion logic, not just the presence of the functions and that not error has been returned. + +- Research feasibility of generating CRD validation tests from OAS spec. If feasible, implement it. If not, add scaoffolding generation which will require users to fill in the test cases manually. diff --git a/crd-from-oas/config.yaml b/crd-from-oas/config.yaml new file mode 100644 index 0000000000..eec99599ae --- /dev/null +++ b/crd-from-oas/config.yaml @@ -0,0 +1,26 @@ +apiGroupVersions: + # TODO: temporary + x-konnect.konghq.com/v1alpha1: + commonTypes: + objectRef: + import: + path: github.com/kong/kong-operator/v2/api/common/v1alpha1 + alias: commonv1alpha1 + types: + - path: /v1/event-gateways + - path: /v3/portals + cel: + name: + _validations: + - "+kubebuilder:validation:XValidation:rule=\"self == oldSelf\",message=\"name is immutable\"" + ops: + create: + path: github.com/Kong/sdk-konnect-go/models/components.CreatePortal + update: + path: github.com/Kong/sdk-konnect-go/models/components.UpdatePortal + - path: /v3/portals/{portalId}/teams + # TODO: uncomment + # - path: /v3/portals/{portalId}/custom-domain + # - path: /v3/identity-providers + # - path: /v2/dcr-providers + # - path: /v3/portals/{portalId}/teams/{teamId}/developers diff --git a/crd-from-oas/go.mod b/crd-from-oas/go.mod new file mode 100644 index 0000000000..12937ec046 --- /dev/null +++ b/crd-from-oas/go.mod @@ -0,0 +1,275 @@ +module github.com/kong/kong-operator/v2/crd-from-oas + +go 1.26.1 + +require ( + github.com/caarlos0/env/v11 v11.3.1 + github.com/getkin/kin-openapi v0.133.0 + github.com/kong/kong-operator/v2 v2.0.0-00000000000000-000000000000 + github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.35.2 + k8s.io/apimachinery v0.35.2 + k8s.io/client-go v0.35.2 + sigs.k8s.io/controller-runtime v0.23.3 +) + +require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go/auth v0.18.2 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + cloud.google.com/go/container v1.46.0 // indirect + dario.cat/mergo v1.0.2 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect + github.com/Kong/go-diff v1.2.2 // indirect + github.com/Kong/gojsondiff v1.3.2 // indirect + github.com/Kong/sdk-konnect-go v0.26.1 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/adrg/strutil v0.3.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/avast/retry-go/v4 v4.7.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/bombsimon/logrusr/v3 v3.1.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cert-manager/cert-manager v1.19.4 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/cloudflare/cfssl v1.6.5 // indirect + github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect + github.com/cpuguy83/dockercfg v0.3.2 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/docker v28.5.2+incompatible // indirect + github.com/docker/go-connections v0.6.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dominikbraun/graph v0.23.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/ettle/strcase v0.2.0 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect + github.com/fatih/camelcase v1.0.0 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/gammazero/deque v1.2.1 // indirect + github.com/gammazero/workerpool v1.2.1 // indirect + github.com/go-errors/errors v1.4.2 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/swag v0.23.1 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/goccy/go-json v0.10.5 // indirect + github.com/gohugoio/hashstructure v0.6.0 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.26.0 // indirect + github.com/google/certificate-transparency-go v1.3.1 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-containerregistry v0.21.2 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.12 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-immutable-radix v1.3.1 // indirect + github.com/hashicorp/go-memdb v1.3.5 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/golang-lru v1.0.2 // indirect + github.com/hexops/gotextdiff v1.0.3 // indirect + github.com/imdario/mergo v0.3.16 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/itchyny/gojq v0.12.17 // indirect + github.com/itchyny/timefmt-go v0.1.6 // indirect + github.com/jmoiron/sqlx v1.3.5 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/jpillora/backoff v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.4 // indirect + github.com/kong/go-database-reconciler v1.32.0 // indirect + github.com/kong/go-kong v0.72.1 // indirect + github.com/kong/kubernetes-telemetry v0.1.13 // indirect + github.com/kong/kubernetes-testing-framework v0.48.1-0.20260114143846-8c0e96b5bf82 // indirect + github.com/kong/semver/v4 v4.0.1 // indirect + github.com/kr/pretty v0.3.1 // indirect + github.com/kr/text v0.2.0 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a // indirect + github.com/magiconair/properties v1.8.10 // indirect + github.com/mailru/easyjson v0.9.1 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/go-archive v0.1.0 // indirect + github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/sys/sequential v0.6.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect + github.com/moby/sys/userns v0.1.0 // indirect + github.com/moby/term v0.5.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect + github.com/morikuni/aec v1.0.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect + github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect + github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/samber/lo v1.53.0 // indirect + github.com/samber/mo v1.16.0 // indirect + github.com/sethvargo/go-password v0.3.1 // indirect + github.com/shirou/gopsutil/v3 v3.24.5 // indirect + github.com/shirou/gopsutil/v4 v4.25.6 // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/spyzhov/ajson v0.8.0 // indirect + github.com/ssgelm/cookiejarparser v1.0.1 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/testcontainers/testcontainers-go v0.40.0 // indirect + github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 // indirect + github.com/tidwall/gjson v1.18.0 // indirect + github.com/tidwall/match v1.1.1 // indirect + github.com/tidwall/pretty v1.2.1 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/weppos/publicsuffix-go v0.30.0 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/xlab/treeprint v1.2.0 // indirect + github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + github.com/zmap/zcrypto v0.0.0-20230310154051-c8b263fd8300 // indirect + github.com/zmap/zlint/v3 v3.5.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect + go.opentelemetry.io/proto/otlp v1.7.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect + golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect + golang.org/x/mod v0.34.0 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/term v0.40.0 // indirect + golang.org/x/text v0.34.0 // indirect + golang.org/x/time v0.14.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/api v0.269.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect + google.golang.org/grpc v1.79.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/apiextensions-apiserver v0.35.1 // indirect + k8s.io/apiserver v0.35.2 // indirect + k8s.io/cli-runtime v0.35.2 // indirect + k8s.io/component-base v0.35.2 // indirect + k8s.io/component-helpers v0.35.2 // indirect + k8s.io/controller-manager v0.0.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/kubectl v0.35.0 // indirect + k8s.io/kubernetes v1.35.2 // indirect + k8s.io/utils v0.0.0-20260108192941-914a6e750570 // indirect + oras.land/oras-go/v2 v2.6.0 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 // indirect + sigs.k8s.io/gateway-api v1.5.0 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kind v0.31.0 // indirect + sigs.k8s.io/kustomize/api v0.21.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.21.1 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) + +replace ( + github.com/kong/kong-operator/v2 => ../ + k8s.io/api => k8s.io/api v0.35.0 + k8s.io/apiextensions-apiserver => k8s.io/apiextensions-apiserver v0.35.0 + k8s.io/apimachinery => k8s.io/apimachinery v0.35.0 + k8s.io/apiserver => k8s.io/apiserver v0.35.0 + k8s.io/cli-runtime => k8s.io/cli-runtime v0.35.0 + k8s.io/client-go => k8s.io/client-go v0.35.0 + k8s.io/cloud-provider => k8s.io/cloud-provider v0.35.0 + k8s.io/cluster-bootstrap => k8s.io/cluster-bootstrap v0.35.0 + k8s.io/code-generator => k8s.io/code-generator v0.35.0 + k8s.io/component-base => k8s.io/component-base v0.35.0 + k8s.io/component-helpers => k8s.io/component-helpers v0.35.0 + k8s.io/controller-manager => k8s.io/controller-manager v0.35.0 + k8s.io/cri-api => k8s.io/cri-api v0.35.0 + k8s.io/cri-client => k8s.io/cri-client v0.35.0 + k8s.io/csi-translation-lib => k8s.io/csi-translation-lib v0.35.0 + k8s.io/dynamic-resource-allocation => k8s.io/dynamic-resource-allocation v0.35.0 + k8s.io/endpointslice => k8s.io/endpointslice v0.35.0 + k8s.io/externaljwt => k8s.io/externaljwt v0.35.0 + k8s.io/kms => k8s.io/kms v0.35.0 + k8s.io/kube-aggregator => k8s.io/kube-aggregator v0.35.0 + k8s.io/kube-controller-manager => k8s.io/kube-controller-manager v0.35.0 + k8s.io/kube-proxy => k8s.io/kube-proxy v0.35.0 + k8s.io/kube-scheduler => k8s.io/kube-scheduler v0.35.0 + k8s.io/kubectl => k8s.io/kubectl v0.35.0 + k8s.io/kubelet => k8s.io/kubelet v0.35.0 + k8s.io/legacy-cloud-providers => k8s.io/legacy-cloud-providers v0.30.3 + k8s.io/metrics => k8s.io/metrics v0.35.0 + k8s.io/mount-utils => k8s.io/mount-utils v0.35.0 + k8s.io/pod-security-admission => k8s.io/pod-security-admission v0.35.0 + k8s.io/sample-apiserver => k8s.io/sample-apiserver v0.35.0 + k8s.io/sample-cli-plugin => k8s.io/sample-cli-plugin v0.35.0 + k8s.io/sample-controller => k8s.io/sample-controller v0.35.0 +) diff --git a/crd-from-oas/go.sum b/crd-from-oas/go.sum new file mode 100644 index 0000000000..da43820471 --- /dev/null +++ b/crd-from-oas/go.sum @@ -0,0 +1,669 @@ +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/container v1.46.0 h1:xX94Lo3xrS5OkdMWKvpEVAbBwjN9uleVv6vOi02fL4s= +cloud.google.com/go/container v1.46.0/go.mod h1:A7gMqdQduTk46+zssWDTKbGS2z46UsJNXfKqvMI1ZO4= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0= +github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Kong/go-diff v1.2.2 h1:KKKaqHc8IxuguFVIZMNt3bi6YuC/t9r7BGD8bOOpSgM= +github.com/Kong/go-diff v1.2.2/go.mod h1:nlvdwVZQk3Rm+tbI0cDmKFrOjghtcZTrZBp+UruvvA8= +github.com/Kong/gojsondiff v1.3.2 h1:qIOVq2mUXt+NXy8Be5gRUee9TP3Ve0MbQSafg9bXKZE= +github.com/Kong/gojsondiff v1.3.2/go.mod h1:DiIxtU59q4alK7ecP+7k56C5UjgOviJ5gQVR2esEhYw= +github.com/Kong/sdk-konnect-go v0.26.1 h1:kQ3waDE57kglUBNOxv9xepGUXQEMfVq+MJmLsh7A2gI= +github.com/Kong/sdk-konnect-go v0.26.1/go.mod h1:QKGFvWuXu3qPLAlIr0meGWmqw4XBy5v+HLSQjxbbNHw= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/adrg/strutil v0.3.0 h1:bi/HB2zQbDihC8lxvATDTDzkT4bG7PATtVnDYp5rvq4= +github.com/adrg/strutil v0.3.0/go.mod h1:Jz0wzBVE6Uiy9wxo62YEqEY1Nwto3QlLl1Il5gkLKWU= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio= +github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bombsimon/logrusr/v3 v3.1.0 h1:zORbLM943D+hDMGgyjMhSAz/iDz86ZV72qaak/CA0zQ= +github.com/bombsimon/logrusr/v3 v3.1.0/go.mod h1:PksPPgSFEL2I52pla2glgCyyd2OqOHAnFF5E+g8Ixco= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/cert-manager/cert-manager v1.19.4 h1:7lOkSYj+nJNjgGFfAznQzPpOfWX+1Kgz6xUXwTa/K5k= +github.com/cert-manager/cert-manager v1.19.4/go.mod h1:9uBnn3IK9NxjjuXmQDYhwOwFUU5BtGVB1g/voPvvcVw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/cloudflare/cfssl v1.6.5 h1:46zpNkm6dlNkMZH/wMW22ejih6gIaJbzL2du6vD7ZeI= +github.com/cloudflare/cfssl v1.6.5/go.mod h1:Bk1si7sq8h2+yVEDrFJiz3d7Aw+pfjjJSZVaD+Taky4= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a h1:Ohw57yVY2dBTt+gsC6aZdteyxwlxfbtgkFEMTEkwgSw= +github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a/go.mod h1:pCxVEbcm3AMg7ejXyorUXi6HQCzOIBf7zEDVPtw0/U4= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo= +github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= +github.com/envoyproxy/go-control-plane/envoy v1.36.0 h1:yg/JjO5E7ubRyKX3m07GF3reDNEnfOboJ0QySbH736g= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/ettle/strcase v0.2.0 h1:fGNiVF21fHXpX1niBgk0aROov1LagYsOwV/xqKDKR/Q= +github.com/ettle/strcase v0.2.0/go.mod h1:DajmHElDSaX76ITe3/VHVyMin4LWSJN5Z909Wp+ED1A= +github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= +github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= +github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= +github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gammazero/deque v1.2.1 h1:9fnQVFCCZ9/NOc7ccTNqzoKd1tCWOqeI05/lPqFPMGQ= +github.com/gammazero/deque v1.2.1/go.mod h1:5nSFkzVm+afG9+gy0VIowlqVAW4N8zNcMne+CMQVD2g= +github.com/gammazero/workerpool v1.2.1 h1:MEDvUJsNYGuCvl1RwIXNKu2YtQtHqCSF9XWF04N7lqs= +github.com/gammazero/workerpool v1.2.1/go.mod h1:E32GVRUanF4d6QtRmdss3AScgaDkIyrvPtgRQUWgmx4= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/swag v0.23.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/gohugoio/hashstructure v0.6.0 h1:7wMB/2CfXoThFYhdWRGv3u3rUM761Cq29CxUW+NltUg= +github.com/gohugoio/hashstructure v0.6.0/go.mod h1:lapVLk9XidheHG1IQ4ZSbyYrXcaILU1ZEP/+vno5rBQ= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= +github.com/google/certificate-transparency-go v1.3.1 h1:akbcTfQg0iZlANZLn0L9xOeWtyCIdeoYhKrqi5iH3Go= +github.com/google/certificate-transparency-go v1.3.1/go.mod h1:gg+UQlx6caKEDQ9EElFOujyxEQEfOiQzAt6782Bvi8k= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.2 h1:vYaMU4nU55JJGFC9JR/s8NZcTjbE9DBBbvusTW9NeS0= +github.com/google/go-containerregistry v0.21.2/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0= +github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY= +github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ= +github.com/google/go-github/v48 v48.2.0 h1:68puzySE6WqUY9KWmpOsDEQfDZsso98rT6pZcz9HqcE= +github.com/google/go-github/v48 v48.2.0/go.mod h1:dDlehKBDo850ZPvCTK0sEqTCVWcrGl2LcDiajkYi89Y= +github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= +github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 h1:z2ogiKUYzX5Is6zr/vP9vJGqPwcdqsWjOt+V8J7+bTc= +github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.12 h1:Fg+zsqzYEs1ZnvmcztTYxhgCBsx3eEhEwQ1W/lHq/sQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.12/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc= +github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60= +github.com/hashicorp/go-memdb v1.3.5 h1:b3taDMxCBCBVgyRrS1AZVHO14ubMYZB++QpNhBg+Nyo= +github.com/hashicorp/go-memdb v1.3.5/go.mod h1:8IVKKBkVe+fxFgdFOYxzQQNjz+sWCyHCdIC/+5+Vy1Y= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-uuid v1.0.0 h1:RS8zrF7PhGwyNPOtxSClXXj9HA8feRnJzgnI1RJCSnM= +github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= +github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg= +github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY= +github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q= +github.com/itchyny/timefmt-go v0.1.6/go.mod h1:RRDZYC5s9ErkjQvTvvU7keJjxUYzIISJGxm9/mAERQg= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= +github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= +github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/kong/go-database-reconciler v1.32.0 h1:xjRBDBlNRWd+rxOJEOp9tvBBD2kBU3l37qd5NcsPZtI= +github.com/kong/go-database-reconciler v1.32.0/go.mod h1:zdxuIftbmygObEnixiW3fNnWar+8KejqsfruhsV4juQ= +github.com/kong/go-kong v0.72.1 h1:rQ69f3Wd0Fvc3JANkavo34vePqR4uZG/YQ2y5U7d2Po= +github.com/kong/go-kong v0.72.1/go.mod h1:J0vGB3wsZ2i99zly1zTRe3v7rOKpkhQZRwbcTFP76qM= +github.com/kong/kubernetes-telemetry v0.1.13 h1:Cx8zt6udtzYo95KMGMMeguQWPqF3OeNlpqUcxW32Yrs= +github.com/kong/kubernetes-telemetry v0.1.13/go.mod h1:fYOEST9ZVpIC+qFLyyI2e11qhkYFYcVKCAQEjWIQ1jM= +github.com/kong/kubernetes-testing-framework v0.48.1-0.20260114143846-8c0e96b5bf82 h1:GXO5LdlBxr/4kjZITg+Y/QXUM+23bC4YF3YZ6uBtu6M= +github.com/kong/kubernetes-testing-framework v0.48.1-0.20260114143846-8c0e96b5bf82/go.mod h1:7d83ezAr/OJHKmB93k4NeA51SId8znXX0JqnLf3O9H4= +github.com/kong/semver/v4 v4.0.1 h1:DIcNR8W3gfx0KabFBADPalxxsp+q/5COwIFkkhrFQ2Y= +github.com/kong/semver/v4 v4.0.1/go.mod h1:LImQ0oT15pJvSns/hs2laLca2zcYoHu5EsSNY0J6/QA= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lithammer/dedent v1.1.0 h1:VNzHMVCBNG1j0fh3OrsFRkVUwStdDArbgBWoPAffktY= +github.com/lithammer/dedent v1.1.0/go.mod h1:jrXYCQtgg0nJiN+StA2KgR7w6CiQNv9Fd/Z9BP0jIOc= +github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a h1:N9zuLhTvBSRt0gWSiJswwQ2HqDmtX/ZCDJURnKUt1Ik= +github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a/go.mod h1:JKx41uQRwqlTZabZc+kILPrO/3jlKnQ2Z8b7YiVw5cE= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI= +github.com/mdelapenya/tlscert v0.2.0/go.mod h1:O4njj3ELLnJjGdkN7M/vIVCpZ+Cf0L6muqOG4tLSl8o= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= +github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= +github.com/mreiferson/go-httpclient v0.0.0-20201222173833-5e475fde3a4d/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onsi/ginkgo v1.1.1-0.20150303023352-38caab951a9f h1:znMoXRti03ZIy/gaW5cl3EO2A5zqzHBKA6uMnrAQDE0= +github.com/onsi/ginkgo v1.1.1-0.20150303023352-38caab951a9f/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo/v2 v2.28.0 h1:Rrf+lVLmtlBIKv6KrIGJCjyY8N36vDVcutbGJkyqjJc= +github.com/onsi/ginkgo/v2 v2.28.0/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.1 h1:1IJLAad4zjPn2PsnhH70V4DKRFlrCzGBNrNaru+Vf28= +github.com/onsi/gomega v1.39.1/go.mod h1:hL6yVALoTOxeWudERyfppUcZXjMwIMLnuSfruD2lcfg= +github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b h1:0LFwY6Q3gMACTjAbMZBjXAqTOzOwFaj2Ld6cjeQ7Rig= +github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= +github.com/puzpuzpuz/xsync/v2 v2.5.1/go.mod h1:gD2H2krq/w52MfPLE+Uy64TzJDVY7lP2znR9qmR35kU= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.53.0 h1:t975lj2py4kJPQ6haz1QMgtId2gtmfktACxIXArw3HM= +github.com/samber/lo v1.53.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= +github.com/samber/mo v1.16.0 h1:qpEPCI63ou6wXlsNDMLE0IIN8A+devbGX/K1xdgr4b4= +github.com/samber/mo v1.16.0/go.mod h1:DlgzJ4SYhOh41nP1L9kh9rDNERuf8IqWSAs+gj2Vxag= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/sethvargo/go-password v0.3.1 h1:WqrLTjo7X6AcVYfC6R7GtSyuUQR9hGyAj/f1PYQZCJU= +github.com/sethvargo/go-password v0.3.1/go.mod h1:rXofC1zT54N7R8K/h1WDUdkf9BOx5OptoxrMBcrXzvs= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs= +github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/sirupsen/logrus v1.3.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spyzhov/ajson v0.8.0 h1:sFXyMbi4Y/BKjrsfkUZHSjA2JM1184enheSjjoT/zCc= +github.com/spyzhov/ajson v0.8.0/go.mod h1:63V+CGM6f1Bu/p4nLIN8885ojBdt88TbLoSFzyqMuVA= +github.com/ssgelm/cookiejarparser v1.0.1 h1:cRdXauUbOTFzTPJFaeiWbHnQ+tRGlpKKzvIK9PUekE4= +github.com/ssgelm/cookiejarparser v1.0.1/go.mod h1:DUfC0mpjIzlDN7DzKjXpHj0qMI5m9VrZuz3wSlI+OEI= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU= +github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk= +github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/tonglil/buflogr v1.1.1 h1:CKAjOHBSMmgbRFxpn/RhQHPj5oANc7ekhlsoUDvcZIg= +github.com/tonglil/buflogr v1.1.1/go.mod h1:WLLtPRLqcFYWQLbA+ytXy5WrFTYnfA+beg1MpvJCxm4= +github.com/ugorji/go/codec v1.2.7 h1:YPXUKf7fYbp/y8xloBqZOw2qaVggbfwMlI8WM3wZUJ0= +github.com/ugorji/go/codec v1.2.7/go.mod h1:WGN1fab3R1fzQlVQTkfxVtIBhWDRqOviHU95kRgeqEY= +github.com/weppos/publicsuffix-go v0.12.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= +github.com/weppos/publicsuffix-go v0.13.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k= +github.com/weppos/publicsuffix-go v0.30.0 h1:QHPZ2GRu/YE7cvejH9iyavPOkVCB4dNxp2ZvtT+vQLY= +github.com/weppos/publicsuffix-go v0.30.0/go.mod h1:kBi8zwYnR0zrbm8RcuN1o9Fzgpnnn+btVN8uWPMyXAY= +github.com/weppos/publicsuffix-go/publicsuffix/generator v0.0.0-20220927085643-dc0d00c92642/go.mod h1:GHfoeIdZLdZmLjMlzBftbTDntahTttUMWjxZwQJhULE= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82 h1:BHyfKlQyqbsFN5p3IfnEUduWvb9is428/nNb5L3U01M= +github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM= +github.com/yudai/pp v2.0.2-0.20150410014804-be8315415630+incompatible h1:TmF93o7P81230DTx1l2zw5rZbsDpOOQXoKVCa8+nXXI= +github.com/yudai/pp v2.0.2-0.20150410014804-be8315415630+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= +github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE= +github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is= +github.com/zmap/zcertificate v0.0.1/go.mod h1:q0dlN54Jm4NVSSuzisusQY0hqDWvu92C+TWveAxiVWk= +github.com/zmap/zcrypto v0.0.0-20201128221613-3719af1573cf/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ= +github.com/zmap/zcrypto v0.0.0-20201211161100-e54a5822fb7e/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ= +github.com/zmap/zcrypto v0.0.0-20230310154051-c8b263fd8300 h1:DZH5n7L3L8RxKdSyJHZt7WePgwdhHnPhQFdQSJaHF+o= +github.com/zmap/zcrypto v0.0.0-20230310154051-c8b263fd8300/go.mod h1:mOd4yUMgn2fe2nV9KXsa9AyQBFZGzygVPovsZR+Rl5w= +github.com/zmap/zlint/v3 v3.0.0/go.mod h1:paGwFySdHIBEMJ61YjoqT4h7Ge+fdYG4sUQhnTb1lJ8= +github.com/zmap/zlint/v3 v3.5.0 h1:Eh2B5t6VKgVH0DFmTwOqE50POvyDhUaU9T2mJOe1vfQ= +github.com/zmap/zlint/v3 v3.5.0/go.mod h1:JkNSrsDJ8F4VRtBZcYUQSvnWFL7utcjDIn+FE64mlBI= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0 h1:EtFWSnwW9hGObjkIdmlnWSydO+Qs8OwzfzXLUPg4xOc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.37.0/go.mod h1:QjUEoiGCPkvFZ/MjK6ZZfNOS6mfVEVKYE99dFhuN2LI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0 h1:wpMfgF8E1rkrT1Z6meFh1NDtownE9Ii3n3X2GJYjsaU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.33.0/go.mod h1:wAy0T/dUbs468uOlkT31xjvqQgEVXv58BRFWEgn5v/0= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= +go.opentelemetry.io/proto/otlp v1.7.0 h1:jX1VolD6nHuFzOYso2E73H85i92Mv8JQYk0K9vz09os= +go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= +go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= +golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20201124201722-c8d3bf9c5392/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= +golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= +golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191027093000-83d349e8ac1a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201126233918-771906719818/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= +golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.269.0 h1:qDrTOxKUQ/P0MveH6a7vZ+DNHxJQjtGm/uvdbdGXCQg= +google.golang.org/api v0.269.0/go.mod h1:N8Wpcu23Tlccl0zSHEkcAZQKDLdquxK+l9r2LkwAauE= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 h1:VQZ/yAbAtjkHgH80teYd2em3xtIkkHd7ZhqfH2N9CsM= +google.golang.org/genproto v0.0.0-20260128011058-8636f8732409/go.mod h1:rxKD3IEILWEu3P44seeNOAwZN4SaoKaQ/2eTg4mM6EM= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409 h1:merA0rdPeUV3YIIfHHcH4qBkiQAc1nfCKSI7lB4cV2M= +google.golang.org/genproto/googleapis/api v0.0.0-20260128011058-8636f8732409/go.mod h1:fl8J1IvUjCilwZzQowmw2b7HQB2eAuYBabMXzWurF+I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= +k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= +k8s.io/cli-runtime v0.35.0 h1:PEJtYS/Zr4p20PfZSLCbY6YvaoLrfByd6THQzPworUE= +k8s.io/cli-runtime v0.35.0/go.mod h1:VBRvHzosVAoVdP3XwUQn1Oqkvaa8facnokNkD7jOTMY= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= +k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= +k8s.io/component-helpers v0.35.0 h1:wcXv7HJRksgVjM4VlXJ1CNFBpyDHruRI99RrBtrJceA= +k8s.io/component-helpers v0.35.0/go.mod h1:ahX0m/LTYmu7fL3W8zYiIwnQ/5gT28Ex4o2pymF63Co= +k8s.io/controller-manager v0.35.0 h1:KteodmfVIRzfZ3RDaxhnHb72rswBxEngvdL9vuZOA9A= +k8s.io/controller-manager v0.35.0/go.mod h1:1bVuPNUG6/dpWpevsJpXioS0E0SJnZ7I/Wqc9Awyzm4= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kubectl v0.35.0 h1:cL/wJKHDe8E8+rP3G7avnymcMg6bH6JEcR5w5uo06wc= +k8s.io/kubectl v0.35.0/go.mod h1:VR5/TSkYyxZwrRwY5I5dDq6l5KXmiCb+9w8IKplk3Qo= +k8s.io/kubernetes v1.35.2 h1:2HthVDfK3YJYv624imuKXPzUJ17xQop9OT5dgT+IMKE= +k8s.io/kubernetes v1.35.2/go.mod h1:AaPpCpiS8oAqRbEwpY5r3RitLpwpVp5lVXKFkJril58= +k8s.io/utils v0.0.0-20260108192941-914a6e750570 h1:JT4W8lsdrGENg9W+YwwdLJxklIuKWdRm+BC+xt33FOY= +k8s.io/utils v0.0.0-20260108192941-914a6e750570/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0 h1:qPrZsv1cwQiFeieFlRqT627fVZ+tyfou/+S5S0H5ua0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.33.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.23.3 h1:VjB/vhoPoA9l1kEKZHBMnQF33tdCLQKJtydy4iqwZ80= +sigs.k8s.io/controller-runtime v0.23.3/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/gateway-api v1.5.0 h1:duoo14Ky/fJXpjpmyMISE2RTBGnfCg8zICfTYLTnBJA= +sigs.k8s.io/gateway-api v1.5.0/go.mod h1:GvCETiaMAlLym5CovLxGjS0NysqFk3+Yuq3/rh6QL2o= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kind v0.31.0 h1:UcT4nzm+YM7YEbqiAKECk+b6dsvc/HRZZu9U0FolL1g= +sigs.k8s.io/kind v0.31.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8= +sigs.k8s.io/kustomize/api v0.21.1 h1:lzqbzvz2CSvsjIUZUBNFKtIMsEw7hVLJp0JeSIVmuJs= +sigs.k8s.io/kustomize/api v0.21.1/go.mod h1:f3wkKByTrgpgltLgySCntrYoq5d3q7aaxveSagwTlwI= +sigs.k8s.io/kustomize/kyaml v0.21.1 h1:IVlbmhC076nf6foyL6Taw4BkrLuEsXUXNpsE+ScX7fI= +sigs.k8s.io/kustomize/kyaml v0.21.1/go.mod h1:hmxADesM3yUN2vbA5z1/YTBnzLJ1dajdqpQonwBL1FQ= +sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/crd-from-oas/main.go b/crd-from-oas/main.go new file mode 100644 index 0000000000..6de57e32db --- /dev/null +++ b/crd-from-oas/main.go @@ -0,0 +1,58 @@ +package main + +import ( + "context" + "log/slog" + "os" + + "github.com/caarlos0/env/v11" + + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/config" + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/run" +) + +type EnvConfig struct { + InputFile string `env:"INPUT_FILE" envDefault:"openapi.yaml"` + OutputDir string `env:"OUTPUT_DIR" envDefault:"api/"` + ConfigFile string `env:"CONFIG_FILE,required"` +} + +func main() { + logger := GetLogger() + + var cfg EnvConfig + if err := env.Parse(&cfg); err != nil { + logger.Error("failed to parse environment variables", "error", err) + os.Exit(1) + } + + // Load project config + projectCfg, err := config.LoadProjectConfig(cfg.ConfigFile) + if err != nil { + logger.Error("failed to load config file", "error", err) + os.Exit(1) + } + + runner, err := run.New(projectCfg, cfg.InputFile, cfg.OutputDir) + if err != nil { + logger.Error("failed to initialize runner", "error", err) + os.Exit(1) + } + + if err = runner.Run(context.Background(), logger); err != nil { + logger.Error("failed to run generator", "error", err) + os.Exit(1) + } + + logger.Info("done") +} + +func LogOptions() *slog.HandlerOptions { + return &slog.HandlerOptions{ + Level: slog.LevelInfo, + } +} + +func GetLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(os.Stderr, LogOptions())) +} diff --git a/crd-from-oas/pkg/config/config.go b/crd-from-oas/pkg/config/config.go new file mode 100644 index 0000000000..e1bb1e69b2 --- /dev/null +++ b/crd-from-oas/pkg/config/config.go @@ -0,0 +1,238 @@ +package config + +import ( + "fmt" + "os" + "strings" + + "gopkg.in/yaml.v3" +) + +// ProjectConfig is the top-level configuration loaded from the config file. +// It groups paths and entity configs by API group-version. +type ProjectConfig struct { + APIGroupVersions map[string]*APIGroupVersionConfig `yaml:"apiGroupVersions"` +} + +// APIGroupVersionConfig holds configuration for a single API group-version. +type APIGroupVersionConfig struct { + CommonTypes *CommonTypesConfig `yaml:"commonTypes,omitempty"` + + Types []*TypeConfig `yaml:"types"` +} + +// CommonTypesConfig holds configuration for common types that can be shared +// across multiple entities in the same API group-version. +type CommonTypesConfig struct { + ObjectRef *ObjectRefConfig `yaml:"objectRef,omitempty"` +} + +// ObjectRefConfig holds configuration for the common ObjectRef type, which +// can be generated locally or imported from an existing package. +type ObjectRefConfig struct { + // Generate indicates whether to generate a common ObjectRef type for + // referencing Kubernetes objects. + // If false, the generator assumes that the designated API group version + // already defines an ObjectRef type. + // Defaults to true when not specified. + Generate *bool `yaml:"generate,omitempty"` + + // Namespaced indicates whether the generated ObjectRef type should include a Namespace field. + // Defaults to false when not specified. + // Can only be set when Generate is true, and causes errors if set when Generate is false. + Namespaced bool `yaml:"namespaced,omitempty"` + + // ImportPath is the Go import path where the ObjectRef type is defined. + // Can't be set if Generate is true, and is required if Generate is false. + Import *ImportConfig `yaml:"import,omitempty"` +} + +type ImportConfig struct { + // Path is the Go import path (e.g. "github.com/kong/kong-operator/v2/api/common/v1alpha1"). + Path string `yaml:"path"` + // Alias is an optional alias to use for the imported package (e.g. "commonv1alpha1"). + Alias string `yaml:"alias,omitempty"` +} + +// TypeConfig holds configuration for a single CRD type (identified by its OpenAPI path). +type TypeConfig struct { + // Path is the OpenAPI path that identifies the resource (e.g. "/services"). + Path string `yaml:"path"` + // CEL maps field names to their configurations, allowing additional + // kubebuilder validation markers to be attached to specific fields. + CEL map[string]*FieldConfig `yaml:"cel,omitempty"` + // Ops maps operation names (e.g. "create", "update") to SDK type configurations. + // When set, conversion methods are generated on the entity's APISpec type. + Ops map[string]*OpConfig `yaml:"ops,omitempty"` +} + +// OpConfig holds configuration for a single SDK operation. +type OpConfig struct { + // Path is the fully qualified Go type path in the form "importpath.TypeName", + // e.g. "github.com/Kong/sdk-konnect-go/models/components.CreatePortal". + Path string `yaml:"path"` +} + +// EntityOpsConfig holds the operations configuration for a single entity type. +type EntityOpsConfig struct { + // Ops maps operation names (e.g. "create", "update") to their SDK type configs. + Ops map[string]*OpConfig +} + +// GetPaths returns the list of OpenAPI paths from the types configuration. +func (c *APIGroupVersionConfig) GetPaths() []string { + paths := make([]string, 0, len(c.Types)) + for _, tc := range c.Types { + paths = append(paths, tc.Path) + } + return paths +} + +// FieldConfig builds a *Config suitable for the generator's FieldConfig parameter. +// It maps CEL validations from per-path config to per-entity config using the provided +// pathToEntityName mapping (built after parsing the OpenAPI spec). +func (c *APIGroupVersionConfig) FieldConfig(pathToEntityName map[string]string) *Config { + entities := make(map[string]*EntityConfig) + for _, tc := range c.Types { + if tc.CEL == nil { + continue + } + entityName, ok := pathToEntityName[tc.Path] + if !ok { + continue + } + entities[entityName] = &EntityConfig{Fields: tc.CEL} + } + return &Config{Entities: entities} +} + +// OpsConfig builds a mapping from entity name to operations config using the provided +// pathToEntityName mapping (built after parsing the OpenAPI spec). +func (c *APIGroupVersionConfig) OpsConfig(pathToEntityName map[string]string) map[string]*EntityOpsConfig { + result := make(map[string]*EntityOpsConfig) + for _, tc := range c.Types { + if tc.Ops == nil { + continue + } + entityName, ok := pathToEntityName[tc.Path] + if !ok { + continue + } + result[entityName] = &EntityOpsConfig{Ops: tc.Ops} + } + return result +} + +func (c *APIGroupVersionConfig) validate() error { + if c.CommonTypes == nil || c.CommonTypes.ObjectRef == nil { + return nil + } + ref := c.CommonTypes.ObjectRef + + // Default Generate to true when ObjectRef is present but Generate is not explicitly set. + if ref.Generate == nil && ref.Import == nil { + ref.Generate = new(true) + } + + // Only flag mutual exclusion when generate is explicitly set to true. + if ref.Generate != nil && *ref.Generate && ref.Import != nil { + return fmt.Errorf("commonTypes.objectRef: generate and import are mutually exclusive") + } + // Require import when generate is explicitly set to false. + if ref.Generate != nil && !*ref.Generate && ref.Import == nil { + return fmt.Errorf("commonTypes.objectRef: import is required when generate is false") + } + if ref.Import != nil && ref.Import.Path == "" { + return fmt.Errorf("commonTypes.objectRef.import: path is required") + } + return nil +} + +// ParseAPIGroupVersion splits a "group/version" string into its group and version parts. +func ParseAPIGroupVersion(gv string) (group, version string, err error) { + parts := strings.SplitN(gv, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", fmt.Errorf("invalid apiGroupVersion %q: must be in format 'group/version'", gv) + } + return parts[0], parts[1], nil +} + +// FieldConfig holds configuration for a single field +type FieldConfig struct { + // Validations are additional kubebuilder markers to add to the field + Validations []string `yaml:"_validations,omitempty"` +} + +// EntityConfig holds configuration for a single entity (CRD type) +type EntityConfig struct { + // Fields maps field names to their configurations + Fields map[string]*FieldConfig `yaml:",inline"` +} + +// Config holds the complete configuration for CRD generation +type Config struct { + // Entities maps entity names to their configurations + Entities map[string]*EntityConfig `yaml:",inline"` +} + +// LoadConfig loads configuration from a YAML file +func LoadConfig(path string) (*Config, error) { + if path == "" { + return &Config{Entities: make(map[string]*EntityConfig)}, nil + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg.Entities); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + if cfg.Entities == nil { + cfg.Entities = make(map[string]*EntityConfig) + } + + return &cfg, nil +} + +// GetFieldValidations returns the additional validations for a field +func (c *Config) GetFieldValidations(entityName, fieldName string) []string { + if c == nil || c.Entities == nil { + return nil + } + + entityCfg, ok := c.Entities[entityName] + if !ok || entityCfg == nil || entityCfg.Fields == nil { + return nil + } + + fieldCfg, ok := entityCfg.Fields[fieldName] + if !ok || fieldCfg == nil { + return nil + } + + return fieldCfg.Validations +} + +// ValidateAgainstSchema validates that all fields in the config exist in the schema +func (c *Config) ValidateAgainstSchema(entityName string, validFields map[string]bool) error { + if c == nil || c.Entities == nil { + return nil + } + + entityCfg, ok := c.Entities[entityName] + if !ok || entityCfg == nil || entityCfg.Fields == nil { + return nil + } + + for fieldName := range entityCfg.Fields { + if !validFields[fieldName] { + return fmt.Errorf("field %q in config does not exist in entity %q", fieldName, entityName) + } + } + + return nil +} diff --git a/crd-from-oas/pkg/config/config_test.go b/crd-from-oas/pkg/config/config_test.go new file mode 100644 index 0000000000..f9c1da2a21 --- /dev/null +++ b/crd-from-oas/pkg/config/config_test.go @@ -0,0 +1,384 @@ +package config + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadProjectConfig(t *testing.T) { + t.Run("valid config", func(t *testing.T) { + content := ` +apiGroupVersions: + konnect.konghq.com/v1alpha1: + types: + - path: /v3/portals + cel: + name: + _validations: + - "+kubebuilder:validation:MinLength=1" + ops: + create: + path: github.com/Kong/sdk-konnect-go/models/components.CreatePortal + update: + path: github.com/Kong/sdk-konnect-go/models/components.UpdatePortal + - path: /v3/portals/{portalId}/teams + gateway.konghq.com/v1beta1: + types: + - path: /v3/gateways +` + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + cfg, err := LoadProjectConfig(path) + require.NoError(t, err) + require.Len(t, cfg.APIGroupVersions, 2) + + konnect := cfg.APIGroupVersions["konnect.konghq.com/v1alpha1"] + require.NotNil(t, konnect) + require.Len(t, konnect.Types, 2) + assert.Equal(t, "/v3/portals", konnect.Types[0].Path) + require.NotNil(t, konnect.Types[0].CEL) + assert.Contains(t, konnect.Types[0].CEL, "name") + require.NotNil(t, konnect.Types[0].Ops) + assert.Len(t, konnect.Types[0].Ops, 2) + assert.Equal(t, "github.com/Kong/sdk-konnect-go/models/components.CreatePortal", konnect.Types[0].Ops["create"].Path) + assert.Equal(t, "github.com/Kong/sdk-konnect-go/models/components.UpdatePortal", konnect.Types[0].Ops["update"].Path) + assert.Equal(t, "/v3/portals/{portalId}/teams", konnect.Types[1].Path) + assert.Nil(t, konnect.Types[1].CEL) + assert.Nil(t, konnect.Types[1].Ops) + + gateway := cfg.APIGroupVersions["gateway.konghq.com/v1beta1"] + require.NotNil(t, gateway) + require.Len(t, gateway.Types, 1) + assert.Equal(t, "/v3/gateways", gateway.Types[0].Path) + }) + + t.Run("valid config with commonTypes import", func(t *testing.T) { + content := ` +apiGroupVersions: + konnect.konghq.com/v1alpha1: + commonTypes: + objectRef: + import: + path: github.com/kong/kong-operator/v2/api/common/v1alpha1 + alias: commonv1alpha1 + types: + - path: /v3/portals +` + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + cfg, err := LoadProjectConfig(path) + require.NoError(t, err) + + konnect := cfg.APIGroupVersions["konnect.konghq.com/v1alpha1"] + require.NotNil(t, konnect) + require.NotNil(t, konnect.CommonTypes) + require.NotNil(t, konnect.CommonTypes.ObjectRef) + assert.Nil(t, konnect.CommonTypes.ObjectRef.Generate, "generate should be nil when not specified") + require.NotNil(t, konnect.CommonTypes.ObjectRef.Import) + assert.Equal(t, "github.com/kong/kong-operator/v2/api/common/v1alpha1", konnect.CommonTypes.ObjectRef.Import.Path) + assert.Equal(t, "commonv1alpha1", konnect.CommonTypes.ObjectRef.Import.Alias) + }) + + t.Run("valid config with commonTypes generate", func(t *testing.T) { + content := ` +apiGroupVersions: + konnect.konghq.com/v1alpha1: + commonTypes: + objectRef: + generate: true + types: + - path: /v3/portals +` + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + cfg, err := LoadProjectConfig(path) + require.NoError(t, err) + + konnect := cfg.APIGroupVersions["konnect.konghq.com/v1alpha1"] + require.NotNil(t, konnect.CommonTypes) + require.NotNil(t, konnect.CommonTypes.ObjectRef) + require.NotNil(t, konnect.CommonTypes.ObjectRef.Generate) + assert.True(t, bool(*konnect.CommonTypes.ObjectRef.Generate)) + assert.Nil(t, konnect.CommonTypes.ObjectRef.Import) + }) + + t.Run("valid config without commonTypes", func(t *testing.T) { + content := ` +apiGroupVersions: + konnect.konghq.com/v1alpha1: + types: + - path: /v3/portals +` + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + cfg, err := LoadProjectConfig(path) + require.NoError(t, err) + + konnect := cfg.APIGroupVersions["konnect.konghq.com/v1alpha1"] + assert.Nil(t, konnect.CommonTypes) + }) + + t.Run("invalid: generate and import both set", func(t *testing.T) { + content := ` +apiGroupVersions: + konnect.konghq.com/v1alpha1: + commonTypes: + objectRef: + generate: true + import: + path: github.com/kong/kong-operator/v2/api/common/v1alpha1 + types: + - path: /v3/portals +` + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + _, err := LoadProjectConfig(path) + assert.ErrorContains(t, err, "generate and import are mutually exclusive") + }) + + t.Run("empty objectRef defaults to generate true", func(t *testing.T) { + content := ` +apiGroupVersions: + konnect.konghq.com/v1alpha1: + commonTypes: + objectRef: {} + types: + - path: /v3/portals +` + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + cfg, err := LoadProjectConfig(path) + require.NoError(t, err) + + konnect := cfg.APIGroupVersions["konnect.konghq.com/v1alpha1"] + require.NotNil(t, konnect.CommonTypes.ObjectRef) + require.NotNil(t, konnect.CommonTypes.ObjectRef.Generate) + assert.True(t, bool(*konnect.CommonTypes.ObjectRef.Generate)) + }) + + t.Run("invalid: generate false without import", func(t *testing.T) { + content := ` +apiGroupVersions: + konnect.konghq.com/v1alpha1: + commonTypes: + objectRef: + generate: false + types: + - path: /v3/portals +` + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + _, err := LoadProjectConfig(path) + assert.ErrorContains(t, err, "import is required when generate is false") + }) + + t.Run("invalid: import with empty path", func(t *testing.T) { + content := ` +apiGroupVersions: + konnect.konghq.com/v1alpha1: + commonTypes: + objectRef: + import: + path: "" + types: + - path: /v3/portals +` + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + _, err := LoadProjectConfig(path) + assert.ErrorContains(t, err, "path is required") + }) + + t.Run("missing apiGroupVersions", func(t *testing.T) { + content := ` +someOtherKey: value +` + path := filepath.Join(t.TempDir(), "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + + _, err := LoadProjectConfig(path) + assert.ErrorContains(t, err, "apiGroupVersions") + }) + + t.Run("file not found", func(t *testing.T) { + _, err := LoadProjectConfig("/nonexistent/config.yaml") + assert.Error(t, err) + assert.ErrorContains(t, err, "failed to read config file") + }) +} + +func TestParseAPIGroupVersion(t *testing.T) { + tests := []struct { + name string + input string + wantGroup string + wantVer string + wantErr bool + }{ + { + name: "valid group-version", + input: "konnect.konghq.com/v1alpha1", + wantGroup: "konnect.konghq.com", + wantVer: "v1alpha1", + }, + { + name: "simple group-version", + input: "apps/v1", + wantGroup: "apps", + wantVer: "v1", + }, + { + name: "no slash", + input: "konnect.konghq.com", + wantErr: true, + }, + { + name: "empty group", + input: "/v1alpha1", + wantErr: true, + }, + { + name: "empty version", + input: "konnect.konghq.com/", + wantErr: true, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + group, version, err := ParseAPIGroupVersion(tc.input) + if tc.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantGroup, group) + assert.Equal(t, tc.wantVer, version) + }) + } +} + +func TestAPIGroupVersionConfig_GetPaths(t *testing.T) { + agv := &APIGroupVersionConfig{ + Types: []*TypeConfig{ + {Path: "/v3/portals"}, + {Path: "/v3/portals/{portalId}/teams"}, + }, + } + assert.Equal(t, []string{"/v3/portals", "/v3/portals/{portalId}/teams"}, agv.GetPaths()) +} + +func TestAPIGroupVersionConfig_FieldConfig(t *testing.T) { + t.Run("with cel validations", func(t *testing.T) { + agv := &APIGroupVersionConfig{ + Types: []*TypeConfig{ + { + Path: "/v3/portals", + CEL: map[string]*FieldConfig{ + "name": {Validations: []string{"+required"}}, + }, + }, + { + Path: "/v3/portals/{portalId}/teams", + }, + }, + } + + pathToEntity := map[string]string{ + "/v3/portals": "Portal", + "/v3/portals/{portalId}/teams": "PortalTeam", + } + + fc := agv.FieldConfig(pathToEntity) + require.NotNil(t, fc) + assert.Equal(t, []string{"+required"}, fc.GetFieldValidations("Portal", "name")) + assert.Nil(t, fc.GetFieldValidations("PortalTeam", "name")) + }) + + t.Run("no cel validations", func(t *testing.T) { + agv := &APIGroupVersionConfig{ + Types: []*TypeConfig{ + {Path: "/v3/portals"}, + }, + } + + fc := agv.FieldConfig(map[string]string{"/v3/portals": "Portal"}) + require.NotNil(t, fc) + assert.Empty(t, fc.Entities) + }) + + t.Run("nil types", func(t *testing.T) { + agv := &APIGroupVersionConfig{} + + fc := agv.FieldConfig(nil) + require.NotNil(t, fc) + assert.Empty(t, fc.Entities) + }) +} + +func TestAPIGroupVersionConfig_OpsConfig(t *testing.T) { + t.Run("with ops configured", func(t *testing.T) { + agv := &APIGroupVersionConfig{ + Types: []*TypeConfig{ + { + Path: "/v3/portals", + Ops: map[string]*OpConfig{ + "create": {Path: "github.com/Kong/sdk-konnect-go/models/components.CreatePortal"}, + "update": {Path: "github.com/Kong/sdk-konnect-go/models/components.UpdatePortal"}, + }, + }, + { + Path: "/v3/portals/{portalId}/teams", + }, + }, + } + + pathToEntity := map[string]string{ + "/v3/portals": "Portal", + "/v3/portals/{portalId}/teams": "PortalTeam", + } + + oc := agv.OpsConfig(pathToEntity) + require.Len(t, oc, 1) + require.Contains(t, oc, "Portal") + assert.Len(t, oc["Portal"].Ops, 2) + assert.Equal(t, "github.com/Kong/sdk-konnect-go/models/components.CreatePortal", oc["Portal"].Ops["create"].Path) + assert.Equal(t, "github.com/Kong/sdk-konnect-go/models/components.UpdatePortal", oc["Portal"].Ops["update"].Path) + assert.NotContains(t, oc, "PortalTeam") + }) + + t.Run("no ops configured", func(t *testing.T) { + agv := &APIGroupVersionConfig{ + Types: []*TypeConfig{ + {Path: "/v3/portals"}, + }, + } + + oc := agv.OpsConfig(map[string]string{"/v3/portals": "Portal"}) + assert.Empty(t, oc) + }) + + t.Run("nil types", func(t *testing.T) { + agv := &APIGroupVersionConfig{} + + oc := agv.OpsConfig(nil) + assert.Empty(t, oc) + }) +} diff --git a/crd-from-oas/pkg/config/load.go b/crd-from-oas/pkg/config/load.go new file mode 100644 index 0000000000..d7d9c2ad72 --- /dev/null +++ b/crd-from-oas/pkg/config/load.go @@ -0,0 +1,33 @@ +package config + +import ( + "fmt" + "os" + + "gopkg.in/yaml.v3" +) + +// LoadProjectConfig loads the project configuration from a YAML file. +func LoadProjectConfig(path string) (*ProjectConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + var cfg ProjectConfig + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("failed to parse config file: %w", err) + } + + if cfg.APIGroupVersions == nil { + return nil, fmt.Errorf("config file must contain apiGroupVersions") + } + + for gv, agv := range cfg.APIGroupVersions { + if err := agv.validate(); err != nil { + return nil, fmt.Errorf("apiGroupVersion %q: %w", gv, err) + } + } + + return &cfg, nil +} diff --git a/crd-from-oas/pkg/generator/common_types.go b/crd-from-oas/pkg/generator/common_types.go new file mode 100644 index 0000000000..58a2a18570 --- /dev/null +++ b/crd-from-oas/pkg/generator/common_types.go @@ -0,0 +1,136 @@ +package generator + +const objectRefTypeEnum = `// ObjectRefType is the enum type for the ObjectRef. +// +// +kubebuilder:validation:Enum=namespacedRef +type ObjectRefType string + +const ( + // ObjectRefTypeNamespacedRef is the type for the namespaced ref. + // It is used to reference an entity inside the cluster + // using a namespaced reference. + ObjectRefTypeNamespacedRef ObjectRefType = "namespacedRef" +)` + +const objectRefType = `// ObjectRef is the schema for the ObjectRef type. +// It is used to reference an entity inside the cluster +// by its namespaced name. +// +// +kubebuilder:validation:XValidation:rule="self.type == 'namespacedRef' ? has(self.namespacedRef) : true", message="when type is namespacedRef, namespacedRef must be set" +// +kong:channels=kong-operator +type ObjectRef struct { + // Type defines type of the object which is referenced. It can be one of: + // + // - namespacedRef + // + // +required + Type ObjectRefType ` + "`json:\"type,omitempty\"`" + ` + + // NamespacedRef is a reference to an entity inside the cluster. + // This field is required when the Type is namespacedRef. + // + // +optional + NamespacedRef *NamespacedRef ` + "`json:\"namespacedRef,omitempty\"`" + ` +}` + +// namespacedRefType uses Go template syntax for the conditional Namespace field. +// It is parsed as part of commonTypesTemplate. +const namespacedRefType = `// NamespacedRef is a reference to a namespaced resource. +type NamespacedRef struct { + // Name is the name of the referred resource. + // + // +required + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Name string ` + "`json:\"name,omitempty\"`" + ` +{{- if .Namespaced}} + + // Namespace is the namespace of the referred resource. + // + // For namespace-scoped resources if no Namespace is provided then the + // namespace of the parent object MUST be used. + // + // This field MUST not be set when referring to cluster-scoped resources. + // + // +optional + // +kubebuilder:validation:MaxLength=253 + Namespace *string ` + "`json:\"namespace,omitempty\"`" + ` +{{- end}} +}` + +const secretKeyRefType = `// SecretKeyRef is a reference to a key in a Secret +type SecretKeyRef struct { + // Name is the name of the Secret + // + // +required + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:MinLength=1 + Name string ` + "`json:\"name,omitempty\"`" + ` + + // Key is the key within the Secret + // + // +required + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:MinLength=1 + Key string ` + "`json:\"key,omitempty\"`" + ` + + // Namespace is the namespace of the Secret + // + // +optional + // +kubebuilder:validation:MaxLength=63 + Namespace string ` + "`json:\"namespace,omitempty\"`" + ` +}` + +const configMapKeyRefType = `// ConfigMapKeyRef is a reference to a key in a ConfigMap +type ConfigMapKeyRef struct { + // Name is the name of the ConfigMap + // + // +required + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:MinLength=1 + Name string ` + "`json:\"name,omitempty\"`" + ` + + // Key is the key within the ConfigMap + // + // +required + // +kubebuilder:validation:MaxLength=253 + // +kubebuilder:validation:MinLength=1 + Key string ` + "`json:\"key,omitempty\"`" + ` + + // Namespace is the namespace of the ConfigMap + // + // +optional + // +kubebuilder:validation:MaxLength=63 + Namespace string ` + "`json:\"namespace,omitempty\"`" + ` +}` + +const konnectEntityStatusType = `// KonnectEntityStatus represents the status of a Konnect entity. +type KonnectEntityStatus struct { + // ID is the unique identifier of the Konnect entity as assigned by Konnect API. + // If it's unset (empty string), it means the Konnect entity hasn't been created yet. + // + // +optional + // +kubebuilder:validation:MaxLength=256 + ID string ` + "`json:\"id,omitempty\"`" + ` + + // ServerURL is the URL of the Konnect server in which the entity exists. + // + // +optional + // +kubebuilder:validation:MaxLength=512 + ServerURL string ` + "`json:\"serverURL,omitempty\"`" + ` + + // OrgID is ID of Konnect Org that this entity has been created in. + // + // +optional + // +kubebuilder:validation:MaxLength=256 + OrgID string ` + "`json:\"organizationID,omitempty\"`" + ` +}` + +const konnectEntityRefType = `// KonnectEntityRef is a reference to a Konnect entity. +type KonnectEntityRef struct { + // ID is the unique identifier of the Konnect entity as assigned by Konnect API. + // + // +optional + // +kubebuilder:validation:MaxLength=256 + ID string ` + "`json:\"id,omitempty\"`" + ` +}` diff --git a/crd-from-oas/pkg/generator/generator.go b/crd-from-oas/pkg/generator/generator.go new file mode 100644 index 0000000000..98eec24da8 --- /dev/null +++ b/crd-from-oas/pkg/generator/generator.go @@ -0,0 +1,1026 @@ +package generator + +import ( + "fmt" + "sort" + "strings" + "text/template" + + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/config" + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/parser" +) + +// Config holds generator configuration +type Config struct { + // API group for CRDs. + APIGroup string + // API version. + APIVersion string + // Whether to generate status subresource + GenerateStatus bool + // FieldConfig holds additional field configurations from YAML + FieldConfig *config.Config + // OpsConfig maps entity names to SDK operation configurations. + // When set, conversion methods are generated on the entity's APISpec type. + OpsConfig map[string]*config.EntityOpsConfig + // CommonTypes holds configuration for shared types like ObjectRef. + // When ObjectRef has an Import config, it will be imported from an external + // package instead of being generated locally. + CommonTypes *config.CommonTypesConfig +} + +// Generator generates Go CRD types from parsed OpenAPI schemas +type Generator struct { + config Config +} + +// NewGenerator creates a new generator +func NewGenerator(config Config) *Generator { + return &Generator{config: config} +} + +// GeneratedFile represents a generated Go file +type GeneratedFile struct { + Name string + Content string +} + +// Generate generates Go CRD types from parsed schemas +func (g *Generator) Generate(parsed *parser.ParsedSpec) ([]GeneratedFile, error) { + var files []GeneratedFile + + // Collect all referenced schema names from the CRD types + referencedSchemas := make(map[string]bool) + + // Generate types for each request body (these are the main CRD types) + for name, schema := range parsed.RequestBodies { + content, err := g.generateCRDType(name, schema) + if err != nil { + return nil, fmt.Errorf("failed to generate type for %s: %w", name, err) + } + + entityName := parser.GetEntityNameFromType(name) + fileName := strings.ToLower(entityName) + "_types.go" + files = append(files, GeneratedFile{ + Name: fileName, + Content: content, + }) + + // Generate SDK ops conversion file if ops are configured for this entity + if g.config.OpsConfig != nil { + if opsConfig, ok := g.config.OpsConfig[entityName]; ok && opsConfig != nil && len(opsConfig.Ops) > 0 { + opsContent, err := g.generateSDKOps(entityName, opsConfig) + if err != nil { + return nil, fmt.Errorf("failed to generate SDK ops for %s: %w", entityName, err) + } + files = append(files, GeneratedFile{ + Name: strings.ToLower(entityName) + "_sdkops.go", + Content: opsContent, + }) + + opsTestContent, err := g.generateSDKOpsTest(entityName, schema, opsConfig) + if err != nil { + return nil, fmt.Errorf("failed to generate SDK ops test for %s: %w", entityName, err) + } + files = append(files, GeneratedFile{ + Name: strings.ToLower(entityName) + "_sdkops_test.go", + Content: opsTestContent, + }) + } + } + + // Collect referenced schemas + g.collectReferencedSchemas(schema, referencedSchemas) + } + + // Generate a register file + registerContent, err := g.generateRegister(parsed) + if err != nil { + return nil, fmt.Errorf("failed to generate register file: %w", err) + } + files = append(files, GeneratedFile{ + Name: "register.go", + Content: registerContent, + }) + + // Generate a doc.go file + docContent := g.generateDoc() + files = append(files, GeneratedFile{ + Name: "doc.go", + Content: docContent, + }) + + // Generate common types (ObjectRef, etc.) including referenced schemas + commonContent, err := g.generateCommonTypes() + if err != nil { + return nil, fmt.Errorf("failed to generate common types: %w", err) + } + files = append(files, GeneratedFile{ + Name: "common_types.go", + Content: commonContent, + }) + + // Generate type aliases for referenced schemas + if len(referencedSchemas) > 0 { + schemaTypesContent := g.generateSchemaTypes(referencedSchemas, parsed) + files = append(files, GeneratedFile{ + Name: "schema_types.go", + Content: schemaTypesContent, + }) + } + + return files, nil +} + +// collectReferencedSchemas collects all schema names referenced by properties +func (g *Generator) collectReferencedSchemas(schema *parser.Schema, refs map[string]bool) { + for _, prop := range schema.Properties { + g.collectRefsFromProperty(prop, refs) + } + // Also collect refs from root-level oneOf variants + for _, variant := range schema.OneOf { + g.collectRefsFromProperty(variant, refs) + } +} + +func (g *Generator) collectRefsFromProperty(prop *parser.Property, refs map[string]bool) { + // Don't collect refs for properties that will be skipped + if skipProperty(prop) { + return + } + if prop.RefName != "" && !prop.IsReference { + refs[prop.RefName] = true + } + if prop.Items != nil { + g.collectRefsFromProperty(prop.Items, refs) + } + for _, nestedProp := range prop.Properties { + g.collectRefsFromProperty(nestedProp, refs) + } + if prop.AdditionalProperties != nil { + g.collectRefsFromProperty(prop.AdditionalProperties, refs) + } + // Collect refs from oneOf variants + for _, variant := range prop.OneOf { + if variant.RefName != "" { + refs[variant.RefName] = true + } + g.collectRefsFromProperty(variant, refs) + } +} + +// generateSchemaTypes generates Go type definitions for referenced schemas +func (g *Generator) generateSchemaTypes(refs map[string]bool, parsed *parser.ParsedSpec) string { + var buf strings.Builder + fmt.Fprintf(&buf, "package %s\n\n", g.config.APIVersion) + + // Sort keys to ensure deterministic output order + refNames := make([]string, 0, len(refs)) + for refName := range refs { + refNames = append(refNames, refName) + } + sort.Strings(refNames) + + for _, refName := range refNames { + if schema, ok := parsed.Schemas[refName]; ok { + // Format the description as a proper comment + comment := formatSchemaComment(refName, schema.Description) + + // Generate based on schema type + if len(schema.Properties) > 0 { + // It's an object type - generate a struct + buf.WriteString(comment) + fmt.Fprintf(&buf, "type %s struct {\n", refName) + for _, prop := range schema.Properties { + if skipProperty(prop) { + continue + } + fmt.Fprintf(&buf, "\t%s %s `json:\"%s\"`\n", goFieldName(prop.Name), g.goType(prop), jsonTag(prop)) + } + buf.WriteString("}\n\n") + } else { + // Generate based on the schema's actual type + buf.WriteString(comment) + goType := schemaToGoType(schema) + fmt.Fprintf(&buf, "type %s %s\n\n", refName, goType) + } + } else { + // Schema not found in parsed schemas, generate a placeholder + fmt.Fprintf(&buf, "// %s is a referenced type (definition not found in spec)\n", refName) + fmt.Fprintf(&buf, "type %s map[string]string\n\n", refName) + } + } + + return buf.String() +} + +// schemaToGoType converts a parsed Schema's type info to the appropriate Go type string. +// This is used for referenced schemas that are simple types (not objects with properties). +func schemaToGoType(schema *parser.Schema) string { + switch schema.Type { + case "string": + return "string" + case "boolean": + return "bool" + case "integer": + return "int" + case "number": + return "float64" + case "array": + if schema.Items != nil && schema.Items.Type != "" { + switch schema.Items.Type { + case "string": + return "[]string" + case "integer": + return "[]int" + case "boolean": + return "[]bool" + default: + return "[]any" + } + } + return "[]any" + default: + // For object types without properties or unknown types, default to map[string]string + return "map[string]string" + } +} + +func (g *Generator) generateCRDType(name string, schema *parser.Schema) (string, error) { + entityName := parser.GetEntityNameFromType(name) + + // Build a map of valid field names for validation + validFields := make(map[string]bool) + for _, prop := range schema.Properties { + if !skipProperty(prop) { + validFields[prop.Name] = true + } + } + // Also include dependency fields + for _, dep := range schema.Dependencies { + validFields[dep.JSONName] = true + } + + // Validate that all configured fields exist + if g.config.FieldConfig != nil { + if err := g.config.FieldConfig.ValidateAgainstSchema(entityName, validFields); err != nil { + return "", err + } + } + + // Create a closure that captures entityName and fieldConfig for KubebuilderTags + kubebuilderTagsWithConfig := func(prop *parser.Property) []string { + return KubebuilderTags(prop, entityName, g.config.FieldConfig) + } + + funcMap := template.FuncMap{ + "goType": g.goType, + "goFieldName": goFieldName, + "jsonTag": jsonTag, + "kubebuilderTags": kubebuilderTagsWithConfig, + "isRefProperty": isRefProperty, + "refEntityName": parser.GetRefEntityName, + "skipProperty": skipProperty, + "lower": strings.ToLower, + "formatComment": formatComment, + "hasRootOneOf": hasRootOneOf, + "objectRefTypeName": func() string { return g.objectRefTypeName() }, + } + + tmpl := template.Must(template.New("crd").Funcs(funcMap).Parse(crdTypeTemplate)) + + var buf strings.Builder + data := struct { + EntityName string + Schema *parser.Schema + APIGroup string + APIVersion string + NeedsJSONImport bool + ObjectRefImport *config.ImportConfig + }{ + EntityName: entityName, + Schema: schema, + APIGroup: g.config.APIGroup, + APIVersion: g.config.APIVersion, + NeedsJSONImport: schemaUsesJSON(g, schema), + ObjectRefImport: g.objectRefImportIfNeeded(schema), + } + + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + + // Generate union types for any oneOf properties + unionTypes := g.generateUnionTypes(schema) + if unionTypes != "" { + buf.WriteString("\n") + buf.WriteString(unionTypes) + } + + // Post-process to remove trailing empty lines before closing braces in structs + result := fixTrailingEmptyLines(buf.String()) + + return result, nil +} + +// generateUnionTypes generates Go union type structs for properties with oneOf +func (g *Generator) generateUnionTypes(schema *parser.Schema) string { + var buf strings.Builder + + // Handle root-level oneOf (the schema itself is a union type) + if len(schema.OneOf) > 0 { + buf.WriteString(g.generateRootUnionType(schema)) + } + + // Handle property-level oneOf + for _, prop := range schema.Properties { + if skipProperty(prop) { + continue + } + if len(prop.OneOf) > 0 { + buf.WriteString(g.generateUnionType(prop)) + } + } + + return buf.String() +} + +// generateRootUnionType generates a union type for a schema with root-level oneOf +func (g *Generator) generateRootUnionType(schema *parser.Schema) string { + // Create a synthetic property for the union type + // Add "Config" suffix to differentiate from the main CRD type + entityName := parser.GetEntityNameFromType(schema.Name) + prop := &parser.Property{ + Name: entityName + "Config", + OneOf: schema.OneOf, + } + return g.generateUnionType(prop) +} + +// generateUnionType generates a single union type struct +func (g *Generator) generateUnionType(prop *parser.Property) string { + var buf strings.Builder + + typeName := goFieldName(prop.Name) + + // Collect raw variant names (ref names or variant names) + var rawVariantNames []string + for _, variant := range prop.OneOf { + variantName := variant.Name + if variant.RefName != "" { + variantName = variant.RefName + } + rawVariantNames = append(rawVariantNames, variantName) + } + + // Extract clean variant names by finding common prefix/suffix + variantNames := extractVariantNames(rawVariantNames) + + // Generate the union type comment + fmt.Fprintf(&buf, "// %s represents a union type for %s.\n", typeName, prop.Name) + buf.WriteString("// Only one of the fields should be set based on the Type.\n") + buf.WriteString("//\n") + fmt.Fprintf(&buf, "type %s struct {\n", typeName) + + // Generate the Type discriminator field + buf.WriteString("\t// Type designates the type of configuration.\n") + buf.WriteString("\t//\n") + buf.WriteString("\t// +required\n") + buf.WriteString("\t// +kubebuilder:validation:MinLength=1\n") + fmt.Fprintf(&buf, "\t// +kubebuilder:validation:Enum=%s\n", strings.Join(variantNames, ";")) + fmt.Fprintf(&buf, "\tType %sType `json:\"type,omitempty\"`\n\n", typeName) + + // Generate a field for each variant + for i, variant := range prop.OneOf { + refTypeName := variant.Name + if variant.RefName != "" { + refTypeName = variant.RefName + } + + fieldName := variantNames[i] + + // Generate JSON tag - convert to lowercase + jsonTag := strings.ToLower(fieldName) + + fmt.Fprintf(&buf, "\t// %s configuration.\n", fieldName) + buf.WriteString("\t//\n") + buf.WriteString("\t// +optional\n") + fmt.Fprintf(&buf, "\t%s *%s `json:\"%s,omitempty\"`\n", fieldName, refTypeName, jsonTag) + } + + buf.WriteString("}\n\n") + + // Generate the Type type alias with constants + fmt.Fprintf(&buf, "// %sType represents the type of %s.\n", typeName, prop.Name) + fmt.Fprintf(&buf, "type %sType string\n\n", typeName) + + fmt.Fprintf(&buf, "// %sType values.\n", typeName) + buf.WriteString("const (\n") + for _, variantName := range variantNames { + constName := fmt.Sprintf("%sType%s", typeName, variantName) + fmt.Fprintf(&buf, "\t%s %sType = \"%s\"\n", constName, typeName, variantName) + } + buf.WriteString(")\n") + + return buf.String() +} + +// extractVariantNames extracts clean field names from a list of variant names +// by finding the common prefix and suffix, then extracting the unique middle part. +// e.g., ["ConfigureOIDCIdentityProviderConfig", "SAMLIdentityProviderConfig"] -> ["OIDC", "SAML"] +// e.g., ["CreateDcrProviderRequestAuth0", "CreateDcrProviderRequestAzureAd"] -> ["Auth0", "AzureAd"] +func extractVariantNames(names []string) []string { + if len(names) == 0 { + return nil + } + if len(names) == 1 { + // Single variant - just clean up common prefixes/suffixes + return []string{cleanSingleVariantName(names[0])} + } + + // Find common prefix + prefix := names[0] + for _, name := range names[1:] { + prefix = commonPrefix(prefix, name) + } + + // Find common suffix + suffix := names[0] + for _, name := range names[1:] { + suffix = commonSuffix(suffix, name) + } + + // Extract the unique middle part from each name + result := make([]string, len(names)) + for i, name := range names { + middle := name + if len(prefix) > 0 { + middle = strings.TrimPrefix(middle, prefix) + } + if len(suffix) > 0 { + middle = strings.TrimSuffix(middle, suffix) + } + // If nothing was extracted, fall back to cleaning the whole name + if middle == "" { + middle = cleanSingleVariantName(name) + } else { + // Also clean up common prefixes from the extracted name + middle = cleanSingleVariantName(middle) + } + result[i] = middle + } + + return result +} + +// commonPrefix finds the longest common prefix of two strings +func commonPrefix(a, b string) string { + minLen := min(len(b), len(a)) + i := 0 + for i < minLen && a[i] == b[i] { + i++ + } + return a[:i] +} + +// commonSuffix finds the longest common suffix of two strings +func commonSuffix(a, b string) string { + minLen := min(len(b), len(a)) + i := 0 + for i < minLen && a[len(a)-1-i] == b[len(b)-1-i] { + i++ + } + return a[len(a)-i:] +} + +// cleanSingleVariantName cleans a single variant name by removing common prefixes/suffixes +func cleanSingleVariantName(name string) string { + result := name + for _, suffix := range []string{"Config", "Configuration", "Provider", "Request", "IdentityProvider"} { + result = strings.TrimSuffix(result, suffix) + } + for _, prefix := range []string{"Configure", "Create", "Update"} { + result = strings.TrimPrefix(result, prefix) + } + return result +} + +// fixTrailingEmptyLines removes empty lines that appear right before a closing brace +func fixTrailingEmptyLines(s string) string { + lines := strings.Split(s, "\n") + var result []string + for i := range lines { + // Skip empty lines that are followed by a line containing only "}" + if strings.TrimSpace(lines[i]) == "" && i+1 < len(lines) && strings.TrimSpace(lines[i+1]) == "}" { + continue + } + result = append(result, lines[i]) + } + return strings.Join(result, "\n") +} + +func (g *Generator) generateRegister(parsed *parser.ParsedSpec) (string, error) { + tmpl := template.Must(template.New("register").Parse(registerTemplate)) + + var entityNames []string + for name := range parsed.RequestBodies { + entityNames = append(entityNames, parser.GetEntityNameFromType(name)) + } + + var buf strings.Builder + data := struct { + APIGroup string + APIVersion string + EntityNames []string + }{ + APIGroup: g.config.APIGroup, + APIVersion: g.config.APIVersion, + EntityNames: entityNames, + } + + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + + return buf.String(), nil +} + +func (g *Generator) generateDoc() string { + return fmt.Sprintf(`// Package %s contains API types for the %s API group. +// +// +kubebuilder:object:generate=true +// +groupName=%s +package %s +`, g.config.APIVersion, g.config.APIGroup, g.config.APIGroup, g.config.APIVersion) +} + +func (g *Generator) generateCommonTypes() (string, error) { + tmpl := template.Must(template.New("commonTypes").Parse(commonTypesTemplate)) + + var buf strings.Builder + data := struct { + APIVersion string + ObjectRefImported bool + Namespaced bool + }{ + APIVersion: g.config.APIVersion, + ObjectRefImported: g.objectRefImported(), + Namespaced: g.objectRefNamespaced(), + } + + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +// objectRefNamespaced returns true if the generated ObjectRef's NamespacedRef +// should include a Namespace field. +func (g *Generator) objectRefNamespaced() bool { + return g.config.CommonTypes != nil && + g.config.CommonTypes.ObjectRef != nil && + g.config.CommonTypes.ObjectRef.Namespaced +} + +// objectRefImported returns true if ObjectRef should be imported from an +// external package rather than generated locally. +func (g *Generator) objectRefImported() bool { + return g.config.CommonTypes != nil && + g.config.CommonTypes.ObjectRef != nil && + g.config.CommonTypes.ObjectRef.Import != nil +} + +// objectRefImportIfNeeded returns the ImportConfig for ObjectRef only when the +// schema actually uses ObjectRef (has dependencies or reference properties) and +// ObjectRef is configured as an external import. Returns nil otherwise. +func (g *Generator) objectRefImportIfNeeded(schema *parser.Schema) *config.ImportConfig { + if !g.objectRefImported() { + return nil + } + if schemaUsesObjectRef(schema) { + return g.config.CommonTypes.ObjectRef.Import + } + return nil +} + +// schemaUsesObjectRef returns true if the schema has dependencies or reference +// properties that will generate ObjectRef fields. +func schemaUsesObjectRef(schema *parser.Schema) bool { + if len(schema.Dependencies) > 0 { + return true + } + for _, prop := range schema.Properties { + if !skipProperty(prop) && prop.IsReference { + return true + } + } + return false +} + +// objectRefTypeName returns the Go type name for ObjectRef, qualified with the +// import alias when ObjectRef is imported from an external package. +func (g *Generator) objectRefTypeName() string { + if g.objectRefImported() { + imp := g.config.CommonTypes.ObjectRef.Import + if imp.Alias != "" { + return imp.Alias + ".ObjectRef" + } + // Fall back to last segment of the import path as the package name. + parts := strings.Split(imp.Path, "/") + return parts[len(parts)-1] + ".ObjectRef" + } + return "ObjectRef" +} + +// sdkOpsImport represents a single import needed for SDK ops generation. +type sdkOpsImport struct { + Alias string + Path string +} + +// sdkOpsMethod represents a single SDK conversion method to generate. +type sdkOpsMethod struct { + MethodName string + TypeName string + ImportAlias string +} + +// sdkOpsTestField represents a field to populate in the generated test. +type sdkOpsTestField struct { + FieldName string + TestValue string +} + +// generateSDKOps generates a file with conversion methods from {Entity}APISpec +// to SDK request types using JSON marshal/unmarshal. +func (g *Generator) generateSDKOps(entityName string, opsConfig *config.EntityOpsConfig) (string, error) { + imports, methods, err := g.buildSDKOpsMethods(opsConfig) + if err != nil { + return "", err + } + + tmpl := template.Must(template.New("sdkops").Parse(sdkOpsTemplate)) + var buf strings.Builder + data := struct { + APIVersion string + EntityName string + Imports []*sdkOpsImport + Methods []sdkOpsMethod + }{ + APIVersion: g.config.APIVersion, + EntityName: entityName, + Imports: imports, + Methods: methods, + } + + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +// generateSDKOpsTest generates a test file for the SDK ops conversion methods. +func (g *Generator) generateSDKOpsTest(entityName string, schema *parser.Schema, opsConfig *config.EntityOpsConfig) (string, error) { + _, methods, err := g.buildSDKOpsMethods(opsConfig) + if err != nil { + return "", err + } + + // Build test fields from schema properties + var testFields []sdkOpsTestField + for _, prop := range schema.Properties { + if skipProperty(prop) || prop.IsReference { + continue + } + goType := g.goType(prop) + testValue := testValueForType(goType) + if testValue == "" { + continue + } + testFields = append(testFields, sdkOpsTestField{ + FieldName: goFieldName(prop.Name), + TestValue: testValue, + }) + } + + tmpl := template.Must(template.New("sdkopstest").Parse(sdkOpsTestTemplate)) + var buf strings.Builder + data := struct { + APIVersion string + EntityName string + Methods []sdkOpsMethod + TestFields []sdkOpsTestField + }{ + APIVersion: g.config.APIVersion, + EntityName: entityName, + Methods: methods, + TestFields: testFields, + } + + if err := tmpl.Execute(&buf, data); err != nil { + return "", err + } + return buf.String(), nil +} + +// buildSDKOpsMethods parses the ops config and returns sorted imports and methods. +func (g *Generator) buildSDKOpsMethods(opsConfig *config.EntityOpsConfig) ([]*sdkOpsImport, []sdkOpsMethod, error) { + imports := make(map[string]*sdkOpsImport) + var methods []sdkOpsMethod + + opNames := make([]string, 0, len(opsConfig.Ops)) + for opName := range opsConfig.Ops { + opNames = append(opNames, opName) + } + sort.Strings(opNames) + + for _, opName := range opNames { + opCfg := opsConfig.Ops[opName] + importPath, typeName, err := ParseSDKTypePath(opCfg.Path) + if err != nil { + return nil, nil, fmt.Errorf("operation %q: %w", opName, err) + } + + alias := sdkImportAlias(importPath) + imports[importPath] = &sdkOpsImport{ + Alias: alias, + Path: importPath, + } + + methods = append(methods, sdkOpsMethod{ + MethodName: "To" + typeName, + TypeName: typeName, + ImportAlias: alias, + }) + } + + importPaths := make([]string, 0, len(imports)) + for p := range imports { + importPaths = append(importPaths, p) + } + sort.Strings(importPaths) + sortedImports := make([]*sdkOpsImport, 0, len(importPaths)) + for _, p := range importPaths { + sortedImports = append(sortedImports, imports[p]) + } + + return sortedImports, methods, nil +} + +// sdkImportAlias generates a deterministic import alias from an SDK import path. +// For "github.com/Kong/sdk-konnect-go/models/components" it produces "sdkkonnectcomp". +func sdkImportAlias(importPath string) string { + parts := strings.Split(importPath, "/") + lastSegment := parts[len(parts)-1] + + short := lastSegment + if len(short) > 4 { + short = short[:4] + } + + return "sdkkonnect" + short +} + +// testValueForType returns a Go literal suitable for populating a test struct field. +func testValueForType(goType string) string { + switch goType { + case "string": + return `"test-value"` + case "*string": + return `new("test-value")` + case "bool": + return "true" + case "*bool": + return `new(true)` + case "int", "int32", "int64": + return "1" + case "float32", "float64": + return "1.0" + } + // Skip complex types (maps, slices, structs, etc.) in generated tests + return "" +} + +// goType converts OpenAPI type to Go type +func (g *Generator) goType(prop *parser.Property) string { + // Handle references to other entities - convert to ObjectRef + if prop.IsReference { + return "*" + g.objectRefTypeName() + } + + // Handle $ref + if prop.RefName != "" { + return prop.RefName + } + + // Handle oneOf - this becomes a union type + if len(prop.OneOf) > 0 { + // Generate a union type name based on the property name + return "*" + goFieldName(prop.Name) + } + + var baseType string + switch prop.Type { + case "string": + baseType = "string" + case "integer": + switch prop.Format { + case "int32": + baseType = "int32" + case "int64": + baseType = "int64" + default: + baseType = "int" + } + case "number": + switch prop.Format { + case "float": + baseType = "float32" + case "double": + baseType = "float64" + default: + baseType = "float64" + } + case "boolean": + // Required bools have a valid zero value (false), so they need to be pointers + if prop.Required && !prop.Nullable { + return "*bool" + } + baseType = "bool" + case "array": + if prop.Items != nil { + itemType := g.goType(prop.Items) + return "[]" + itemType + } + return "[]any" + case "object": + // Check for oneOf inside object type + if len(prop.OneOf) > 0 { + return "*" + goFieldName(prop.Name) + } + if prop.AdditionalProperties != nil { + valueType := g.goType(prop.AdditionalProperties) + return "map[string]" + valueType + } + if len(prop.Properties) > 0 { + // This will be handled by generating an inline struct + return goFieldName(prop.Name) + } + // Use apiextensionsv1.JSON for arbitrary JSON objects - controller-gen can handle this + return "apiextensionsv1.JSON" + default: + return "any" + } + + // Handle nullable types with pointers + if prop.Nullable && !prop.Required { + return "*" + baseType + } + + return baseType +} + +// formatComment formats a description string for use as a Go comment +// It handles multiline descriptions by prefixing each line with // +// and wraps lines longer than 80 characters +func formatComment(desc string) string { + if desc == "" { + return "\t//" + } + lines := strings.Split(desc, "\n") + var result []string + for _, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + result = append(result, "\t//") + } else { + // Wrap lines longer than 80 characters (accounting for "\t// " prefix = 4 chars) + wrapped := WrapLine(trimmed, 76) + for _, wrappedLine := range wrapped { + result = append(result, "\t// "+wrappedLine) + } + } + } + return strings.Join(result, "\n") +} + +// formatSchemaComment formats a description for a top-level schema type +// and wraps lines longer than 80 characters +func formatSchemaComment(name, desc string) string { + if desc == "" { + return fmt.Sprintf("// %s is a type alias.\n", name) + } + lines := strings.Split(desc, "\n") + var result []string + // First line includes the type name + firstLine := strings.TrimSpace(lines[0]) + if firstLine != "" { + firstLineWithName := fmt.Sprintf("%s %s", name, firstLine) + // Wrap if needed (accounting for "// " prefix = 3 chars) + wrapped := WrapLine(firstLineWithName, 77) + for _, wrappedLine := range wrapped { + result = append(result, "// "+wrappedLine) + } + } else { + result = append(result, fmt.Sprintf("// %s", name)) + } + // Remaining lines + for _, line := range lines[1:] { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + result = append(result, "//") + } else { + // Wrap lines longer than 80 characters + wrapped := WrapLine(trimmed, 77) + for _, wrappedLine := range wrapped { + result = append(result, "// "+wrappedLine) + } + } + } + return strings.Join(result, "\n") + "\n" +} + +// goFieldName converts property name to Go field name (PascalCase) +func goFieldName(name string) string { + parts := strings.Split(name, "_") + for i, part := range parts { + if len(part) > 0 { + // Handle common acronyms + upper := strings.ToUpper(part) + if isCommonAcronym(upper) { + parts[i] = upper + } else { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + } + return strings.Join(parts, "") +} + +func isCommonAcronym(s string) bool { + acronyms := map[string]bool{ + "ID": true, + "URL": true, + "URI": true, + "API": true, + "HTTP": true, + "HTTPS": true, + "JSON": true, + "XML": true, + "UUID": true, + "RBAC": true, + "DNS": true, + "TLS": true, + "SSL": true, + "IP": true, + } + return acronyms[s] +} + +// jsonTag generates the json struct tag +func jsonTag(prop *parser.Property) string { + tag := prop.Name + // K8s API best practice: all fields should have omitempty + tag += ",omitempty" + return tag +} + +// isRefProperty checks if a property is a reference +func isRefProperty(prop *parser.Property) bool { + return prop.IsReference +} + +// hasRootOneOf returns true if the schema has root-level oneOf (i.e., the schema itself is a union type) +func hasRootOneOf(schema *parser.Schema) bool { + return len(schema.OneOf) > 0 +} + +// skipProperty returns true if the property should be skipped in CRD generation +func skipProperty(prop *parser.Property) bool { + // Skip read-only properties (they're typically server-managed like id, created_at, updated_at) + if prop.ReadOnly { + return true + } + // Skip id field as it's managed by Kubernetes + if prop.Name == "id" { + return true + } + // Skip timestamp fields + if prop.Name == "created_at" || prop.Name == "updated_at" { + return true + } + return false +} + +// schemaUsesJSON checks if any property in the schema will be generated as apiextensionsv1.JSON +func schemaUsesJSON(g *Generator, schema *parser.Schema) bool { + for _, prop := range schema.Properties { + if skipProperty(prop) { + continue + } + if g.goType(prop) == "apiextensionsv1.JSON" { + return true + } + } + return false +} diff --git a/crd-from-oas/pkg/generator/generator_test.go b/crd-from-oas/pkg/generator/generator_test.go new file mode 100644 index 0000000000..c9900717c5 --- /dev/null +++ b/crd-from-oas/pkg/generator/generator_test.go @@ -0,0 +1,594 @@ +package generator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/config" + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/parser" +) + +func TestObjectRefTypeName(t *testing.T) { + tests := []struct { + name string + commonTypes *config.CommonTypesConfig + want string + }{ + { + name: "nil commonTypes returns ObjectRef", + commonTypes: nil, + want: "ObjectRef", + }, + { + name: "generate true returns ObjectRef", + commonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{ + Generate: new(true), + }, + }, + want: "ObjectRef", + }, + { + name: "import with alias returns qualified name", + commonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{ + Import: &config.ImportConfig{ + Path: "github.com/kong/kong-operator/v2/api/common/v1alpha1", + Alias: "commonv1alpha1", + }, + }, + }, + want: "commonv1alpha1.ObjectRef", + }, + { + name: "import without alias uses last path segment", + commonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{ + Import: &config.ImportConfig{ + Path: "github.com/kong/kong-operator/v2/api/common/v1alpha1", + }, + }, + }, + want: "v1alpha1.ObjectRef", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewGenerator(Config{ + CommonTypes: tc.commonTypes, + }) + assert.Equal(t, tc.want, g.objectRefTypeName()) + }) + } +} + +func TestGoType_ObjectRef(t *testing.T) { + t.Run("without import uses ObjectRef", func(t *testing.T) { + g := NewGenerator(Config{}) + prop := &parser.Property{IsReference: true} + assert.Equal(t, "*ObjectRef", g.goType(prop)) + }) + + t.Run("with import uses qualified ObjectRef", func(t *testing.T) { + g := NewGenerator(Config{ + CommonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{ + Import: &config.ImportConfig{ + Path: "github.com/kong/kong-operator/v2/api/common/v1alpha1", + Alias: "commonv1alpha1", + }, + }, + }, + }) + prop := &parser.Property{IsReference: true} + assert.Equal(t, "*commonv1alpha1.ObjectRef", g.goType(prop)) + }) +} + +func TestGenerateCommonTypes(t *testing.T) { + t.Run("without import includes union ObjectRef types", func(t *testing.T) { + g := NewGenerator(Config{APIVersion: "v1alpha1"}) + content, err := g.generateCommonTypes() + require.NoError(t, err) + assert.Contains(t, content, "type ObjectRefType string") + assert.Contains(t, content, "type ObjectRef struct") + assert.Contains(t, content, "Type ObjectRefType") + assert.NotContains(t, content, "KonnectID") + assert.Contains(t, content, "NamespacedRef *NamespacedRef") + assert.Contains(t, content, "type NamespacedRef struct") + assert.NotContains(t, content, "Namespace *string") + assert.Contains(t, content, "Code generated by CRD generation pipeline. DO NOT EDIT.") + }) + + t.Run("without import with namespaced includes Namespace field", func(t *testing.T) { + g := NewGenerator(Config{ + APIVersion: "v1alpha1", + CommonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{ + Namespaced: true, + }, + }, + }) + content, err := g.generateCommonTypes() + require.NoError(t, err) + assert.Contains(t, content, "type NamespacedRef struct") + assert.Contains(t, content, "Namespace *string") + }) + + t.Run("with import excludes ObjectRef types", func(t *testing.T) { + g := NewGenerator(Config{ + APIVersion: "v1alpha1", + CommonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{ + Import: &config.ImportConfig{ + Path: "github.com/kong/kong-operator/v2/api/common/v1alpha1", + Alias: "commonv1alpha1", + }, + }, + }, + }) + content, err := g.generateCommonTypes() + require.NoError(t, err) + assert.NotContains(t, content, "type ObjectRef struct") + assert.NotContains(t, content, "type ObjectRefType string") + assert.NotContains(t, content, "type NamespacedRef struct") + // Other common types should still be present + assert.Contains(t, content, "type SecretKeyRef struct") + assert.Contains(t, content, "type KonnectEntityStatus struct") + assert.Contains(t, content, "type KonnectEntityRef struct") + assert.Contains(t, content, "Code generated by CRD generation pipeline. DO NOT EDIT.") + }) +} + +func TestGenerateCRDType_ObjectRefImport(t *testing.T) { + schema := &parser.Schema{ + Name: "CreatePortal", + Dependencies: []*parser.Dependency{ + { + ParamName: "portalId", + EntityName: "Portal", + FieldName: "PortalRef", + JSONName: "portal_ref", + }, + }, + } + + t.Run("without import uses unqualified ObjectRef", func(t *testing.T) { + g := NewGenerator(Config{ + APIGroup: "konnect.konghq.com", + APIVersion: "v1alpha1", + }) + content, err := g.generateCRDType("CreatePortal", schema) + require.NoError(t, err) + assert.Contains(t, content, "PortalRef ObjectRef") + assert.NotContains(t, content, "commonv1alpha1") + }) + + t.Run("with import uses qualified ObjectRef and adds import", func(t *testing.T) { + g := NewGenerator(Config{ + APIGroup: "konnect.konghq.com", + APIVersion: "v1alpha1", + CommonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{ + Import: &config.ImportConfig{ + Path: "github.com/kong/kong-operator/v2/api/common/v1alpha1", + Alias: "commonv1alpha1", + }, + }, + }, + }) + content, err := g.generateCRDType("CreatePortal", schema) + require.NoError(t, err) + assert.Contains(t, content, "PortalRef commonv1alpha1.ObjectRef") + assert.Contains(t, content, `commonv1alpha1 "github.com/kong/kong-operator/v2/api/common/v1alpha1"`) + }) + + t.Run("with import qualifies ref property types", func(t *testing.T) { + schemaWithRef := &parser.Schema{ + Name: "CreateTeam", + Properties: []*parser.Property{ + { + Name: "portal_id", + Type: "string", + Format: "uuid", + IsReference: true, + }, + }, + } + g := NewGenerator(Config{ + APIGroup: "konnect.konghq.com", + APIVersion: "v1alpha1", + CommonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{ + Import: &config.ImportConfig{ + Path: "github.com/kong/kong-operator/v2/api/common/v1alpha1", + Alias: "commonv1alpha1", + }, + }, + }, + }) + content, err := g.generateCRDType("CreateTeam", schemaWithRef) + require.NoError(t, err) + assert.Contains(t, content, "*commonv1alpha1.ObjectRef") + }) +} + +func TestGenerateCRDType_NoObjectRefImportWhenUnneeded(t *testing.T) { + schema := &parser.Schema{ + Name: "CreatePortal", + Properties: []*parser.Property{ + { + Name: "name", + Type: "string", + }, + }, + } + + g := NewGenerator(Config{ + APIGroup: "konnect.konghq.com", + APIVersion: "v1alpha1", + CommonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{ + Import: &config.ImportConfig{ + Path: "github.com/kong/kong-operator/v2/api/common/v1alpha1", + Alias: "commonv1alpha1", + }, + }, + }, + }) + content, err := g.generateCRDType("CreatePortal", schema) + require.NoError(t, err) + // When there are no dependencies or ref properties, the import should + // not be included to avoid unused import errors. + assert.NotContains(t, content, "commonv1alpha1") +} + +func TestObjectRefNamespaced(t *testing.T) { + tests := []struct { + name string + commonTypes *config.CommonTypesConfig + want bool + }{ + { + name: "nil commonTypes", + want: false, + }, + { + name: "nil objectRef", + commonTypes: &config.CommonTypesConfig{}, + want: false, + }, + { + name: "namespaced false", + commonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{Namespaced: false}, + }, + want: false, + }, + { + name: "namespaced true", + commonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{Namespaced: true}, + }, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewGenerator(Config{CommonTypes: tc.commonTypes}) + assert.Equal(t, tc.want, g.objectRefNamespaced()) + }) + } +} + +func TestObjectRefImported(t *testing.T) { + tests := []struct { + name string + commonTypes *config.CommonTypesConfig + want bool + }{ + { + name: "nil commonTypes", + want: false, + }, + { + name: "nil objectRef", + commonTypes: &config.CommonTypesConfig{}, + want: false, + }, + { + name: "generate true, no import", + commonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{Generate: new(true)}, + }, + want: false, + }, + { + name: "import set", + commonTypes: &config.CommonTypesConfig{ + ObjectRef: &config.ObjectRefConfig{ + Import: &config.ImportConfig{Path: "some/path"}, + }, + }, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + g := NewGenerator(Config{CommonTypes: tc.commonTypes}) + assert.Equal(t, tc.want, g.objectRefImported()) + }) + } +} + +func TestExtractVariantNames(t *testing.T) { + tests := []struct { + name string + input []string + expected []string + }{ + { + name: "empty input", + input: []string{}, + expected: nil, + }, + { + name: "single variant - removes common prefixes/suffixes", + input: []string{"CreateDcrProviderRequestAuth0"}, + expected: []string{"DcrProviderRequestAuth0"}, // Request not removed since it's not at the end + }, + { + name: "identity provider variants - OIDC and SAML", + input: []string{"ConfigureOIDCIdentityProviderConfig", "SAMLIdentityProviderConfig"}, + expected: []string{"OIDC", "SAML"}, + }, + { + name: "dcr provider variants - multiple providers", + input: []string{"CreateDcrProviderRequestAuth0", "CreateDcrProviderRequestAzureAd", "CreateDcrProviderRequestCurity", "CreateDcrProviderRequestOkta", "CreateDcrProviderRequestHttp"}, + expected: []string{"Auth0", "AzureAd", "Curity", "Okta", "Http"}, + }, + { + name: "common prefix only", + input: []string{"ConfigTypeA", "ConfigTypeB"}, + expected: []string{"A", "B"}, + }, + { + name: "common suffix only", + input: []string{"AConfig", "BConfig"}, + expected: []string{"A", "B"}, + }, + { + name: "no common prefix or suffix - common suffix 'a' is too short", + input: []string{"Alpha", "Beta"}, + expected: []string{"Alph", "Bet"}, // common suffix is "a" so it gets trimmed + }, + { + name: "variants with Configure prefix", + input: []string{"ConfigureAuth", "ConfigureSAML"}, + expected: []string{"Auth", "SAML"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := extractVariantNames(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestCommonPrefix(t *testing.T) { + tests := []struct { + name string + a string + b string + expected string + }{ + { + name: "identical strings", + a: "hello", + b: "hello", + expected: "hello", + }, + { + name: "common prefix", + a: "CreateDcrProviderRequestAuth0", + b: "CreateDcrProviderRequestAzureAd", + expected: "CreateDcrProviderRequestA", + }, + { + name: "no common prefix", + a: "alpha", + b: "beta", + expected: "", + }, + { + name: "empty strings", + a: "", + b: "", + expected: "", + }, + { + name: "one empty string", + a: "hello", + b: "", + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := commonPrefix(tc.a, tc.b) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestCommonSuffix(t *testing.T) { + tests := []struct { + name string + a string + b string + expected string + }{ + { + name: "identical strings", + a: "hello", + b: "hello", + expected: "hello", + }, + { + name: "common suffix", + a: "ConfigureOIDCIdentityProviderConfig", + b: "SAMLIdentityProviderConfig", + expected: "IdentityProviderConfig", + }, + { + name: "no common suffix", + a: "alpha", + b: "beta", + expected: "a", + }, + { + name: "empty strings", + a: "", + b: "", + expected: "", + }, + { + name: "one empty string", + a: "hello", + b: "", + expected: "", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := commonSuffix(tc.a, tc.b) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestCleanSingleVariantName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "removes Config suffix", + input: "SomethingConfig", + expected: "Something", + }, + { + name: "removes Configuration suffix", + input: "SomethingConfiguration", + expected: "Something", + }, + { + name: "removes Provider suffix", + input: "SomethingProvider", + expected: "Something", + }, + { + name: "removes Request suffix", + input: "SomethingRequest", + expected: "Something", + }, + { + name: "removes Configure prefix", + input: "ConfigureSomething", + expected: "Something", + }, + { + name: "removes Create prefix", + input: "CreateSomething", + expected: "Something", + }, + { + name: "removes Update prefix", + input: "UpdateSomething", + expected: "Something", + }, + { + name: "removes multiple prefixes/suffixes", + input: "CreateSomethingRequest", + expected: "Something", + }, + { + name: "no prefixes or suffixes to remove", + input: "Something", + expected: "Something", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := cleanSingleVariantName(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestParseSDKTypePath(t *testing.T) { + tests := []struct { + name string + input string + wantImport string + wantType string + wantErr bool + }{ + { + name: "valid SDK type path", + input: "github.com/Kong/sdk-konnect-go/models/components.CreatePortal", + wantImport: "github.com/Kong/sdk-konnect-go/models/components", + wantType: "CreatePortal", + }, + { + name: "valid path with nested packages", + input: "github.com/Kong/sdk-konnect-go/models/operations.ListPortals", + wantImport: "github.com/Kong/sdk-konnect-go/models/operations", + wantType: "ListPortals", + }, + { + name: "no dot separator", + input: "noDotAtAll", + wantErr: true, + }, + { + name: "leading dot", + input: ".CreatePortal", + wantErr: true, + }, + { + name: "trailing dot", + input: "github.com/Kong/sdk-konnect-go/models/components.", + wantErr: true, + }, + { + name: "empty string", + input: "", + wantErr: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + importPath, typeName, err := ParseSDKTypePath(tc.input) + if tc.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantImport, importPath) + assert.Equal(t, tc.wantType, typeName) + }) + } +} diff --git a/crd-from-oas/pkg/generator/markers.go b/crd-from-oas/pkg/generator/markers.go new file mode 100644 index 0000000000..7990d73b30 --- /dev/null +++ b/crd-from-oas/pkg/generator/markers.go @@ -0,0 +1,31 @@ +package generator + +import "fmt" + +const ( + kbOptional = "+optional" + kbRequired = "+required" + + kbValidationMaxLengthFmt = "+kubebuilder:validation:MaxLength=%d" + kbValidationMinLengthFmt = "+kubebuilder:validation:MinLength=%d" + kbValidationPatternFmt = "+kubebuilder:validation:Pattern=`%s`" + kbValidationMinimumFmt = "+kubebuilder:validation:Minimum=%v" + kbValidationMaximumFmt = "+kubebuilder:validation:Maximum=%v" + kbValidationEnumFmt = "+kubebuilder:validation:Enum=%s" + + kbDefaultBoolFmt = "+kubebuilder:default=%t" + kbDefaultStringFmt = "+kubebuilder:default=%s" +) + +func markerOptional() string { return kbOptional } +func markerRequired() string { return kbRequired } + +func markerValidationMaxLength(v int) string { return fmt.Sprintf(kbValidationMaxLengthFmt, v) } +func markerValidationMinLength(v int) string { return fmt.Sprintf(kbValidationMinLengthFmt, v) } +func markerValidationPattern(v string) string { return fmt.Sprintf(kbValidationPatternFmt, v) } +func markerValidationMinimum(v any) string { return fmt.Sprintf(kbValidationMinimumFmt, v) } +func markerValidationMaximum(v any) string { return fmt.Sprintf(kbValidationMaximumFmt, v) } +func markerValidationEnum(v string) string { return fmt.Sprintf(kbValidationEnumFmt, v) } + +func markerDefaultBool(v bool) string { return fmt.Sprintf(kbDefaultBoolFmt, v) } +func markerDefaultString(v string) string { return fmt.Sprintf(kbDefaultStringFmt, v) } diff --git a/crd-from-oas/pkg/generator/parsesdkpath.go b/crd-from-oas/pkg/generator/parsesdkpath.go new file mode 100644 index 0000000000..99d37f307f --- /dev/null +++ b/crd-from-oas/pkg/generator/parsesdkpath.go @@ -0,0 +1,17 @@ +package generator + +import ( + "fmt" + "strings" +) + +// ParseSDKTypePath splits a fully qualified SDK type path like +// "github.com/Kong/sdk-konnect-go/models/components.CreatePortal" +// into its import path and type name by splitting on the last ".". +func ParseSDKTypePath(path string) (importPath, typeName string, err error) { + lastDot := strings.LastIndex(path, ".") + if lastDot == -1 || lastDot == 0 || lastDot == len(path)-1 { + return "", "", fmt.Errorf("invalid SDK type path %q: must be in format 'importpath.TypeName'", path) + } + return path[:lastDot], path[lastDot+1:], nil +} diff --git a/crd-from-oas/pkg/generator/tags.go b/crd-from-oas/pkg/generator/tags.go new file mode 100644 index 0000000000..6b9e502c63 --- /dev/null +++ b/crd-from-oas/pkg/generator/tags.go @@ -0,0 +1,82 @@ +package generator + +import ( + "fmt" + "strings" + + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/config" + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/parser" +) + +// KubebuilderTags generates kubebuilder validation tags for a property. +// It takes an optional fieldConfig for custom validations. +func KubebuilderTags(prop *parser.Property, entityName string, fieldConfig *config.Config) []string { + var tags []string + + // Required validation + if prop.Required && !prop.Nullable { + tags = append(tags, markerRequired()) + } else { + tags = append(tags, markerOptional()) + } + + // String validations (skip for reference properties which become ObjectRef) + if prop.Type == "string" && !prop.IsReference { + if prop.MinLength != nil { + tags = append(tags, markerValidationMinLength(int(*prop.MinLength))) + } else if prop.Required && !prop.Nullable { + // Add MinLength=1 for required strings without explicit minLength + tags = append(tags, markerValidationMinLength(1)) + } + if prop.MaxLength != nil { + tags = append(tags, markerValidationMaxLength(int(*prop.MaxLength))) + } else { + // Add default MaxLength for strings without explicit maxLength + tags = append(tags, markerValidationMaxLength(256)) + } + if prop.Pattern != "" { + tags = append(tags, markerValidationPattern(prop.Pattern)) + } + } + + // Numeric validations + if prop.Minimum != nil { + tags = append(tags, markerValidationMinimum(*prop.Minimum)) + } + if prop.Maximum != nil { + tags = append(tags, markerValidationMaximum(*prop.Maximum)) + } + + // Enum validation + if len(prop.Enum) > 0 { + var enumValues []string + for _, e := range prop.Enum { + if s, ok := e.(string); ok { + enumValues = append(enumValues, s) + } + } + if len(enumValues) > 0 { + tags = append(tags, markerValidationEnum(strings.Join(enumValues, ";"))) + } + } + + // Default value + if prop.Default != nil { + switch v := prop.Default.(type) { + case bool: + tags = append(tags, markerDefaultBool(v)) + case string: + tags = append(tags, markerDefaultString(v)) + default: + panic("unsupported default value type: " + fmt.Sprintf("%T", v)) + } + } + + // Add custom validations from config + if fieldConfig != nil { + customValidations := fieldConfig.GetFieldValidations(entityName, prop.Name) + tags = append(tags, customValidations...) + } + + return tags +} diff --git a/crd-from-oas/pkg/generator/tags_test.go b/crd-from-oas/pkg/generator/tags_test.go new file mode 100644 index 0000000000..0a7c84d335 --- /dev/null +++ b/crd-from-oas/pkg/generator/tags_test.go @@ -0,0 +1,413 @@ +package generator + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/config" + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/parser" +) + +func TestKubebuilderTags(t *testing.T) { + tests := []struct { + name string + prop *parser.Property + entityName string + fieldConfig *config.Config + expected []string + }{ + { + name: "required non-nullable string without validations", + prop: &parser.Property{ + Name: "name", + Type: "string", + Required: true, + Nullable: false, + }, + expected: []string{ + "+required", + "+kubebuilder:validation:MinLength=1", + "+kubebuilder:validation:MaxLength=256", + }, + }, + { + name: "optional string", + prop: &parser.Property{ + Name: "description", + Type: "string", + Required: false, + }, + expected: []string{ + "+optional", + "+kubebuilder:validation:MaxLength=256", + }, + }, + { + name: "required nullable string is optional", + prop: &parser.Property{ + Name: "title", + Type: "string", + Required: true, + Nullable: true, + }, + expected: []string{ + "+optional", + "+kubebuilder:validation:MaxLength=256", + }, + }, + { + name: "string with explicit min and max length", + prop: &parser.Property{ + Name: "code", + Type: "string", + Required: true, + MinLength: new(int64(3)), + MaxLength: new(int64(10)), + }, + expected: []string{ + "+required", + "+kubebuilder:validation:MinLength=3", + "+kubebuilder:validation:MaxLength=10", + }, + }, + { + name: "string with pattern", + prop: &parser.Property{ + Name: "email", + Type: "string", + Required: true, + Pattern: `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, + }, + expected: []string{ + "+required", + "+kubebuilder:validation:MinLength=1", + "+kubebuilder:validation:MaxLength=256", + "+kubebuilder:validation:Pattern=`^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$`", + }, + }, + { + name: "string with enum", + prop: &parser.Property{ + Name: "status", + Type: "string", + Required: true, + Enum: []any{"active", "inactive", "pending"}, + }, + expected: []string{ + "+required", + "+kubebuilder:validation:MinLength=1", + "+kubebuilder:validation:MaxLength=256", + "+kubebuilder:validation:Enum=active;inactive;pending", + }, + }, + { + name: "integer with minimum and maximum", + prop: &parser.Property{ + Name: "port", + Type: "integer", + Required: true, + Minimum: new(float64(1)), + Maximum: new(float64(65535)), + }, + expected: []string{ + "+required", + "+kubebuilder:validation:Minimum=1", + "+kubebuilder:validation:Maximum=65535", + }, + }, + { + name: "integer with only minimum", + prop: &parser.Property{ + Name: "retries", + Type: "integer", + Required: false, + Minimum: new(float64(0)), + }, + expected: []string{ + "+optional", + "+kubebuilder:validation:Minimum=0", + }, + }, + { + name: "boolean with default true", + prop: &parser.Property{ + Name: "enabled", + Type: "boolean", + Required: false, + Default: true, + }, + expected: []string{ + "+optional", + "+kubebuilder:default=true", + }, + }, + { + name: "boolean with default false", + prop: &parser.Property{ + Name: "disabled", + Type: "boolean", + Required: false, + Default: false, + }, + expected: []string{ + "+optional", + "+kubebuilder:default=false", + }, + }, + { + name: "string with default value", + prop: &parser.Property{ + Name: "protocol", + Type: "string", + Required: false, + Default: "https", + }, + expected: []string{ + "+optional", + "+kubebuilder:validation:MaxLength=256", + "+kubebuilder:default=https", + }, + }, + { + name: "non-string type without string validations", + prop: &parser.Property{ + Name: "count", + Type: "integer", + Required: true, + }, + expected: []string{ + "+required", + }, + }, + { + name: "array type", + prop: &parser.Property{ + Name: "items", + Type: "array", + Required: false, + }, + expected: []string{ + "+optional", + }, + }, + { + name: "object type", + prop: &parser.Property{ + Name: "metadata", + Type: "object", + Required: false, + }, + expected: []string{ + "+optional", + }, + }, + { + name: "enum with mixed types only uses strings", + prop: &parser.Property{ + Name: "priority", + Type: "string", + Required: false, + Enum: []any{"low", 1, "high", nil, "medium"}, + }, + expected: []string{ + "+optional", + "+kubebuilder:validation:MaxLength=256", + "+kubebuilder:validation:Enum=low;high;medium", + }, + }, + { + name: "enum with no string values", + prop: &parser.Property{ + Name: "level", + Type: "integer", + Required: false, + Enum: []any{1, 2, 3}, + }, + expected: []string{ + "+optional", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := KubebuilderTags(tt.prop, tt.entityName, tt.fieldConfig) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestKubebuilderTags_WithFieldConfig(t *testing.T) { + tests := []struct { + name string + prop *parser.Property + entityName string + fieldConfig *config.Config + expected []string + }{ + { + name: "field with custom validations from config", + prop: &parser.Property{ + Name: "url", + Type: "string", + Required: true, + }, + entityName: "Portal", + fieldConfig: &config.Config{ + Entities: map[string]*config.EntityConfig{ + "Portal": { + Fields: map[string]*config.FieldConfig{ + "url": { + Validations: []string{ + "+kubebuilder:validation:Format=uri", + }, + }, + }, + }, + }, + }, + expected: []string{ + "+required", + "+kubebuilder:validation:MinLength=1", + "+kubebuilder:validation:MaxLength=256", + "+kubebuilder:validation:Format=uri", + }, + }, + { + name: "field with multiple custom validations", + prop: &parser.Property{ + Name: "host", + Type: "string", + Required: false, + }, + entityName: "Service", + fieldConfig: &config.Config{ + Entities: map[string]*config.EntityConfig{ + "Service": { + Fields: map[string]*config.FieldConfig{ + "host": { + Validations: []string{ + "+kubebuilder:validation:Format=hostname", + "+kubebuilder:validation:XValidation:rule=\"self.matches('^[a-z]')\"", + }, + }, + }, + }, + }, + }, + expected: []string{ + "+optional", + "+kubebuilder:validation:MaxLength=256", + "+kubebuilder:validation:Format=hostname", + "+kubebuilder:validation:XValidation:rule=\"self.matches('^[a-z]')\"", + }, + }, + { + name: "field config for different entity does not apply", + prop: &parser.Property{ + Name: "name", + Type: "string", + Required: true, + }, + entityName: "Portal", + fieldConfig: &config.Config{ + Entities: map[string]*config.EntityConfig{ + "Service": { + Fields: map[string]*config.FieldConfig{ + "name": { + Validations: []string{ + "+kubebuilder:validation:Format=dns", + }, + }, + }, + }, + }, + }, + expected: []string{ + "+required", + "+kubebuilder:validation:MinLength=1", + "+kubebuilder:validation:MaxLength=256", + }, + }, + { + name: "field config for different field does not apply", + prop: &parser.Property{ + Name: "name", + Type: "string", + Required: true, + }, + entityName: "Portal", + fieldConfig: &config.Config{ + Entities: map[string]*config.EntityConfig{ + "Portal": { + Fields: map[string]*config.FieldConfig{ + "url": { + Validations: []string{ + "+kubebuilder:validation:Format=uri", + }, + }, + }, + }, + }, + }, + expected: []string{ + "+required", + "+kubebuilder:validation:MinLength=1", + "+kubebuilder:validation:MaxLength=256", + }, + }, + { + name: "nil field config", + prop: &parser.Property{ + Name: "name", + Type: "string", + Required: true, + }, + entityName: "Portal", + fieldConfig: nil, + expected: []string{ + "+required", + "+kubebuilder:validation:MinLength=1", + "+kubebuilder:validation:MaxLength=256", + }, + }, + { + name: "empty field config", + prop: &parser.Property{ + Name: "name", + Type: "string", + Required: true, + }, + entityName: "Portal", + fieldConfig: &config.Config{ + Entities: map[string]*config.EntityConfig{}, + }, + expected: []string{ + "+required", + "+kubebuilder:validation:MinLength=1", + "+kubebuilder:validation:MaxLength=256", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := KubebuilderTags(tt.prop, tt.entityName, tt.fieldConfig) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestKubebuilderTags_DefaultPanic(t *testing.T) { + prop := &parser.Property{ + Name: "count", + Type: "integer", + Required: false, + Default: 123, // int type, not bool or string + } + + assert.Panics(t, func() { + KubebuilderTags(prop, "Test", nil) + }) +} diff --git a/crd-from-oas/pkg/generator/templates.go b/crd-from-oas/pkg/generator/templates.go new file mode 100644 index 0000000000..fe65398a53 --- /dev/null +++ b/crd-from-oas/pkg/generator/templates.go @@ -0,0 +1,216 @@ +package generator + +const ( + sharedGeneratedFilePreamble = `// Code generated by CRD generation pipeline. DO NOT EDIT.` +) + +const crdTypeTemplate = sharedGeneratedFilePreamble + ` + +package {{.APIVersion}} + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +{{- if .NeedsJSONImport}} + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +{{- end}} +{{- if .ObjectRefImport}} + {{.ObjectRefImport.Alias}} "{{.ObjectRefImport.Path}}" +{{- end}} +) + +// {{.EntityName}} is the Schema for the {{.EntityName | lower}}s API. +// +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:scope=Namespaced +// +kubebuilder:printcolumn:name="ID",description="Konnect ID",type="string",JSONPath=".status.id" +// +kubebuilder:printcolumn:name="Programmed",description="The Resource is Programmed on Konnect",type=string,JSONPath=` + "`" + `.status.conditions[?(@.type=='Programmed')].status` + "`" + ` +// +kubebuilder:printcolumn:name="OrgID",description="Konnect Organization ID this resource belongs to.",type=string,JSONPath=` + "`" + `.status.organizationID` + "`" + ` +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" +// +kubebuilder:storageversion +// +apireference:kgo:include +// +kong:channels=kong-operator +type {{.EntityName}} struct { + metav1.TypeMeta ` + "`" + `json:",inline"` + "`" + ` + metav1.ObjectMeta ` + "`" + `json:"metadata,omitzero"` + "`" + ` + + // +optional + Spec {{.EntityName}}Spec ` + "`" + `json:"spec,omitzero"` + "`" + ` + + // +optional + Status {{.EntityName}}Status ` + "`" + `json:"status,omitzero"` + "`" + ` +} + +// {{.EntityName}}List contains a list of {{.EntityName}}. +// +// +kubebuilder:object:root=true +type {{.EntityName}}List struct { + metav1.TypeMeta ` + "`" + `json:",inline"` + "`" + ` + metav1.ListMeta ` + "`" + `json:"metadata,omitzero"` + "`" + ` + Items []{{.EntityName}} ` + "`" + `json:"items"` + "`" + ` +} + +// {{.EntityName}}Spec defines the desired state of {{.EntityName}}. +type {{.EntityName}}Spec struct { +{{- range .Schema.Dependencies}} + // {{.FieldName}} is the reference to the parent {{.EntityName}} object. + // + // +required + {{.FieldName}} {{objectRefTypeName}} ` + "`" + `json:"{{.JSONName}},omitzero"` + "`" + ` +{{end}} + // APISpec defines the desired state of the resource's API spec fields. + // + // +optional + APISpec {{.EntityName}}APISpec ` + "`" + `json:"apiSpec,omitzero"` + "`" + ` +} + +// {{.EntityName}}APISpec defines the API spec fields for {{.EntityName}}. +type {{.EntityName}}APISpec struct { +{{- if hasRootOneOf .Schema}} + // {{.EntityName}}Config embeds the union type configuration. + // + // +optional + *{{.EntityName}}Config ` + "`" + `json:",inline"` + "`" + ` +{{- else}} +{{- range $i, $prop := .Schema.Properties}} +{{- if not (skipProperty $prop)}} +{{formatComment $prop.Description}} + // +{{- range kubebuilderTags $prop}} + // {{.}} +{{- end}} +{{- if isRefProperty $prop}} + {{goFieldName $prop.Name}}Ref {{goType $prop}} ` + "`" + `json:"{{$prop.Name}}_ref,omitempty"` + "`" + ` +{{- else}} + {{goFieldName $prop.Name}} {{goType $prop}} ` + "`" + `json:"{{jsonTag $prop}}"` + "`" + ` +{{- end}} +{{end}} +{{- end}} +{{- end}} +} + +// {{.EntityName}}Status defines the observed state of {{.EntityName}}. +type {{.EntityName}}Status struct { + // Conditions represent the current state of the resource. + // + // +optional + // +listType=map + // +listMapKey=type + // +patchStrategy=merge + // +patchMergeKey=type + // +kubebuilder:validation:MaxItems=8 + Conditions []metav1.Condition ` + "`" + `json:"conditions,omitempty" patchStrategy:"merge" patchMergeKey:"type" protobuf:"bytes,1,rep,name=conditions"` + "`" + ` + + // Konnect contains the Konnect entity status. + // + // +optional + KonnectEntityStatus ` + "`" + `json:",inline"` + "`" + ` +{{range .Schema.Dependencies}} + // {{.EntityName}}ID is the Konnect ID of the parent {{.EntityName}}. + // + // +optional + {{.EntityName}}ID *KonnectEntityRef ` + "`" + `json:"{{.EntityName | lower}}ID,omitempty"` + "`" + ` +{{end}} + // ObservedGeneration is the most recent generation observed + // + // +optional + ObservedGeneration int64 ` + "`" + `json:"observedGeneration,omitempty"` + "`" + ` +} + +func init() { + SchemeBuilder.Register(&{{.EntityName}}{}, &{{.EntityName}}List{}) +} +` + +const sdkOpsTemplate = sharedGeneratedFilePreamble + ` + +package {{.APIVersion}} + +import ( + "encoding/json" + "fmt" +{{range .Imports}} + {{.Alias}} "{{.Path}}" +{{- end}} +) +{{range .Methods}} +// {{.MethodName}} converts the {{$.EntityName}}APISpec to the SDK type +// {{.ImportAlias}}.{{.TypeName}} using JSON marshal/unmarshal. +// Fields that exist in the CRD spec but not in the SDK type (e.g., Kubernetes +// object references) are naturally excluded because they have different JSON names. +func (s *{{$.EntityName}}APISpec) {{.MethodName}}() (*{{.ImportAlias}}.{{.TypeName}}, error) { + data, err := json.Marshal(s) + if err != nil { + return nil, fmt.Errorf("failed to marshal {{$.EntityName}}APISpec: %w", err) + } + var target {{.ImportAlias}}.{{.TypeName}} + if err := json.Unmarshal(data, &target); err != nil { + return nil, fmt.Errorf("failed to unmarshal into {{.TypeName}}: %w", err) + } + return &target, nil +} +{{end}}` + +const sdkOpsTestTemplate = sharedGeneratedFilePreamble + ` + +package {{.APIVersion}} + +import ( + "testing" + + "github.com/stretchr/testify/require" +) +{{range .Methods}} +func Test{{$.EntityName}}APISpec_{{.MethodName}}(t *testing.T) { + spec := &{{$.EntityName}}APISpec{ +{{- range $.TestFields}} + {{.FieldName}}: {{.TestValue}}, +{{- end}} + } + result, err := spec.{{.MethodName}}() + require.NoError(t, err) + require.NotNil(t, result) +} +{{end}}` + +const commonTypesTemplate = sharedGeneratedFilePreamble + ` + +package {{.APIVersion}} +{{- if not .ObjectRefImported}} + +` + objectRefTypeEnum + ` + +` + objectRefType + ` + +` + namespacedRefType + ` +{{- end}} + +` + secretKeyRefType + ` + +` + configMapKeyRefType + ` + +` + konnectEntityStatusType + ` + +` + konnectEntityRefType + ` +` + +const registerTemplate = sharedGeneratedFilePreamble + ` + +package {{.APIVersion}} + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects + GroupVersion = schema.GroupVersion{Group: "{{.APIGroup}}", Version: "{{.APIVersion}}"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme + AddToScheme = SchemeBuilder.AddToScheme +) +` diff --git a/crd-from-oas/pkg/generator/wrap.go b/crd-from-oas/pkg/generator/wrap.go new file mode 100644 index 0000000000..381787fbe6 --- /dev/null +++ b/crd-from-oas/pkg/generator/wrap.go @@ -0,0 +1,94 @@ +package generator + +import "strings" + +// WrapLine wraps a line to the specified width, breaking at word boundaries. +// It first splits on sentence boundaries (". "), then wraps any long sentences. +func WrapLine(line string, maxWidth int) []string { + if len(line) <= maxWidth { + return []string{line} + } + + var result []string + + // Split on sentence boundaries (". " followed by more text) + sentences := SplitSentences(line) + + for _, sentence := range sentences { + sentence = strings.TrimSpace(sentence) + if sentence == "" { + continue + } + + // If sentence fits on one line, add it directly + if len(sentence) <= maxWidth { + result = append(result, sentence) + continue + } + + // Otherwise, wrap the sentence at word boundaries + wrapped := WrapLongLine(sentence, maxWidth) + result = append(result, wrapped...) + } + + return result +} + +// SplitSentences splits text on sentence boundaries (". " followed by more text) +func SplitSentences(text string) []string { + var sentences []string + remaining := text + + for { + // Find ". " followed by a character (sentence boundary) + idx := strings.Index(remaining, ". ") + if idx == -1 { + // No more sentence boundaries + if remaining != "" { + sentences = append(sentences, remaining) + } + break + } + + // Check if there's more text after ". " + if idx+2 < len(remaining) { + // Add the sentence including the period + sentences = append(sentences, remaining[:idx+1]) + remaining = remaining[idx+2:] + } else { + // ". " is at the end, just add the rest + sentences = append(sentences, remaining) + break + } + } + + return sentences +} + +// WrapLongLine wraps a single long line at word boundaries +func WrapLongLine(line string, maxWidth int) []string { + var result []string + words := strings.Fields(line) + if len(words) == 0 { + return []string{line} + } + + currentLine := words[0] + for i := 1; i < len(words); i++ { + word := words[i] + tentativeLine := currentLine + " " + word + + if len(tentativeLine) > maxWidth { + result = append(result, currentLine) + currentLine = word + } else { + currentLine = tentativeLine + } + } + + if currentLine != "" { + result = append(result, currentLine) + } + + return result +} diff --git a/crd-from-oas/pkg/generator/wrap_test.go b/crd-from-oas/pkg/generator/wrap_test.go new file mode 100644 index 0000000000..84ffd1c3a7 --- /dev/null +++ b/crd-from-oas/pkg/generator/wrap_test.go @@ -0,0 +1,257 @@ +package generator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitSentences(t *testing.T) { + tests := []struct { + name string + input string + expected []string + }{ + { + name: "single sentence without period", + input: "This is a test", + expected: []string{"This is a test"}, + }, + { + name: "single sentence with period", + input: "This is a test.", + expected: []string{"This is a test."}, + }, + { + name: "two sentences", + input: "First sentence. Second sentence.", + expected: []string{"First sentence.", "Second sentence."}, + }, + { + name: "three sentences", + input: "First. Second. Third.", + expected: []string{"First.", "Second.", "Third."}, + }, + { + name: "sentence with period at end only", + input: "No splits here. ", + expected: []string{"No splits here. "}, + }, + { + name: "empty string", + input: "", + expected: nil, + }, + { + name: "period without space is not a boundary", + input: "file.txt is a filename", + expected: []string{"file.txt is a filename"}, + }, + { + name: "multiple periods without spaces", + input: "v1.2.3 is a version", + expected: []string{"v1.2.3 is a version"}, + }, + { + name: "real world example", + input: "The default authentication strategy for APIs published to the portal. Newly published APIs will use this authentication strategy unless overridden during publication.", + expected: []string{ + "The default authentication strategy for APIs published to the portal.", + "Newly published APIs will use this authentication strategy unless overridden during publication.", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SplitSentences(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestWrapLongLine(t *testing.T) { + tests := []struct { + name string + input string + maxWidth int + expected []string + }{ + { + name: "short line no wrap needed", + input: "Short line", + maxWidth: 80, + expected: []string{"Short line"}, + }, + { + name: "exact fit", + input: "Exact fit", + maxWidth: 9, + expected: []string{"Exact fit"}, + }, + { + name: "wrap at word boundary", + input: "This is a longer line that needs wrapping", + maxWidth: 20, + expected: []string{"This is a longer", "line that needs", "wrapping"}, + }, + { + name: "single long word", + input: "Supercalifragilisticexpialidocious", + maxWidth: 10, + expected: []string{"Supercalifragilisticexpialidocious"}, + }, + { + name: "empty string", + input: "", + maxWidth: 80, + expected: []string{""}, + }, + { + name: "multiple spaces between words", + input: "Multiple spaces between words", + maxWidth: 20, + expected: []string{"Multiple spaces", "between words"}, + }, + { + name: "real world sentence wrap", + input: "Newly published APIs will use this authentication strategy unless overridden during publication.", + maxWidth: 76, + expected: []string{ + "Newly published APIs will use this authentication strategy unless overridden", + "during publication.", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := WrapLongLine(tt.input, tt.maxWidth) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestWrapLine(t *testing.T) { + tests := []struct { + name string + input string + maxWidth int + expected []string + }{ + { + name: "short line no wrap needed", + input: "Short line", + maxWidth: 80, + expected: []string{"Short line"}, + }, + { + name: "single sentence that fits", + input: "This is a single sentence that fits.", + maxWidth: 80, + expected: []string{"This is a single sentence that fits."}, + }, + { + name: "two short sentences that fit", + input: "First sentence. Second sentence.", + maxWidth: 80, + expected: []string{"First sentence. Second sentence."}, + }, + { + name: "two sentences that need splitting", + input: "First sentence. Second sentence.", + maxWidth: 20, + expected: []string{"First sentence.", "Second sentence."}, + }, + { + name: "sentence split across lines", + input: "This is a very long sentence that definitely needs to be wrapped across multiple lines.", + maxWidth: 40, + expected: []string{ + "This is a very long sentence that", + "definitely needs to be wrapped across", + "multiple lines.", + }, + }, + { + name: "multiple sentences with wrapping", + input: "First short sentence. This is a much longer second sentence that will need wrapping.", + maxWidth: 40, + expected: []string{ + "First short sentence.", + "This is a much longer second sentence", + "that will need wrapping.", + }, + }, + { + name: "real world portal description", + input: "The default authentication strategy for APIs published to the portal. Newly published APIs will use this authentication strategy unless overridden during publication. If set to `null`, API publications will not use an authentication strategy unless set during publication.", + maxWidth: 76, + expected: []string{ + "The default authentication strategy for APIs published to the portal.", + "Newly published APIs will use this authentication strategy unless overridden", + "during publication.", + "If set to `null`, API publications will not use an authentication strategy", + "unless set during publication.", + }, + }, + { + name: "empty string", + input: "", + maxWidth: 80, + expected: []string{""}, + }, + { + name: "abbreviations with periods not split when fits", + input: "Use the e.g. example here. And another sentence.", + maxWidth: 80, + expected: []string{"Use the e.g. example here. And another sentence."}, + }, + { + name: "abbreviations with periods split when needed", + input: "Use the e.g. example here. And another sentence.", + maxWidth: 30, + expected: []string{"Use the e.g.", "example here.", "And another sentence."}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := WrapLine(tt.input, tt.maxWidth) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestWrapLine_MaxWidthEnforced(t *testing.T) { + tests := []struct { + name string + input string + maxWidth int + }{ + { + name: "long text", + input: "The default visibility of APIs in the portal. If set to `public`, newly published APIs are visible to unauthenticated developers. If set to `private`, newly published APIs are hidden from unauthenticated developers.", + maxWidth: 76, + }, + { + name: "very narrow width", + input: "This is a test sentence. Another one here.", + maxWidth: 20, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := WrapLine(tt.input, tt.maxWidth) + for _, line := range result { + // Allow single words to exceed maxWidth + if len(line) > tt.maxWidth { + words := len(strings.Fields(line)) + assert.Equal(t, 1, words, "only single words should exceed maxWidth, got: %q", line) + } + } + }) + } +} diff --git a/crd-from-oas/pkg/parser/parser.go b/crd-from-oas/pkg/parser/parser.go new file mode 100644 index 0000000000..9871226750 --- /dev/null +++ b/crd-from-oas/pkg/parser/parser.go @@ -0,0 +1,436 @@ +package parser + +import ( + "fmt" + "regexp" + "slices" + "sort" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +// Dependency represents a parent resource dependency from a path parameter +type Dependency struct { + // ParamName is the original path parameter name (e.g., "portalId") + ParamName string + // EntityName is the entity name derived from the parameter (e.g., "Portal") + EntityName string + // FieldName is the Go field name for the reference (e.g., "PortalRef") + FieldName string + // JSONName is the JSON tag name (e.g., "portal_ref") + JSONName string +} + +// Property represents a parsed OpenAPI property with its validations +type Property struct { + Name string + Type string + Format string + Description string + Required bool + Nullable bool + ReadOnly bool + + // Validations + MinLength *int64 + MaxLength *int64 + Minimum *float64 + Maximum *float64 + Pattern string + Enum []any + Default any + + // Reference info + RefName string // If this is a $ref, the referenced schema name + IsReference bool // True if this property references another object by ID + + // Nested types + Items *Property // For array types + Properties []*Property // For object types + AdditionalProperties *Property // For map types + + // Union types (oneOf) + OneOf []*Property // For oneOf types - each represents a variant +} + +// Schema represents a parsed OpenAPI schema +type Schema struct { + Name string + SourcePath string // The OpenAPI path this schema was extracted from + Description string + Type string // The schema's type (string, boolean, integer, number, array, object) + Format string // The schema's format (url, uri, uuid, etc.) + Properties []*Property + Required []string + Dependencies []*Dependency // Parent resource dependencies from path parameters + OneOf []*Property // Root-level oneOf variants (for union type schemas) + Items *Property // For array-type schemas, the items type +} + +// ParsedSpec is the result of parsing an OpenAPI spec via ParsePaths. +type ParsedSpec struct { + // Schemas holds component schemas that are transitively referenced ($ref) by + // the request body schemas. These are resolved from the spec's + // components/schemas section and keyed by their component name. + Schemas map[string]*Schema + // RequestBodies holds schemas extracted directly from POST request bodies of + // the target paths, keyed by schema name. Each schema includes parent resource + // dependencies inferred from path parameters (e.g. {portalId} → Portal dependency). + RequestBodies map[string]*Schema +} + +// Parser parses OpenAPI specs +type Parser struct { + doc *openapi3.T + visited map[string]bool // Track visited schemas to prevent infinite recursion +} + +// NewParser creates a new parser +func NewParser(doc *openapi3.T) *Parser { + return &Parser{ + doc: doc, + visited: make(map[string]bool), + } +} + +// ParsePaths parses the OpenAPI spec for each of the given API paths (e.g. +// "/v3/portals/{portalId}/teams") and returns a ParsedSpec containing: +// - RequestBodies: the request body schema for each path's POST operation, +// with parent resource dependencies inferred from path parameters. +// - Schemas: any component schemas transitively referenced by the request bodies. +func (p *Parser) ParsePaths(targetPaths []string) (*ParsedSpec, error) { + result := &ParsedSpec{ + Schemas: make(map[string]*Schema), + RequestBodies: make(map[string]*Schema), + } + + // Collect referenced schema names + referencedSchemas := make(map[string]bool) + + for _, targetPath := range targetPaths { + name, schema, err := p.parsePath(targetPath) + if err != nil { + return nil, err + } + result.RequestBodies[name] = schema + p.collectReferencedSchemas(schema, referencedSchemas) + } + + // Parse all referenced component schemas + for name := range referencedSchemas { + if schemaRef, ok := p.doc.Components.Schemas[name]; ok && schemaRef.Value != nil { + schema := p.parseSchema(name, schemaRef.Value) + result.Schemas[name] = schema + } + } + + return result, nil +} + +// parsePath processes a single API path, returning the schema name and parsed schema +// for the POST operation's request body. +func (p *Parser) parsePath(targetPath string) (string, *Schema, error) { + // Find the path in the OpenAPI spec + pathItem := p.doc.Paths.Find(targetPath) + if pathItem == nil { + return "", nil, fmt.Errorf("path not found: %s", targetPath) + } + + // We're interested in POST operations (create operations) + if pathItem.Post == nil { + return "", nil, fmt.Errorf("path %s does not have a POST operation", targetPath) + } + + // Get request body + if pathItem.Post.RequestBody == nil || pathItem.Post.RequestBody.Value == nil { + return "", nil, fmt.Errorf("path %s POST operation has no request body", targetPath) + } + + reqBody := pathItem.Post.RequestBody.Value + if reqBody.Content == nil { + return "", nil, fmt.Errorf("path %s POST request body has no content", targetPath) + } + + // Find the schema name: prefer request body ref, then path derivation + var schemaName string + if pathItem.Post.RequestBody.Ref != "" { + schemaName = extractRefName(pathItem.Post.RequestBody.Ref) + } else { + schemaName = deriveSchemaNameFromPath(targetPath, pathItem.Post.OperationID) + } + + // Extract path parameters as dependencies + dependencies := p.extractPathDependencies(targetPath) + + // Parse the first media type that has a valid schema + for _, mediaTypeObj := range reqBody.Content { + if mediaTypeObj.Schema == nil || mediaTypeObj.Schema.Value == nil { + continue + } + + schema := p.parseSchema(schemaName, mediaTypeObj.Schema.Value) + schema.SourcePath = targetPath + schema.Dependencies = dependencies + return schemaName, schema, nil + } + + return "", nil, fmt.Errorf("path %s POST request body has no valid schema", targetPath) +} + +// extractPathDependencies extracts parent resource dependencies from path parameters +func (p *Parser) extractPathDependencies(path string) []*Dependency { + var deps []*Dependency + + // Extract path parameters using regex (e.g., {portalId}) + paramRegex := regexp.MustCompile(`\{([^}]+)\}`) + matches := paramRegex.FindAllStringSubmatch(path, -1) + + // All path parameters in a create path are dependencies + // e.g., POST /v3/portals/{portalId}/teams creates a team under a portal + // so portalId is a dependency + for _, match := range matches { + paramName := match[1] // e.g., "portalId" + dep := &Dependency{ + ParamName: paramName, + EntityName: getEntityNameFromParam(paramName), + FieldName: getEntityNameFromParam(paramName) + "Ref", + JSONName: toSnakeCase(getEntityNameFromParam(paramName)) + "_ref", + } + deps = append(deps, dep) + } + + return deps +} + +// getEntityNameFromParam converts a path parameter name to an entity name +// e.g., "portalId" -> "Portal", "teamId" -> "Team" +func getEntityNameFromParam(paramName string) string { + // Remove common suffixes + name := paramName + for _, suffix := range []string{"Id", "ID", "_id"} { + name = strings.TrimSuffix(name, suffix) + } + + // Convert camelCase to PascalCase + if len(name) > 0 { + name = strings.ToUpper(name[:1]) + name[1:] + } + + return name +} + +// toSnakeCase converts a string to snake_case +func toSnakeCase(s string) string { + var result strings.Builder + for i, r := range s { + if i > 0 && r >= 'A' && r <= 'Z' { + result.WriteRune('_') + } + result.WriteRune(r) + } + return strings.ToLower(result.String()) +} + +// toTitleCase converts the first character of a string to uppercase +func toTitleCase(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + +// deriveSchemaNameFromPath derives a schema name from the path and operation ID +func deriveSchemaNameFromPath(path string, operationID string) string { + // Try to use operation ID first (e.g., "create-portal-team" -> "CreatePortalTeam") + if operationID != "" { + parts := strings.Split(operationID, "-") + var result strings.Builder + for _, part := range parts { + if len(part) > 0 { + result.WriteString(strings.ToUpper(part[:1]) + part[1:]) + } + } + return result.String() + } + + // Fallback: derive from path segments + segments := strings.Split(strings.Trim(path, "/"), "/") + if len(segments) > 0 { + lastSegment := segments[len(segments)-1] + // Remove any path parameters + if !strings.HasPrefix(lastSegment, "{") { + return "Create" + toTitleCase(strings.TrimSuffix(lastSegment, "s")) + } + } + + return "Unknown" +} + +// collectReferencedSchemas collects all schema names referenced by the schema's properties +func (p *Parser) collectReferencedSchemas(schema *Schema, refs map[string]bool) { + for _, prop := range schema.Properties { + p.collectRefsFromProperty(prop, refs) + } + // Also collect refs from root-level oneOf variants + for _, variant := range schema.OneOf { + p.collectRefsFromProperty(variant, refs) + } +} + +func (p *Parser) collectRefsFromProperty(prop *Property, refs map[string]bool) { + // Don't collect refs for read-only properties (they won't be in the spec) + if prop.ReadOnly { + return + } + if prop.RefName != "" && !prop.IsReference { + refs[prop.RefName] = true + } + if prop.Items != nil { + p.collectRefsFromProperty(prop.Items, refs) + } + for _, nestedProp := range prop.Properties { + p.collectRefsFromProperty(nestedProp, refs) + } + if prop.AdditionalProperties != nil { + p.collectRefsFromProperty(prop.AdditionalProperties, refs) + } + // Collect refs from oneOf variants + for _, variant := range prop.OneOf { + if variant.RefName != "" { + refs[variant.RefName] = true + } + p.collectRefsFromProperty(variant, refs) + } +} + +func (p *Parser) parseSchema(name string, schemaValue *openapi3.Schema) *Schema { + schema := &Schema{ + Name: name, + Description: schemaValue.Description, + Type: getSchemaType(schemaValue), + Format: schemaValue.Format, + Required: schemaValue.Required, + Properties: make([]*Property, 0), + } + + // Handle array items + if schema.Type == "array" && schemaValue.Items != nil { + schema.Items = ParseProperty("items", schemaValue.Items, 0, p.visited) + } + + // Handle root-level oneOf (union type schemas) + if len(schemaValue.OneOf) > 0 { + for _, oneOfRef := range schemaValue.OneOf { + variantName := fmt.Sprintf("variant%d", len(schema.OneOf)) + if oneOfRef.Ref != "" { + variantName = extractRefName(oneOfRef.Ref) + } + variantProp := ParseProperty(variantName, oneOfRef, 0, p.visited) + schema.OneOf = append(schema.OneOf, variantProp) + } + } + + // Parse properties + for propName, propSchemaRef := range schemaValue.Properties { + if propSchemaRef.Value == nil { + continue + } + + prop := ParseProperty(propName, propSchemaRef, 0, p.visited) + prop.Required = slices.Contains(schemaValue.Required, propName) + schema.Properties = append(schema.Properties, prop) + } + + // Sort properties for consistent output + sort.Slice(schema.Properties, func(i, j int) bool { + return schema.Properties[i].Name < schema.Properties[j].Name + }) + + return schema +} + +// getSchemaType extracts the type from a schema, handling OpenAPI 3.1 type arrays +func getSchemaType(schema *openapi3.Schema) string { + if schema.Type == nil { + return "" + } + types := schema.Type.Slice() + if len(types) == 0 { + return "" + } + // Return the first non-null type + for _, t := range types { + if t != "null" { + return t + } + } + return types[0] +} + +// extractRefName extracts the schema name from a $ref string +func extractRefName(ref string) string { + // #/components/schemas/SomeSchema -> SomeSchema + parts := strings.Split(ref, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return ref +} + +// isReferenceProperty checks if a property is a reference to another entity +func isReferenceProperty(name string, schema *openapi3.Schema) bool { + // Check if the property name ends with _id and has uuid format + if strings.HasSuffix(name, "_id") && schema.Format == "uuid" { + return true + } + return false +} + +// GetEntityNameFromType extracts the entity name from a type name +// e.g., "CreatePortal" -> "Portal", "PortalCreateTeam" -> "PortalTeam" +func GetEntityNameFromType(name string) string { + // Remove common prefixes + result := name + for _, prefix := range []string{"Add", "Create", "Update", "Delete", "Get", "List"} { + result = strings.TrimPrefix(result, prefix) + } + + // Also handle infix patterns like "PortalCreateTeam" -> "PortalTeam" + for _, infix := range []string{"Create", "Update", "Delete"} { + result = strings.ReplaceAll(result, infix, "") + } + + return result +} + +// GetRefEntityName extracts the entity name from a reference property name +// e.g., "default_application_auth_strategy_id" -> "ApplicationAuthStrategy" +func GetRefEntityName(propName string) string { + // Remove _id suffix + name := strings.TrimSuffix(propName, "_id") + + // Convert snake_case to PascalCase + parts := strings.Split(name, "_") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(part[:1]) + part[1:] + } + } + + // Remove common prefixes like "default_" + result := strings.Join(parts, "") + result = strings.TrimPrefix(result, "Default") + + return result +} + +// ValidateRefName validates and normalizes a reference name +var refNameRegex = regexp.MustCompile(`^[A-Z][a-zA-Z0-9]*$`) + +func ValidateRefName(name string) error { + if !refNameRegex.MatchString(name) { + return fmt.Errorf("invalid reference name: %s", name) + } + return nil +} diff --git a/crd-from-oas/pkg/parser/parser_test.go b/crd-from-oas/pkg/parser/parser_test.go new file mode 100644 index 0000000000..e5adf777dd --- /dev/null +++ b/crd-from-oas/pkg/parser/parser_test.go @@ -0,0 +1,935 @@ +package parser + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParsePaths_BasicPath(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal", + RequestBody: &openapi3.RequestBodyRef{ + Ref: "#/components/requestBodies/CreatePortal", + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Description: "Create a portal", + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Portal name", + MinLength: 1, + MaxLength: new(uint64(100)), + }, + }, + "is_public": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"boolean"}, + Description: "Whether the portal is public", + }, + }, + }, + Required: []string{"name"}, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals"}) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.RequestBodies, 1) + + schema, ok := result.RequestBodies["CreatePortal"] + require.True(t, ok, "expected CreatePortal schema to exist") + assert.Equal(t, "CreatePortal", schema.Name) + assert.Equal(t, "Create a portal", schema.Description) + assert.Empty(t, schema.Dependencies) + + // Check properties + require.Len(t, schema.Properties, 2) + + // Properties are sorted alphabetically + assert.Equal(t, "is_public", schema.Properties[0].Name) + assert.Equal(t, "boolean", schema.Properties[0].Type) + assert.False(t, schema.Properties[0].Required) + + assert.Equal(t, "name", schema.Properties[1].Name) + assert.Equal(t, "string", schema.Properties[1].Type) + assert.True(t, schema.Properties[1].Required) + assert.Equal(t, int64(1), *schema.Properties[1].MinLength) + assert.Equal(t, int64(100), *schema.Properties[1].MaxLength) +} + +func TestParsePaths_WithPathDependencies(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals/{portalId}/teams", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal-team", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals/{portalId}/teams"}) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.RequestBodies, 1) + + schema, ok := result.RequestBodies["CreatePortalTeam"] + require.True(t, ok) + + // Check dependencies from path parameters + require.Len(t, schema.Dependencies, 1) + dep := schema.Dependencies[0] + assert.Equal(t, "portalId", dep.ParamName) + assert.Equal(t, "Portal", dep.EntityName) + assert.Equal(t, "PortalRef", dep.FieldName) + assert.Equal(t, "portal_ref", dep.JSONName) +} + +func TestParsePaths_MultiplePaths(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + openapi3.WithPath("/v3/portals/{portalId}/teams", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal-team", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "team_name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals", "/v3/portals/{portalId}/teams"}) + + require.NoError(t, err) + require.NotNil(t, result) + require.Len(t, result.RequestBodies, 2) + + _, hasPortal := result.RequestBodies["CreatePortal"] + assert.True(t, hasPortal) + + _, hasTeam := result.RequestBodies["CreatePortalTeam"] + assert.True(t, hasTeam) +} + +func TestParsePaths_PathNotFound(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths(), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/nonexistent/path"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "path not found: /nonexistent/path") + assert.Nil(t, result) +} + +func TestParsePaths_NoPostOperation(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "list-portals", + }, + // No Post operation + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "does not have a POST operation") + assert.Nil(t, result) +} + +func TestParsePaths_NoRequestBody(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal", + // No RequestBody + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals"}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "has no request body") + assert.Nil(t, result) +} + +func TestParsePaths_WithReferencedSchemas(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "labels": &openapi3.SchemaRef{ + Ref: "#/components/schemas/LabelsUpdate", + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + AdditionalProperties: openapi3.AdditionalProperties{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{ + "LabelsUpdate": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Description: "Labels for the entity", + AdditionalProperties: openapi3.AdditionalProperties{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + }, + }, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals"}) + + require.NoError(t, err) + require.NotNil(t, result) + + // Check that referenced schema is in Schemas + labelsSchema, ok := result.Schemas["LabelsUpdate"] + require.True(t, ok, "expected LabelsUpdate schema to be parsed") + assert.Equal(t, "Labels for the entity", labelsSchema.Description) +} + +func TestParsePaths_WithEnumValidation(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "visibility": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Portal visibility", + Enum: []any{"public", "private", "internal"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals"}) + + require.NoError(t, err) + require.NotNil(t, result) + + schema := result.RequestBodies["CreatePortal"] + require.Len(t, schema.Properties, 1) + + visibilityProp := schema.Properties[0] + assert.Equal(t, "visibility", visibilityProp.Name) + assert.Equal(t, []any{"public", "private", "internal"}, visibilityProp.Enum) +} + +func TestParsePaths_WithPatternValidation(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "slug": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Pattern: "^[a-z0-9-]+$", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals"}) + + require.NoError(t, err) + require.NotNil(t, result) + + schema := result.RequestBodies["CreatePortal"] + require.Len(t, schema.Properties, 1) + + slugProp := schema.Properties[0] + assert.Equal(t, "slug", slugProp.Name) + assert.Equal(t, "^[a-z0-9-]+$", slugProp.Pattern) +} + +func TestParsePaths_WithArrayType(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "tags": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Description: "List of tags", + Items: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals"}) + + require.NoError(t, err) + require.NotNil(t, result) + + schema := result.RequestBodies["CreatePortal"] + require.Len(t, schema.Properties, 1) + + tagsProp := schema.Properties[0] + assert.Equal(t, "tags", tagsProp.Name) + assert.Equal(t, "array", tagsProp.Type) + require.NotNil(t, tagsProp.Items) + assert.Equal(t, "string", tagsProp.Items.Type) +} + +func TestParsePaths_WithNestedDependencies(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals/{portalId}/teams/{teamId}/members", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal-team-member", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "user_id": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "uuid", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals/{portalId}/teams/{teamId}/members"}) + + require.NoError(t, err) + require.NotNil(t, result) + + schema := result.RequestBodies["CreatePortalTeamMember"] + require.Len(t, schema.Dependencies, 2) + + // Dependencies should be in order of appearance in path + assert.Equal(t, "portalId", schema.Dependencies[0].ParamName) + assert.Equal(t, "Portal", schema.Dependencies[0].EntityName) + + assert.Equal(t, "teamId", schema.Dependencies[1].ParamName) + assert.Equal(t, "Team", schema.Dependencies[1].EntityName) +} + +func TestParsePaths_WithReadOnlyProperty(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + "created_at": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + ReadOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals"}) + + require.NoError(t, err) + require.NotNil(t, result) + + schema := result.RequestBodies["CreatePortal"] + require.Len(t, schema.Properties, 2) + + // Find the created_at property + var createdAtProp *Property + for _, prop := range schema.Properties { + if prop.Name == "created_at" { + createdAtProp = prop + break + } + } + require.NotNil(t, createdAtProp) + assert.True(t, createdAtProp.ReadOnly) +} + +func TestParsePaths_WithReferenceProperty(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "default_application_auth_strategy_id": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "uuid", + Description: "The default auth strategy ID", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals"}) + + require.NoError(t, err) + require.NotNil(t, result) + + schema := result.RequestBodies["CreatePortal"] + require.Len(t, schema.Properties, 1) + + authStrategyProp := schema.Properties[0] + assert.Equal(t, "default_application_auth_strategy_id", authStrategyProp.Name) + assert.True(t, authStrategyProp.IsReference, "property ending with _id and uuid format should be a reference") +} + +func TestParsePaths_WithDefaultValue(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "visibility": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Default: "private", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals"}) + + require.NoError(t, err) + require.NotNil(t, result) + + schema := result.RequestBodies["CreatePortal"] + require.Len(t, schema.Properties, 1) + + visibilityProp := schema.Properties[0] + assert.Equal(t, "visibility", visibilityProp.Name) + assert.Equal(t, "private", visibilityProp.Default) +} + +func TestParsePaths_WithNullableProperty(t *testing.T) { + doc := &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath("/v3/portals", &openapi3.PathItem{ + Post: &openapi3.Operation{ + OperationID: "create-portal", + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "description": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Nullable: true, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }), + ), + Components: &openapi3.Components{ + Schemas: openapi3.Schemas{}, + }, + } + + parser := NewParser(doc) + result, err := parser.ParsePaths([]string{"/v3/portals"}) + + require.NoError(t, err) + require.NotNil(t, result) + + schema := result.RequestBodies["CreatePortal"] + require.Len(t, schema.Properties, 1) + + descProp := schema.Properties[0] + assert.Equal(t, "description", descProp.Name) + assert.True(t, descProp.Nullable) +} + +func TestExtractPathDependencies(t *testing.T) { + tests := []struct { + name string + path string + expected []*Dependency + }{ + { + name: "no dependencies", + path: "/v3/portals", + expected: nil, + }, + { + name: "single dependency", + path: "/v3/portals/{portalId}/teams", + expected: []*Dependency{ + { + ParamName: "portalId", + EntityName: "Portal", + FieldName: "PortalRef", + JSONName: "portal_ref", + }, + }, + }, + { + name: "multiple dependencies", + path: "/v3/portals/{portalId}/teams/{teamId}/members", + expected: []*Dependency{ + { + ParamName: "portalId", + EntityName: "Portal", + FieldName: "PortalRef", + JSONName: "portal_ref", + }, + { + ParamName: "teamId", + EntityName: "Team", + FieldName: "TeamRef", + JSONName: "team_ref", + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + parser := NewParser(&openapi3.T{}) + deps := parser.extractPathDependencies(tc.path) + + if tc.expected == nil { + assert.Nil(t, deps) + } else { + require.Len(t, deps, len(tc.expected)) + for i, expected := range tc.expected { + assert.Equal(t, expected.ParamName, deps[i].ParamName) + assert.Equal(t, expected.EntityName, deps[i].EntityName) + assert.Equal(t, expected.FieldName, deps[i].FieldName) + assert.Equal(t, expected.JSONName, deps[i].JSONName) + } + } + }) + } +} + +func TestGetEntityNameFromParam(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"portalId", "Portal"}, + {"teamId", "Team"}, + {"applicationID", "Application"}, + {"user_id", "User"}, + {"someEntity", "SomeEntity"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := getEntityNameFromParam(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestToSnakeCase(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"Portal", "portal"}, + {"PortalTeam", "portal_team"}, + {"ApplicationAuthStrategy", "application_auth_strategy"}, + {"already_snake", "already_snake"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := toSnakeCase(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestDeriveSchemaNameFromPath(t *testing.T) { + tests := []struct { + name string + path string + operationID string + expected string + }{ + { + name: "from operation ID", + path: "/v3/portals", + operationID: "create-portal", + expected: "CreatePortal", + }, + { + name: "from operation ID with multiple parts", + path: "/v3/portals/{portalId}/teams", + operationID: "create-portal-team", + expected: "CreatePortalTeam", + }, + { + name: "fallback to path", + path: "/v3/portals", + operationID: "", + expected: "CreatePortal", + }, + { + name: "path ending with parameter", + path: "/v3/portals/{portalId}", + operationID: "", + expected: "Unknown", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := deriveSchemaNameFromPath(tc.path, tc.operationID) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestGetEntityNameFromType(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"CreatePortal", "Portal"}, + {"UpdatePortal", "Portal"}, + {"PortalCreateTeam", "PortalTeam"}, + {"DeletePortal", "Portal"}, + {"Portal", "Portal"}, + {"AddDeveloperToTeam", "DeveloperToTeam"}, + {"AddSomething", "Something"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := GetEntityNameFromType(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestGetRefEntityName(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"default_application_auth_strategy_id", "ApplicationAuthStrategy"}, + {"portal_id", "Portal"}, + {"team_id", "Team"}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := GetRefEntityName(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} diff --git a/crd-from-oas/pkg/parser/property.go b/crd-from-oas/pkg/parser/property.go new file mode 100644 index 0000000000..6fefb42f25 --- /dev/null +++ b/crd-from-oas/pkg/parser/property.go @@ -0,0 +1,110 @@ +package parser + +import ( + "slices" + "sort" + + "github.com/getkin/kin-openapi/openapi3" +) + +// ParseProperty parses an OpenAPI schema reference into a Property struct. +// It handles nested objects, arrays, maps, and tracks visited schemas to prevent cycles. +// The depth parameter limits recursion to prevent infinite loops. +func ParseProperty(name string, schemaRef *openapi3.SchemaRef, depth int, visited map[string]bool) *Property { + prop := &Property{ + Name: name, + } + + // Prevent infinite recursion with a depth limit + if depth > 10 { + return prop + } + + // Handle $ref - check for cycles + if schemaRef.Ref != "" { + refName := extractRefName(schemaRef.Ref) + prop.RefName = refName + + // Don't recurse into already visited schemas + if visited[refName] { + return prop + } + } + + schemaValue := schemaRef.Value + if schemaValue == nil { + return prop + } + + // Basic type info + prop.Type = getSchemaType(schemaValue) + prop.Format = schemaValue.Format + prop.Description = schemaValue.Description + prop.Nullable = schemaValue.Nullable + prop.ReadOnly = schemaValue.ReadOnly + + // Check if this is a reference to another entity (ends with _id and has uuid format) + prop.IsReference = isReferenceProperty(name, schemaValue) + + // Validations + if schemaValue.MinLength > 0 { + minLen := int64(schemaValue.MinLength) + prop.MinLength = &minLen + } + if schemaValue.MaxLength != nil { + maxLen := int64(*schemaValue.MaxLength) + prop.MaxLength = &maxLen + } + if schemaValue.Min != nil { + prop.Minimum = schemaValue.Min + } + if schemaValue.Max != nil { + prop.Maximum = schemaValue.Max + } + if schemaValue.Pattern != "" { + prop.Pattern = schemaValue.Pattern + } + if len(schemaValue.Enum) > 0 { + prop.Enum = schemaValue.Enum + } + if schemaValue.Default != nil { + prop.Default = schemaValue.Default + } + + // Handle array types + if prop.Type == "array" && schemaValue.Items != nil { + prop.Items = ParseProperty("items", schemaValue.Items, depth+1, visited) + } + + // Handle nested object types + if prop.Type == "object" && len(schemaValue.Properties) > 0 { + for nestedName, nestedRef := range schemaValue.Properties { + nestedProp := ParseProperty(nestedName, nestedRef, depth+1, visited) + nestedProp.Required = slices.Contains(schemaValue.Required, nestedName) + prop.Properties = append(prop.Properties, nestedProp) + } + sort.Slice(prop.Properties, func(i, j int) bool { + return prop.Properties[i].Name < prop.Properties[j].Name + }) + } + + // Handle additionalProperties (map types) + if schemaValue.AdditionalProperties.Schema != nil { + prop.AdditionalProperties = ParseProperty("value", schemaValue.AdditionalProperties.Schema, depth+1, visited) + } + + // Handle oneOf (union types) + if len(schemaValue.OneOf) > 0 { + for _, oneOfRef := range schemaValue.OneOf { + // Extract the name from the $ref if available + variantName := "Variant" + if oneOfRef.Ref != "" { + variantName = extractRefName(oneOfRef.Ref) + } + variantProp := ParseProperty(variantName, oneOfRef, depth+1, visited) + prop.OneOf = append(prop.OneOf, variantProp) + } + } + + return prop +} diff --git a/crd-from-oas/pkg/parser/property_test.go b/crd-from-oas/pkg/parser/property_test.go new file mode 100644 index 0000000000..8fac50fb69 --- /dev/null +++ b/crd-from-oas/pkg/parser/property_test.go @@ -0,0 +1,479 @@ +package parser + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseProperty_BasicString(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "A string property", + Format: "email", + }, + } + + prop := ParseProperty("email", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "email", prop.Name) + assert.Equal(t, "string", prop.Type) + assert.Equal(t, "email", prop.Format) + assert.Equal(t, "A string property", prop.Description) +} + +func TestParseProperty_StringValidations(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + MinLength: 5, + MaxLength: new(uint64(100)), + Pattern: "^[a-z]+$", + }, + } + + prop := ParseProperty("name", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "string", prop.Type) + require.NotNil(t, prop.MinLength) + assert.Equal(t, int64(5), *prop.MinLength) + require.NotNil(t, prop.MaxLength) + assert.Equal(t, int64(100), *prop.MaxLength) + assert.Equal(t, "^[a-z]+$", prop.Pattern) +} + +func TestParseProperty_NumberValidations(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"number"}, + Min: new(float64(0.0)), + Max: new(float64(100.0)), + }, + } + + prop := ParseProperty("score", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "number", prop.Type) + require.NotNil(t, prop.Minimum) + assert.Equal(t, 0.0, *prop.Minimum) + require.NotNil(t, prop.Maximum) + assert.Equal(t, 100.0, *prop.Maximum) +} + +func TestParseProperty_IntegerType(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Format: "int32", + }, + } + + prop := ParseProperty("count", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "integer", prop.Type) + assert.Equal(t, "int32", prop.Format) +} + +func TestParseProperty_BooleanType(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"boolean"}, + Description: "Is active flag", + }, + } + + prop := ParseProperty("is_active", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "boolean", prop.Type) + assert.Equal(t, "Is active flag", prop.Description) +} + +func TestParseProperty_EnumValues(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []any{"active", "inactive", "pending"}, + }, + } + + prop := ParseProperty("status", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "string", prop.Type) + assert.Equal(t, []any{"active", "inactive", "pending"}, prop.Enum) +} + +func TestParseProperty_DefaultValue(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Default: "default_value", + }, + } + + prop := ParseProperty("field", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "default_value", prop.Default) +} + +func TestParseProperty_NullableProperty(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Nullable: true, + }, + } + + prop := ParseProperty("optional_field", schemaRef, 0, make(map[string]bool)) + + assert.True(t, prop.Nullable) +} + +func TestParseProperty_ReadOnlyProperty(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + ReadOnly: true, + }, + } + + prop := ParseProperty("created_at", schemaRef, 0, make(map[string]bool)) + + assert.True(t, prop.ReadOnly) + assert.Equal(t, "date-time", prop.Format) +} + +func TestParseProperty_ReferenceProperty(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "uuid", + }, + } + + prop := ParseProperty("portal_id", schemaRef, 0, make(map[string]bool)) + + assert.True(t, prop.IsReference, "property ending with _id and uuid format should be a reference") +} + +func TestParseProperty_NonReferenceUUID(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "uuid", + }, + } + + prop := ParseProperty("uuid_field", schemaRef, 0, make(map[string]bool)) + + assert.False(t, prop.IsReference, "uuid property not ending with _id should not be a reference") +} + +func TestParseProperty_ArrayType(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Description: "List of tags", + Items: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + } + + prop := ParseProperty("tags", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "array", prop.Type) + assert.Equal(t, "List of tags", prop.Description) + require.NotNil(t, prop.Items) + assert.Equal(t, "string", prop.Items.Type) +} + +func TestParseProperty_ArrayOfObjects(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "id": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + "name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + }, + }, + } + + prop := ParseProperty("items", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "array", prop.Type) + require.NotNil(t, prop.Items) + assert.Equal(t, "object", prop.Items.Type) + require.Len(t, prop.Items.Properties, 2) +} + +func TestParseProperty_NestedObject(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "street": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + "city": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + "zip": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + Required: []string{"street", "city"}, + }, + } + + prop := ParseProperty("address", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "object", prop.Type) + require.Len(t, prop.Properties, 3) + + // Properties should be sorted alphabetically + assert.Equal(t, "city", prop.Properties[0].Name) + assert.True(t, prop.Properties[0].Required) + + assert.Equal(t, "street", prop.Properties[1].Name) + assert.True(t, prop.Properties[1].Required) + + assert.Equal(t, "zip", prop.Properties[2].Name) + assert.False(t, prop.Properties[2].Required) +} + +func TestParseProperty_MapType(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + AdditionalProperties: openapi3.AdditionalProperties{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + } + + prop := ParseProperty("labels", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "object", prop.Type) + require.NotNil(t, prop.AdditionalProperties) + assert.Equal(t, "string", prop.AdditionalProperties.Type) +} + +func TestParseProperty_WithSchemaRef(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Ref: "#/components/schemas/LabelsUpdate", + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + AdditionalProperties: openapi3.AdditionalProperties{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + } + + prop := ParseProperty("labels", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "LabelsUpdate", prop.RefName) + assert.Equal(t, "object", prop.Type) +} + +func TestParseProperty_CycleDetection(t *testing.T) { + visited := map[string]bool{ + "RecursiveSchema": true, // Already visited + } + + schemaRef := &openapi3.SchemaRef{ + Ref: "#/components/schemas/RecursiveSchema", + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + } + + prop := ParseProperty("recursive", schemaRef, 0, visited) + + // Should return early with just the ref name set, no type/properties + assert.Equal(t, "RecursiveSchema", prop.RefName) + assert.Equal(t, "", prop.Type) + assert.Empty(t, prop.Properties) +} + +func TestParseProperty_DepthLimit(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "nested": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + } + + // Depth > 10 should return early + prop := ParseProperty("deep", schemaRef, 11, make(map[string]bool)) + + assert.Equal(t, "deep", prop.Name) + assert.Equal(t, "", prop.Type) + assert.Empty(t, prop.Properties) +} + +func TestParseProperty_NilSchemaValue(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Ref: "#/components/schemas/SomeSchema", + Value: nil, + } + + prop := ParseProperty("field", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "field", prop.Name) + assert.Equal(t, "SomeSchema", prop.RefName) + assert.Equal(t, "", prop.Type) +} + +func TestParseProperty_EmptySchema(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{}, + } + + prop := ParseProperty("empty", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "empty", prop.Name) + assert.Equal(t, "", prop.Type) +} + +func TestParseProperty_MultipleTypes(t *testing.T) { + // OpenAPI 3.1 allows multiple types like ["string", "null"] + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string", "null"}, + }, + } + + prop := ParseProperty("nullable_string", schemaRef, 0, make(map[string]bool)) + + // Should return the first non-null type + assert.Equal(t, "string", prop.Type) +} + +func TestParseProperty_DeeplyNestedObject(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "level1": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "level2": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "value": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + } + + prop := ParseProperty("nested", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "object", prop.Type) + require.Len(t, prop.Properties, 1) + + level1 := prop.Properties[0] + assert.Equal(t, "level1", level1.Name) + assert.Equal(t, "object", level1.Type) + require.Len(t, level1.Properties, 1) + + level2 := level1.Properties[0] + assert.Equal(t, "level2", level2.Name) + assert.Equal(t, "object", level2.Type) + require.Len(t, level2.Properties, 1) + + value := level2.Properties[0] + assert.Equal(t, "value", value.Name) + assert.Equal(t, "string", value.Type) +} + +func TestParseProperty_AllValidations(t *testing.T) { + schemaRef := &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Full validation test", + Format: "custom", + MinLength: 5, + MaxLength: new(uint64(50)), + Pattern: "^[a-z]+$", + Enum: []any{"a", "b", "c"}, + Default: "a", + Nullable: true, + ReadOnly: true, + Min: new(float64(1.0)), + Max: new(float64(100.0)), + }, + } + + prop := ParseProperty("full", schemaRef, 0, make(map[string]bool)) + + assert.Equal(t, "full", prop.Name) + assert.Equal(t, "string", prop.Type) + assert.Equal(t, "custom", prop.Format) + assert.Equal(t, "Full validation test", prop.Description) + assert.Equal(t, int64(5), *prop.MinLength) + assert.Equal(t, int64(50), *prop.MaxLength) + assert.Equal(t, "^[a-z]+$", prop.Pattern) + assert.Equal(t, []any{"a", "b", "c"}, prop.Enum) + assert.Equal(t, "a", prop.Default) + assert.True(t, prop.Nullable) + assert.True(t, prop.ReadOnly) + assert.Equal(t, 1.0, *prop.Minimum) + assert.Equal(t, 100.0, *prop.Maximum) +} diff --git a/crd-from-oas/pkg/run/run.go b/crd-from-oas/pkg/run/run.go new file mode 100644 index 0000000000..9796942fa2 --- /dev/null +++ b/crd-from-oas/pkg/run/run.go @@ -0,0 +1,95 @@ +package run + +import ( + "context" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/config" + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/generator" + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/parser" +) + +// Run is responsible for orchestrating the entire generation process: +// parsing the OpenAPI spec, applying configurations, generating code, and writing files. +func (r *Runner) Run( + ctx context.Context, + logger *slog.Logger, +) error { + for _, gvKey := range r.gvKeys { + agvConfig := r.projectCfg.APIGroupVersions[gvKey] + + apiGroup, apiVersion, err := config.ParseAPIGroupVersion(gvKey) + if err != nil { + return fmt.Errorf("invalid APIGroupVersion key %q: %w", gvKey, err) + } + + paths := agvConfig.GetPaths() + logger.Info("processing group-version", "apiGroupVersion", gvKey, "paths", paths) + + // Parse the spec for this group-version's paths + p := parser.NewParser(r.openAPI) + parsed, err := p.ParsePaths(paths) + if err != nil { + return fmt.Errorf("failed to parse OpenAPI paths for apiGroupVersion %q: %w", gvKey, err) + } + + logger.Info("found paths to process", "apiGroupVersion", gvKey, "count", len(parsed.RequestBodies)) + + // Build path → entity name mapping for field config resolution + pathToEntityName := make(map[string]string) + for name, schema := range parsed.RequestBodies { + entityName := parser.GetEntityNameFromType(name) + pathToEntityName[schema.SourcePath] = entityName + if len(schema.Dependencies) > 0 { + deps := make([]string, 0, len(schema.Dependencies)) + for _, dep := range schema.Dependencies { + deps = append(deps, dep.EntityName) + } + logger.Info("processing schema", "name", name, "depends_on", strings.Join(deps, ", ")) + } else { + logger.Info("processing schema", "name", name) + } + } + + if len(parsed.RequestBodies) == 0 { + logger.Warn("no matching request bodies found", "apiGroupVersion", gvKey) + continue + } + + // Generate CRD types + gen := generator.NewGenerator(generator.Config{ + APIGroup: apiGroup, + APIVersion: apiVersion, + GenerateStatus: true, + FieldConfig: agvConfig.FieldConfig(pathToEntityName), + OpsConfig: agvConfig.OpsConfig(pathToEntityName), + CommonTypes: agvConfig.CommonTypes, + }) + + files, err := gen.Generate(parsed) + if err != nil { + return fmt.Errorf("failed to generate types for apiGroupVersion %q: %w", gvKey, err) + } + + // Create output directory + dir := filepath.Join(r.outputDir, strings.Split(apiGroup, ".")[0], apiVersion) + if err := os.MkdirAll(dir, 0o755); err != nil { + return fmt.Errorf("failed to create output directory %q: %w", dir, err) + } + + // Write generated files + for _, file := range files { + filePath := filepath.Join(dir, file.Name) + if err := os.WriteFile(filePath, []byte(file.Content), 0o600); err != nil { + return fmt.Errorf("failed to write file %q: %w", filePath, err) + } + logger.Info("generated file", "path", filePath) + } + } + + return nil +} diff --git a/crd-from-oas/pkg/run/runner.go b/crd-from-oas/pkg/run/runner.go new file mode 100644 index 0000000000..0e73f2d745 --- /dev/null +++ b/crd-from-oas/pkg/run/runner.go @@ -0,0 +1,48 @@ +package run + +import ( + "fmt" + "maps" + "slices" + "sort" + + "github.com/getkin/kin-openapi/openapi3" + + "github.com/kong/kong-operator/v2/crd-from-oas/pkg/config" +) + +// Runner is responsible for orchestrating the entire generation process. +// It processes each API group-version defined in the project config, parses the OpenAPI spec, +// and generates the corresponding Go types based on the parsed schemas and configurations. +type Runner struct { + projectCfg *config.ProjectConfig + gvKeys []string + openAPI *openapi3.T + outputDir string +} + +// New creates new runner with the given project config, OpenAPI spec file path, and output directory. +// It loads the OpenAPI spec and prepares the runner for execution. +// The actual generation is performed in the Run method. +func New( + projectCfg *config.ProjectConfig, + openAPIFile string, + outputDir string, +) (*Runner, error) { + gvKeys := slices.Collect(maps.Keys(projectCfg.APIGroupVersions)) + sort.Strings(gvKeys) + + // Load OpenAPI spec (shared across all group-versions) + loader := openapi3.NewLoader() + doc, err := loader.LoadFromFile(openAPIFile) + if err != nil { + return nil, fmt.Errorf("failed to load OpenAPI spec: %w", err) + } + + return &Runner{ + projectCfg: projectCfg, + gvKeys: gvKeys, + openAPI: doc, + outputDir: outputDir, + }, nil +} diff --git a/crd-from-oas/test/crdsvalidation/common/common.go b/crd-from-oas/test/crdsvalidation/common/common.go new file mode 100644 index 0000000000..57caacafdd --- /dev/null +++ b/crd-from-oas/test/crdsvalidation/common/common.go @@ -0,0 +1,11 @@ +package common + +import metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + +// CommonObjectMeta returns a common ObjectMeta for test objects in the given namespace. +func CommonObjectMeta(ns string) metav1.ObjectMeta { + return metav1.ObjectMeta{ + GenerateName: "test-", + Namespace: ns, + } +} diff --git a/crd-from-oas/test/crdsvalidation/common/constraints.go b/crd-from-oas/test/crdsvalidation/common/constraints.go new file mode 100644 index 0000000000..87e6177287 --- /dev/null +++ b/crd-from-oas/test/crdsvalidation/common/constraints.go @@ -0,0 +1,14 @@ +package common + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// ObjectWithControlPlaneRef is an interface for objects that have a ControlPlaneRef +// and support deepcopy and condition setting. +type ObjectWithControlPlaneRef[T any] interface { + client.Object + DeepCopy() T + SetConditions([]metav1.Condition) +} diff --git a/crd-from-oas/test/crdsvalidation/common/setup.go b/crd-from-oas/test/crdsvalidation/common/setup.go new file mode 100644 index 0000000000..4378e0d52c --- /dev/null +++ b/crd-from-oas/test/crdsvalidation/common/setup.go @@ -0,0 +1,23 @@ +package common + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + + "github.com/kong/kong-operator/v2/test/envtest" +) + +func Setup( + t *testing.T, + scheme *runtime.Scheme, +) (*rest.Config, *corev1.Namespace) { + return envtest.Setup(t, t.Context(), scheme, + envtest.WithInstallGatewayCRDs(false), + envtest.WithInstallKongCRDs(false), + // TODO: make this not relative + envtest.WithAdditionalCRDPaths([]string{"../../../config/crd/"}), + ) +} diff --git a/crd-from-oas/test/crdsvalidation/common/suite_crd_ref_change.go b/crd-from-oas/test/crdsvalidation/common/suite_crd_ref_change.go new file mode 100644 index 0000000000..5b0d028d16 --- /dev/null +++ b/crd-from-oas/test/crdsvalidation/common/suite_crd_ref_change.go @@ -0,0 +1,53 @@ +package common + +import ( + "testing" + + "k8s.io/client-go/rest" +) + +// Scope represents the scope of the object +type Scope byte + +const ( + // ScopeCluster represents the cluster scope + ScopeCluster Scope = iota + // ScopeNamespace represents the namespace scope + ScopeNamespace +) + +// ControlPlaneRefRequiredT is a type to specify whether control plane ref is required or not +type ControlPlaneRefRequiredT bool + +const ( + // ControlPlaneRefRequired represents that control plane ref is required + ControlPlaneRefRequired ControlPlaneRefRequiredT = true + // ControlPlaneRefNotRequired represents that control plane ref is not required + ControlPlaneRefNotRequired ControlPlaneRefRequiredT = false +) + +// NewCRDValidationTestCasesGroupCPRefChange creates a test cases group for control plane ref change +func NewCRDValidationTestCasesGroupCPRefChange[ + T ObjectWithControlPlaneRef[T], +]( + t *testing.T, + cfg *rest.Config, + obj T, + controlPlaneRefRequired ControlPlaneRefRequiredT, +) TestCasesGroup[T] { + var ( + ret = TestCasesGroup[T]{} + ) + + { + // Since objects managed by KIC do not require spec.controlPlane, + // object without spec.controlPlaneRef should be allowed. + obj := obj.DeepCopy() + ret = append(ret, TestCase[T]{ + Name: "base", + TestObject: obj, + }) + } + + return ret +} diff --git a/crd-from-oas/test/crdsvalidation/common/testcase.go b/crd-from-oas/test/crdsvalidation/common/testcase.go new file mode 100644 index 0000000000..48823c1a84 --- /dev/null +++ b/crd-from-oas/test/crdsvalidation/common/testcase.go @@ -0,0 +1,215 @@ +package common + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/rest" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/config" + + "github.com/kong/kong-operator/v2/crd-from-oas/test/scheme" +) + +// TestCasesGroup is a group of test cases related to CRD validation. +type TestCasesGroup[T client.Object] []TestCase[T] + +// RunWithConfig runs all test cases in the group against the provided rest.Config's cluster. +func (g TestCasesGroup[T]) RunWithConfig(t *testing.T, cfg *rest.Config, scheme *runtime.Scheme) { + for _, tc := range g { + tc. + RunWithConfig(t, cfg, scheme) + } +} + +// Run runs all test cases in the group. +func (g TestCasesGroup[T]) Run(t *testing.T) { + cfg, err := config.GetConfig() + require.NoError(t, err) + + // TODO + scheme := scheme.Get() + + g.RunWithConfig(t, cfg, scheme) +} + +const ( + // DefaultEventuallyTimeout is the default timeout for EventuallyConfig. + DefaultEventuallyTimeout = 5 * time.Second + // DefaultEventuallyPeriod is the default period for EventuallyConfig. + DefaultEventuallyPeriod = 10 * time.Millisecond +) + +// EventuallyConfig is the configuration for assert.Eventually() which is used to assert errors. +type EventuallyConfig struct { + // Timeout is the maximum time to wait for the condition to be true. + Timeout time.Duration + // Period is the time to wait between retries. + Period time.Duration +} + +// TestCase represents a test case for CRD validation. +type TestCase[T client.Object] struct { + // Name is the name of the test case. + Name string + + // SkipReason is the reason to skip the test case. + SkipReason string + + // TestObject is the object to be tested. + TestObject T + + // ExpectedErrorMessage is the expected error message when creating the object. + ExpectedErrorMessage *string + + // ExpectedErrorEventuallyConfig is the configuration for assert.Eventually() which is used to assert the create error. + // If not provided the error is checked immediately, just once. + ExpectedErrorEventuallyConfig EventuallyConfig + + // ExpectedUpdateErrorMessage is the expected error message when updating the object. + ExpectedUpdateErrorMessage *string + + // Update is a function that updates the object in the test case after it's created. + // It can be used to verify CEL rules that verify the previous object's version against the new one. + Update func(T) + + // ExpectedWarningMessage is the substring expected to be found in at least one collected warning. + ExpectedWarningMessage *string + + // Assert is an optional function to perform additional assertions on the created object. + // It is called after the object is created and before an update (if specified). + Assert func(*testing.T, T) +} + +// RunWithConfig runs the test case against the provided rest.Config's cluster. +func (tc *TestCase[T]) RunWithConfig(t *testing.T, cfg *rest.Config, scheme *runtime.Scheme) { + if tc.SkipReason != "" { + t.Skip(tc.SkipReason) + } + + timeout := DefaultEventuallyTimeout + if tc.ExpectedErrorEventuallyConfig.Timeout != 0 { + timeout = tc.ExpectedErrorEventuallyConfig.Timeout + } + period := DefaultEventuallyPeriod + if tc.ExpectedErrorEventuallyConfig.Period != 0 { + period = tc.ExpectedErrorEventuallyConfig.Period + } + + require.NotNil(t, tc.TestObject, "TestObject is nil in test %s", tc.Name) + + // Run the test case. + t.Run(tc.Name, func(t *testing.T) { + require.NotNil(t, tc.TestObject, "TestObject is nil in test %s", tc.Name) + + t.Parallel() + ctx := context.Background() + + // Create a new controller-runtime client.Client. + cl, err := client.New(cfg, client.Options{ + Scheme: scheme, + }) + require.NoError(t, err) + + // Take a copy so that we can update the status field if needed. Without copying, the Create call + // overwrites the status field in tc.TestObject with the default server returns, and we lose the status + // set in the test case. + desiredObj := tc.TestObject.DeepCopyObject().(T) + + tCleanupObject := func(ctx context.Context, t *testing.T, obj client.Object) { + // NOTE: Deep copy the object as without this we end up causing a data race: + objToDelete := obj.DeepCopyObject().(T) + t.Cleanup(func() { + assert.NoError(t, client.IgnoreNotFound(cl.Delete(ctx, objToDelete))) + }) + } + + if !assert.EventuallyWithT( + t, + func(c *assert.CollectT) { + toCreate := tc.TestObject.DeepCopyObject().(T) + + // Create the object and set a cleanup function to delete it after the test if created successfully. + err = cl.Create(ctx, toCreate) + if err == nil { + tCleanupObject(ctx, t, toCreate) + } + + // If the error message is expected, check if the error message contains the expected message and return. + if tc.ExpectedErrorMessage != nil { + if !assert.ErrorContains(c, err, *tc.ExpectedErrorMessage) { + t.Logf("Create error: %v; expected: %q\n", err, *tc.ExpectedErrorMessage) + return + } + } else { + if !assert.NoError(c, err) { + t.Logf("Create error: %v\n", err) + return + } + } + + tc.TestObject = toCreate + }, + timeout, period, + ) { + return + } + + if tc.Assert != nil { + require.NoError(t, cl.Get(ctx, client.ObjectKeyFromObject(tc.TestObject), tc.TestObject)) + tc.Assert(t, tc.TestObject) + } + + // Check with reflect if the status field is set and Update the status if so before updating the object. + // That's required to populate Status that is not set on Create. + if status := reflect.ValueOf(desiredObj).Elem().FieldByName("Status"); status.IsValid() && !status.IsZero() { + // Populate name and resource version obtained from the server on Create. + desiredObj.SetName(tc.TestObject.GetName()) + desiredObj.SetResourceVersion(tc.TestObject.GetResourceVersion()) + + err = cl.Status().Update(ctx, desiredObj) + require.NoError(t, err) + + err = cl.Get(ctx, client.ObjectKeyFromObject(tc.TestObject), tc.TestObject) + require.NoError(t, err) + } + + // If the Update function was defined, update the object and check if the update is allowed. + if tc.Update != nil { + require.EventuallyWithT(t, func(c *assert.CollectT) { + err := cl.Get(ctx, client.ObjectKeyFromObject(tc.TestObject), tc.TestObject) + require.NoError(c, err) + // Update the object state and push the update to the server. + tc.Update(tc.TestObject) + err = cl.Update(ctx, tc.TestObject) + if tc.ExpectedWarningMessage != nil { + found := false + if !assert.True(c, found, "Warning message not found: %s", *tc.ExpectedWarningMessage) { + return + } + } + // If the expected update error message is defined, check if the error message contains the expected message + // and return. Otherwise, expect no error. + if tc.ExpectedUpdateErrorMessage != nil { + require.NotNil(c, err) + assert.Contains(c, err.Error(), *tc.ExpectedUpdateErrorMessage) + return + } + require.NoError(c, err) + }, timeout, period) + } + }) +} + +// Run runs the test case. +func (tc *TestCase[T]) Run(t *testing.T) { + cfg, err := config.GetConfig() + require.NoError(t, err) + + tc.RunWithConfig(t, cfg, scheme.Get()) +} diff --git a/crd-from-oas/test/scheme/scheme.go b/crd-from-oas/test/scheme/scheme.go new file mode 100644 index 0000000000..d18d4eb5c9 --- /dev/null +++ b/crd-from-oas/test/scheme/scheme.go @@ -0,0 +1,22 @@ +package scheme + +import ( + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + + // konnectv1alpha1 "github.com/kong/kong-operator/v2/api/konnect/v1alpha1" + xkonnectv1alpha1 "github.com/kong/kong-operator/v2/api/x-konnect/v1alpha1" +) + +// Get returns a scheme aware of all types the manager can interact with. +func Get() *runtime.Scheme { + scheme := runtime.NewScheme() + + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + + // utilruntime.Must(konnectv1alpha1.AddToScheme(scheme)) + utilruntime.Must(xkonnectv1alpha1.AddToScheme(scheme)) + + return scheme +} diff --git a/hack/generators/conversion-webhook/main.go b/hack/generators/conversion-webhook/main.go index 57900f7c8d..21d930ca47 100644 --- a/hack/generators/conversion-webhook/main.go +++ b/hack/generators/conversion-webhook/main.go @@ -61,6 +61,8 @@ func main() { } crdContent := out.String() + // TODO: This makes sure that temporary CRDs are not included in the chart. + crdContent = filterOutAPIGroup(crdContent, "x-konnect.konghq.com") crdContent = wrapInIfEnabled(crdContent) crdContent = wrapCertAnnotations(crdContent) crdContent = wrapWebhookConfig(crdContent) @@ -296,6 +298,24 @@ func wrapDeprecatedVersions(content string) string { return strings.Join(result, "\n") } +// filterOutAPIGroup removes all CRD documents belonging to the specified API group +// from the multi-document YAML content. +func filterOutAPIGroup(content, apiGroup string) string { + docs := strings.Split(content, "---") + groupPattern := "group: " + apiGroup + var filtered []string + for _, doc := range docs { + if strings.TrimSpace(doc) == "" { + continue + } + if strings.Contains(doc, groupPattern) { + continue + } + filtered = append(filtered, doc) + } + return strings.Join(filtered, "---") +} + // findVersionEnd finds the end of a version entry starting at startIndex. // It returns the index of the first line that is not part of this version entry. func findVersionEnd(lines []string, startIndex int) int { diff --git a/scripts/crds-generator/main.go b/scripts/crds-generator/main.go index f6e1b7778b..892ac97681 100644 --- a/scripts/crds-generator/main.go +++ b/scripts/crds-generator/main.go @@ -65,6 +65,9 @@ func main() { // incubator.ingress-controller.konghq.com "github.com/kong/kong-operator/v2/api/incubator/v1alpha1", + // x-konnect.konghq.com + "github.com/kong/kong-operator/v2/api/x-konnect/v1alpha1", + // konnect.konghq.com "github.com/kong/kong-operator/v2/api/konnect/v1alpha1", "github.com/kong/kong-operator/v2/api/konnect/v1alpha2", @@ -76,6 +79,7 @@ func main() { // common types "github.com/kong/kong-operator/v2/api/common/v1alpha1", + "github.com/kong/kong-operator/v2/crd-from-oas/api/konnect/v1alpha1", ) if err != nil { log.Fatalf("failed to load package roots: %s", err) diff --git a/test/crdsvalidation/x-konnect.konghq.com/portal_test.go b/test/crdsvalidation/x-konnect.konghq.com/portal_test.go new file mode 100644 index 0000000000..d2fc97085e --- /dev/null +++ b/test/crdsvalidation/x-konnect.konghq.com/portal_test.go @@ -0,0 +1,272 @@ +package configuration_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + xkonnectv1alpha1 "github.com/kong/kong-operator/v2/api/x-konnect/v1alpha1" + "github.com/kong/kong-operator/v2/ingress-controller/pkg/manager/scheme" + common "github.com/kong/kong-operator/v2/test/crdsvalidation/common" + "github.com/kong/kong-operator/v2/test/envtest" +) + +func TestPortal(t *testing.T) { + t.Parallel() + + scheme := scheme.Get() + require.NoError(t, xkonnectv1alpha1.AddToScheme(scheme)) + + ctx := t.Context() + cfg, ns := envtest.Setup(t, ctx, scheme) + + t.Run("name field validation", func(t *testing.T) { + common.TestCasesGroup[*xkonnectv1alpha1.Portal]{ + { + Name: "name with valid value passes validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "test-portal", + }, + }, + }, + }, + { + Name: "name at max length (255) passes validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: strings.Repeat("a", 255), + }, + }, + }, + }, + { + Name: "name exceeding max length (256) fails validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: strings.Repeat("a", 256), + }, + }, + }, + // NOTE: Different versions of k8s return a different error + // message hence this trying to match on the common part of the message. + ExpectedErrorMessage: new("spec.apiSpec.name: Too long: may not be"), + }, + { + Name: "name is immutable", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "immutable-portal-name", + }, + }, + }, + Update: func(p *xkonnectv1alpha1.Portal) { + p.Spec.APISpec.Name = "changed-portal-name" + }, + ExpectedUpdateErrorMessage: new("name is immutable"), + }, + }. + RunWithConfig(t, cfg, scheme) + }) + + t.Run("display_name field validation", func(t *testing.T) { + common.TestCasesGroup[*xkonnectv1alpha1.Portal]{ + { + Name: "display_name with valid value passes validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-display-name-valid", + DisplayName: "My Portal", + }, + }, + }, + }, + { + Name: "display_name at max length (255) passes validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-display-name-max", + DisplayName: strings.Repeat("d", 255), + }, + }, + }, + }, + { + Name: "display_name exceeding max length (256) fails validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-display-name-over", + DisplayName: strings.Repeat("d", 256), + }, + }, + }, + // NOTE: Different versions of k8s return a different error + // message hence this trying to match on the common part of the message. + ExpectedErrorMessage: new("spec.apiSpec.display_name: Too long: may not be"), + }, + }. + RunWithConfig(t, cfg, scheme) + }) + + t.Run("description field validation", func(t *testing.T) { + common.TestCasesGroup[*xkonnectv1alpha1.Portal]{ + { + Name: "description at max length (512) passes validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-desc-max", + Description: new(strings.Repeat("x", 512)), + }, + }, + }, + }, + { + Name: "description exceeding max length (513) fails validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-desc-over", + Description: new(strings.Repeat("x", 513)), + }, + }, + }, + // NOTE: Different versions of k8s return a different error + // message hence this trying to match on the common part of the message. + ExpectedErrorMessage: new("spec.apiSpec.description: Too long: may not be"), + }, + }. + RunWithConfig(t, cfg, scheme) + }) + + t.Run("default_api_visibility field validation", func(t *testing.T) { + common.TestCasesGroup[*xkonnectv1alpha1.Portal]{ + { + Name: "default_api_visibility set to public passes validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-vis-public", + DefaultAPIVisibility: "public", + }, + }, + }, + }, + { + Name: "default_api_visibility set to private passes validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-vis-private", + DefaultAPIVisibility: "private", + }, + }, + }, + }, + { + Name: "default_api_visibility with invalid value fails validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-vis-invalid", + DefaultAPIVisibility: "invalid", + }, + }, + }, + ExpectedErrorMessage: new(`spec.apiSpec.default_api_visibility: Unsupported value: "invalid"`), + }, + }. + RunWithConfig(t, cfg, scheme) + }) + + t.Run("default_page_visibility field validation", func(t *testing.T) { + common.TestCasesGroup[*xkonnectv1alpha1.Portal]{ + { + Name: "default_page_visibility set to public passes validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-page-vis-public", + DefaultPageVisibility: "public", + }, + }, + }, + }, + { + Name: "default_page_visibility set to private passes validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-page-vis-private", + DefaultPageVisibility: "private", + }, + }, + }, + }, + { + Name: "default_page_visibility with invalid value fails validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-page-vis-invalid", + DefaultPageVisibility: "invalid", + }, + }, + }, + ExpectedErrorMessage: new(`spec.apiSpec.default_page_visibility: Unsupported value: "invalid"`), + }, + }. + RunWithConfig(t, cfg, scheme) + }) + + t.Run("full spec with all fields passes validation", func(t *testing.T) { + common.TestCasesGroup[*xkonnectv1alpha1.Portal]{ + { + Name: "all fields populated passes validation", + TestObject: &xkonnectv1alpha1.Portal{ + ObjectMeta: common.CommonObjectMeta(ns.Name), + Spec: xkonnectv1alpha1.PortalSpec{ + APISpec: xkonnectv1alpha1.PortalAPISpec{ + Name: "portal-full-spec", + DisplayName: "Full Spec Portal", + Description: new("A full spec portal"), + AuthenticationEnabled: true, + AutoApproveApplications: true, + AutoApproveDevelopers: true, + DefaultAPIVisibility: "public", + DefaultPageVisibility: "private", + RBACEnabled: true, + Labels: xkonnectv1alpha1.LabelsUpdate{ + "env": "test", + }, + }, + }, + }, + }, + }. + RunWithConfig(t, cfg, scheme) + }) +} diff --git a/test/envtest/setup.go b/test/envtest/setup.go index 31298a8b18..6fbb6da08a 100644 --- a/test/envtest/setup.go +++ b/test/envtest/setup.go @@ -37,6 +37,7 @@ import ( type Options struct { InstallGatewayCRDs bool InstallKongCRDs bool + AdditionalCRDPaths []string } var DefaultEnvTestOpts = Options{ @@ -60,6 +61,13 @@ func WithInstallGatewayCRDs(install bool) OptionModifier { } } +func WithAdditionalCRDPaths(paths []string) OptionModifier { + return func(opts Options) Options { + opts.AdditionalCRDPaths = paths + return opts + } +} + var once sync.Once = sync.Once{} // Setup sets up a test k8s API server environment and returned the configuration. @@ -89,6 +97,7 @@ func Setup(t *testing.T, ctx context.Context, scheme *k8sruntime.Scheme, optModi kcfg.IngressControllerIncubatorCRDsPath(), ) } + crdPaths = append(crdPaths, opts.AdditionalCRDPaths...) testEnv := &envtest.Environment{ ControlPlaneStopTimeout: time.Second * 60,