From 936e4571efe950082eb600c05a6888fc43010be0 Mon Sep 17 00:00:00 2001 From: Ciaran Roche Date: Fri, 6 Feb 2026 15:37:18 +0000 Subject: [PATCH 1/6] Add generic resource type POC with Kubernetes CRDs Implements a generic resource API that allows defining new resource types via YAML configuration files, without requiring code changes. Features: - Generic resource handler with CRUD operations for any resource type - CRD-style resource definitions in config/crds/ - Kubernetes CRDs in Helm chart for API discoverability - RBAC ClusterRoles for viewer and admin access - Database migration for generic resources table - Plugin architecture for resource type registration - ARM64 cross-compilation support in Dockerfile Resource types defined: - Cluster (root-level, cluster-scoped in K8s) - NodePool (owned by Cluster, namespaced in K8s) - IDP (owned by Cluster, namespaced in K8s) The CRDs are for schema discoverability only - PostgreSQL remains the source of truth (not a K8s operator pattern). Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 19 +- Makefile | 7 +- charts/Chart.yaml | 4 +- charts/crds/cluster-crd.yaml | 98 +++++ charts/crds/idp-crd.yaml | 112 ++++++ charts/crds/nodepool-crd.yaml | 106 +++++ charts/templates/rbac/clusterrole-admin.yaml | 35 ++ charts/templates/rbac/clusterrole.yaml | 33 ++ charts/templates/rbac/clusterrolebinding.yaml | 16 + charts/values.yaml | 5 + cmd/hyperfleet-api/main.go | 1 + config/crds/cluster.yaml | 21 + config/crds/idp.yaml | 24 ++ config/crds/nodepool.yaml | 22 ++ pkg/api/resource_definition.go | 90 +++++ pkg/api/resource_types.go | 116 ++++++ pkg/crd/registry.go | 231 +++++++++++ pkg/dao/resource.go | 215 +++++++++++ .../migrations/202602060001_add_resources.go | 134 +++++++ pkg/db/migrations/migration_structs.go | 1 + pkg/handlers/resource.go | 362 ++++++++++++++++++ pkg/services/generic.go | 3 + pkg/services/resource.go | 318 +++++++++++++++ plugins/resources/plugin.go | 163 ++++++++ scripts/test-api.sh | 140 +++++++ 25 files changed, 2265 insertions(+), 11 deletions(-) create mode 100644 charts/crds/cluster-crd.yaml create mode 100644 charts/crds/idp-crd.yaml create mode 100644 charts/crds/nodepool-crd.yaml create mode 100644 charts/templates/rbac/clusterrole-admin.yaml create mode 100644 charts/templates/rbac/clusterrole.yaml create mode 100644 charts/templates/rbac/clusterrolebinding.yaml create mode 100644 config/crds/cluster.yaml create mode 100644 config/crds/idp.yaml create mode 100644 config/crds/nodepool.yaml create mode 100644 pkg/api/resource_definition.go create mode 100644 pkg/api/resource_types.go create mode 100644 pkg/crd/registry.go create mode 100644 pkg/dao/resource.go create mode 100644 pkg/db/migrations/202602060001_add_resources.go create mode 100644 pkg/handlers/resource.go create mode 100644 pkg/services/resource.go create mode 100644 plugins/resources/plugin.go create mode 100755 scripts/test-api.sh diff --git a/Dockerfile b/Dockerfile index fdabcaf..3e57291 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,12 @@ ARG BASE_IMAGE=gcr.io/distroless/static-debian12:nonroot +ARG TARGETARCH=amd64 -# OpenAPI generation stage -FROM golang:1.25 AS builder +# Build stage - explicitly use amd64 for cross-compilation from x86 hosts +FROM --platform=linux/amd64 golang:1.25 AS builder ARG GIT_SHA=unknown ARG GIT_DIRTY="" +ARG TARGETARCH WORKDIR /build @@ -15,11 +17,13 @@ RUN go mod download # Copy source code COPY . . -# Build binary -RUN CGO_ENABLED=0 GOOS=linux make build +# Build binary for target architecture +RUN CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} make build -# Runtime stage -FROM ${BASE_IMAGE} +# Runtime stage - use target architecture for the base image +ARG BASE_IMAGE +ARG TARGETARCH +FROM --platform=linux/${TARGETARCH} ${BASE_IMAGE} WORKDIR /app @@ -29,6 +33,9 @@ COPY --from=builder /build/bin/hyperfleet-api /app/hyperfleet-api # Copy OpenAPI schema for validation (uses the source spec, not the generated one) COPY --from=builder /build/openapi/openapi.yaml /app/openapi/openapi.yaml +# Copy CRD definitions for generic resource API +COPY --from=builder /build/config/crds /app/config/crds + # Set default schema path (can be overridden by Helm for provider-specific schemas) ENV OPENAPI_SCHEMA_PATH=/app/openapi/openapi.yaml diff --git a/Makefile b/Makefile index 6c8304b..f2ffe02 100755 --- a/Makefile +++ b/Makefile @@ -325,11 +325,10 @@ ifndef QUAY_USER @echo "This will build and push to: quay.io/$$QUAY_USER/$(IMAGE_NAME):$(DEV_TAG)" @exit 1 endif - @echo "Building dev image quay.io/$(QUAY_USER)/$(IMAGE_NAME):$(DEV_TAG)..." - # --platform flag requires Docker >= 20.10 or Podman >= 3.4 - # For older engines: use 'docker buildx build' or omit --platform + @echo "Building dev image quay.io/$(QUAY_USER)/$(IMAGE_NAME):$(DEV_TAG) for ARM64..." + # Cross-compile for ARM64: builder uses amd64 golang, Go cross-compiles, final stage uses arm64 base $(container_tool) build \ - --platform linux/amd64 \ + --build-arg TARGETARCH=arm64 \ --build-arg BASE_IMAGE=alpine:3.21 \ --build-arg GIT_SHA=$(GIT_SHA) \ --build-arg GIT_DIRTY=$(GIT_DIRTY) \ diff --git a/charts/Chart.yaml b/charts/Chart.yaml index fd9cfe3..372f6c8 100644 --- a/charts/Chart.yaml +++ b/charts/Chart.yaml @@ -1,6 +1,6 @@ apiVersion: v2 name: hyperfleet-api -description: HyperFleet API - Cluster Lifecycle Management Service +description: HyperFleet API - Cluster Lifecycle Management Service with Custom Resource Definitions for Kubernetes-native discoverability type: application version: 1.0.0 appVersion: "1.0.0" @@ -12,4 +12,6 @@ keywords: - api - kubernetes - cluster-management + - crd + - custom-resource-definition home: https://github.com/openshift-hyperfleet/hyperfleet-api diff --git a/charts/crds/cluster-crd.yaml b/charts/crds/cluster-crd.yaml new file mode 100644 index 0000000..7ddd26f --- /dev/null +++ b/charts/crds/cluster-crd.yaml @@ -0,0 +1,98 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: clusters.hyperfleet.io + labels: + app.kubernetes.io/name: hyperfleet-api + app.kubernetes.io/part-of: hyperfleet +spec: + group: hyperfleet.io + names: + kind: Cluster + listKind: ClusterList + plural: clusters + singular: cluster + shortNames: + - hfc + - hfcluster + scope: Cluster + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + description: Cluster is the Schema for HyperFleet managed clusters + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + description: ClusterSpec defines the desired state of a HyperFleet cluster + x-kubernetes-preserve-unknown-fields: true + status: + type: object + description: ClusterStatus defines the observed state of a Cluster + properties: + conditions: + type: array + description: Conditions represent the latest available observations of the cluster's state + items: + type: object + required: + - type + - status + - lastTransitionTime + properties: + type: + type: string + description: Type of condition (e.g., Ready, Available) + status: + type: string + description: Status of the condition + enum: + - "True" + - "False" + reason: + type: string + description: Machine-readable reason for the condition's last transition + message: + type: string + description: Human-readable message indicating details about the transition + observedGeneration: + type: integer + format: int32 + description: The generation observed by the controller + lastTransitionTime: + type: string + format: date-time + description: Last time the condition transitioned from one status to another + lastUpdatedTime: + type: string + format: date-time + description: Last time the condition was updated + x-kubernetes-preserve-unknown-fields: true + subresources: + status: {} + additionalPrinterColumns: + - name: Ready + type: string + jsonPath: .status.conditions[?(@.type=="Ready")].status + description: Whether the cluster is ready + - name: Available + type: string + jsonPath: .status.conditions[?(@.type=="Available")].status + description: Whether the cluster is available + - name: Generation + type: integer + jsonPath: .metadata.generation + description: The generation of this resource + - name: Age + type: date + jsonPath: .metadata.creationTimestamp + description: Time since creation diff --git a/charts/crds/idp-crd.yaml b/charts/crds/idp-crd.yaml new file mode 100644 index 0000000..926e630 --- /dev/null +++ b/charts/crds/idp-crd.yaml @@ -0,0 +1,112 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: idps.hyperfleet.io + labels: + app.kubernetes.io/name: hyperfleet-api + app.kubernetes.io/part-of: hyperfleet +spec: + group: hyperfleet.io + names: + kind: IDP + listKind: IDPList + plural: idps + singular: idp + shortNames: + - hfidp + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + description: IDP is the Schema for HyperFleet identity providers + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + description: IDPSpec defines the desired state of an IDP + properties: + clusterRef: + type: string + description: Reference to the parent Cluster resource + type: + type: string + description: Type of identity provider (e.g., OIDC, LDAP, SAML) + enum: + - OIDC + - LDAP + - SAML + - GitHub + - GitLab + - Google + - Microsoft + x-kubernetes-preserve-unknown-fields: true + status: + type: object + description: IDPStatus defines the observed state of an IDP + properties: + conditions: + type: array + description: Conditions represent the latest available observations of the IDP's state + items: + type: object + required: + - type + - status + - lastTransitionTime + properties: + type: + type: string + description: Type of condition (e.g., Ready) + status: + type: string + description: Status of the condition + enum: + - "True" + - "False" + reason: + type: string + description: Machine-readable reason for the condition's last transition + message: + type: string + description: Human-readable message indicating details about the transition + observedGeneration: + type: integer + format: int32 + description: The generation observed by the controller + lastTransitionTime: + type: string + format: date-time + description: Last time the condition transitioned from one status to another + lastUpdatedTime: + type: string + format: date-time + description: Last time the condition was updated + x-kubernetes-preserve-unknown-fields: true + subresources: + status: {} + additionalPrinterColumns: + - name: Cluster + type: string + jsonPath: .spec.clusterRef + description: The parent cluster + - name: Type + type: string + jsonPath: .spec.type + description: The IDP type + - name: Ready + type: string + jsonPath: .status.conditions[?(@.type=="Ready")].status + description: Whether the IDP is ready + - name: Age + type: date + jsonPath: .metadata.creationTimestamp + description: Time since creation diff --git a/charts/crds/nodepool-crd.yaml b/charts/crds/nodepool-crd.yaml new file mode 100644 index 0000000..907ca02 --- /dev/null +++ b/charts/crds/nodepool-crd.yaml @@ -0,0 +1,106 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: nodepools.hyperfleet.io + labels: + app.kubernetes.io/name: hyperfleet-api + app.kubernetes.io/part-of: hyperfleet +spec: + group: hyperfleet.io + names: + kind: NodePool + listKind: NodePoolList + plural: nodepools + singular: nodepool + shortNames: + - hfnp + - hfnodepool + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + description: NodePool is the Schema for HyperFleet node pools + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + description: NodePoolSpec defines the desired state of a NodePool + properties: + clusterRef: + type: string + description: Reference to the parent Cluster resource + x-kubernetes-preserve-unknown-fields: true + status: + type: object + description: NodePoolStatus defines the observed state of a NodePool + properties: + conditions: + type: array + description: Conditions represent the latest available observations of the nodepool's state + items: + type: object + required: + - type + - status + - lastTransitionTime + properties: + type: + type: string + description: Type of condition (e.g., Ready, Available) + status: + type: string + description: Status of the condition + enum: + - "True" + - "False" + reason: + type: string + description: Machine-readable reason for the condition's last transition + message: + type: string + description: Human-readable message indicating details about the transition + observedGeneration: + type: integer + format: int32 + description: The generation observed by the controller + lastTransitionTime: + type: string + format: date-time + description: Last time the condition transitioned from one status to another + lastUpdatedTime: + type: string + format: date-time + description: Last time the condition was updated + x-kubernetes-preserve-unknown-fields: true + subresources: + status: {} + additionalPrinterColumns: + - name: Cluster + type: string + jsonPath: .spec.clusterRef + description: The parent cluster + - name: Ready + type: string + jsonPath: .status.conditions[?(@.type=="Ready")].status + description: Whether the nodepool is ready + - name: Available + type: string + jsonPath: .status.conditions[?(@.type=="Available")].status + description: Whether the nodepool is available + - name: Generation + type: integer + jsonPath: .metadata.generation + description: The generation of this resource + - name: Age + type: date + jsonPath: .metadata.creationTimestamp + description: Time since creation diff --git a/charts/templates/rbac/clusterrole-admin.yaml b/charts/templates/rbac/clusterrole-admin.yaml new file mode 100644 index 0000000..1b54027 --- /dev/null +++ b/charts/templates/rbac/clusterrole-admin.yaml @@ -0,0 +1,35 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "hyperfleet-api.fullname" . }}-admin + labels: + {{- include "hyperfleet-api.labels" . | nindent 4 }} +rules: + # Full CRUD access to HyperFleet resources + - apiGroups: + - hyperfleet.io + resources: + - clusters + - nodepools + - idps + verbs: + - create + - delete + - get + - list + - patch + - update + - watch + # Status subresource update permissions + - apiGroups: + - hyperfleet.io + resources: + - clusters/status + - nodepools/status + - idps/status + verbs: + - get + - patch + - update +{{- end }} diff --git a/charts/templates/rbac/clusterrole.yaml b/charts/templates/rbac/clusterrole.yaml new file mode 100644 index 0000000..0b7273c --- /dev/null +++ b/charts/templates/rbac/clusterrole.yaml @@ -0,0 +1,33 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: {{ include "hyperfleet-api.fullname" . }}-crd-viewer + labels: + {{- include "hyperfleet-api.labels" . | nindent 4 }} +rules: + # Read access to CRD definitions + - apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch + resourceNames: + - clusters.hyperfleet.io + - nodepools.hyperfleet.io + - idps.hyperfleet.io + # Read access to HyperFleet resources + - apiGroups: + - hyperfleet.io + resources: + - clusters + - nodepools + - idps + verbs: + - get + - list + - watch +{{- end }} diff --git a/charts/templates/rbac/clusterrolebinding.yaml b/charts/templates/rbac/clusterrolebinding.yaml new file mode 100644 index 0000000..185c958 --- /dev/null +++ b/charts/templates/rbac/clusterrolebinding.yaml @@ -0,0 +1,16 @@ +{{- if .Values.rbac.create -}} +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: {{ include "hyperfleet-api.fullname" . }}-crd-viewer + labels: + {{- include "hyperfleet-api.labels" . | nindent 4 }} +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: {{ include "hyperfleet-api.fullname" . }}-crd-viewer +subjects: + - kind: ServiceAccount + name: {{ include "hyperfleet-api.serviceAccountName" . }} + namespace: {{ .Release.Namespace }} +{{- end }} diff --git a/charts/values.yaml b/charts/values.yaml index cf8007f..20aeb61 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -29,6 +29,11 @@ serviceAccount: # If not set and create is true, a name is generated using the fullname template name: "" +# RBAC configuration +rbac: + # Create ClusterRole and ClusterRoleBinding for CRD access + create: true + podAnnotations: {} podSecurityContext: diff --git a/cmd/hyperfleet-api/main.go b/cmd/hyperfleet-api/main.go index 26a898d..0d6aa3f 100755 --- a/cmd/hyperfleet-api/main.go +++ b/cmd/hyperfleet-api/main.go @@ -18,6 +18,7 @@ import ( _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/clusters" _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/generic" _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/nodePools" + _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/resources" // Generic CRD-driven resource API ) // nolint diff --git a/config/crds/cluster.yaml b/config/crds/cluster.yaml new file mode 100644 index 0000000..798baa6 --- /dev/null +++ b/config/crds/cluster.yaml @@ -0,0 +1,21 @@ +# Cluster Resource Definition +# This defines a root-level Cluster resource for the generic resource API. +# Adding this file enables the following routes: +# GET /api/hyperfleet/v1/clusters +# POST /api/hyperfleet/v1/clusters +# GET /api/hyperfleet/v1/clusters/{id} +# PATCH /api/hyperfleet/v1/clusters/{id} +# DELETE /api/hyperfleet/v1/clusters/{id} + +apiVersion: hyperfleet.io/v1 +kind: Cluster +plural: clusters +singular: cluster +scope: Root +statusConfig: + requiredAdapters: + - validation + - dns + - pullsecret + - hypershift +enabled: true diff --git a/config/crds/idp.yaml b/config/crds/idp.yaml new file mode 100644 index 0000000..38e8b7d --- /dev/null +++ b/config/crds/idp.yaml @@ -0,0 +1,24 @@ +# IDP (Identity Provider) Resource Definition +# This is an example of how easy it is to add a new resource type. +# Simply create this YAML file and restart the service - routes are automatically registered. +# +# This enables the following routes: +# GET /api/hyperfleet/v1/clusters/{cluster_id}/idps +# POST /api/hyperfleet/v1/clusters/{cluster_id}/idps +# GET /api/hyperfleet/v1/clusters/{cluster_id}/idps/{id} +# PATCH /api/hyperfleet/v1/clusters/{cluster_id}/idps/{id} +# DELETE /api/hyperfleet/v1/clusters/{cluster_id}/idps/{id} + +apiVersion: hyperfleet.io/v1 +kind: IDP +plural: idps +singular: idp +scope: Owned +owner: + kind: Cluster + pathParam: cluster_id +statusConfig: + requiredAdapters: + - validation + - idp-controller +enabled: true diff --git a/config/crds/nodepool.yaml b/config/crds/nodepool.yaml new file mode 100644 index 0000000..3a82eda --- /dev/null +++ b/config/crds/nodepool.yaml @@ -0,0 +1,22 @@ +# NodePool Resource Definition +# This defines an owned resource under Cluster for the generic resource API. +# Adding this file enables the following routes: +# GET /api/hyperfleet/v1/clusters/{cluster_id}/nodepools +# POST /api/hyperfleet/v1/clusters/{cluster_id}/nodepools +# GET /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{id} +# PATCH /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{id} +# DELETE /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{id} + +apiVersion: hyperfleet.io/v1 +kind: NodePool +plural: nodepools +singular: nodepool +scope: Owned +owner: + kind: Cluster + pathParam: cluster_id +statusConfig: + requiredAdapters: + - validation + - hypershift +enabled: true diff --git a/pkg/api/resource_definition.go b/pkg/api/resource_definition.go new file mode 100644 index 0000000..288405c --- /dev/null +++ b/pkg/api/resource_definition.go @@ -0,0 +1,90 @@ +/* +Copyright (c) 2018 Red Hat, 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. +*/ + +// This file contains the CRD (Custom Resource Definition) types for the generic resource API. + +package api + +// ResourceScope defines whether a resource is root-level or owned by another resource. +type ResourceScope string + +const ( + // ResourceScopeRoot indicates a top-level resource with no owner. + ResourceScopeRoot ResourceScope = "Root" + // ResourceScopeOwned indicates a resource that belongs to another resource. + ResourceScopeOwned ResourceScope = "Owned" +) + +// OwnerRef defines the parent resource for owned resources. +type OwnerRef struct { + // Kind is the kind of the owner resource (e.g., "Cluster"). + Kind string `yaml:"kind" json:"kind"` + // PathParam is the URL path parameter name for the owner ID (e.g., "cluster_id"). + PathParam string `yaml:"pathParam" json:"pathParam"` +} + +// StatusConfig defines the status aggregation configuration for a resource. +type StatusConfig struct { + // RequiredAdapters is the list of adapter names required for this resource type. + RequiredAdapters []string `yaml:"requiredAdapters" json:"requiredAdapters"` +} + +// ResourceDefinition defines a custom resource type (CRD). +// It specifies the resource's identity, scope, ownership, and status configuration. +type ResourceDefinition struct { + // APIVersion is the API version (e.g., "hyperfleet.io/v1"). + APIVersion string `yaml:"apiVersion" json:"apiVersion"` + // Kind is the resource type name (e.g., "Cluster", "NodePool"). + Kind string `yaml:"kind" json:"kind"` + // Plural is the plural form for API paths (e.g., "clusters", "nodepools"). + Plural string `yaml:"plural" json:"plural"` + // Singular is the singular form (e.g., "cluster", "nodepool"). + Singular string `yaml:"singular" json:"singular"` + // Scope indicates whether this is a Root or Owned resource. + Scope ResourceScope `yaml:"scope" json:"scope"` + // Owner defines the parent resource for Owned scope resources. + Owner *OwnerRef `yaml:"owner,omitempty" json:"owner,omitempty"` + // StatusConfig defines the status aggregation settings. + StatusConfig StatusConfig `yaml:"statusConfig" json:"statusConfig"` + // Enabled indicates whether this resource type is active. + Enabled bool `yaml:"enabled" json:"enabled"` +} + +// IsRoot returns true if this is a root-level resource. +func (rd *ResourceDefinition) IsRoot() bool { + return rd.Scope == ResourceScopeRoot +} + +// IsOwned returns true if this resource has an owner. +func (rd *ResourceDefinition) IsOwned() bool { + return rd.Scope == ResourceScopeOwned && rd.Owner != nil +} + +// GetOwnerKind returns the owner's kind, or empty string if not owned. +func (rd *ResourceDefinition) GetOwnerKind() string { + if rd.Owner == nil { + return "" + } + return rd.Owner.Kind +} + +// GetOwnerPathParam returns the URL path parameter for the owner ID. +func (rd *ResourceDefinition) GetOwnerPathParam() string { + if rd.Owner == nil { + return "" + } + return rd.Owner.PathParam +} diff --git a/pkg/api/resource_types.go b/pkg/api/resource_types.go new file mode 100644 index 0000000..6d709b4 --- /dev/null +++ b/pkg/api/resource_types.go @@ -0,0 +1,116 @@ +/* +Copyright (c) 2018 Red Hat, 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. +*/ + +// This file contains the generic Resource model for the CRD-driven API. + +package api + +import ( + "time" + + "gorm.io/datatypes" + "gorm.io/gorm" +) + +// Resource is a generic database model that can represent any resource type. +// The resource type is determined by the Kind field and CRD definitions. +type Resource struct { + Meta // Contains ID, CreatedTime, UpdatedTime, DeletedAt + + // Core fields + Kind string `json:"kind" gorm:"size:63;not null;index"` + Name string `json:"name" gorm:"size:63;not null"` + Spec datatypes.JSON `json:"spec" gorm:"type:jsonb;not null"` + Labels datatypes.JSON `json:"labels,omitempty" gorm:"type:jsonb"` + Href string `json:"href,omitempty" gorm:"size:500"` + + // Version control + Generation int32 `json:"generation" gorm:"default:1;not null"` + + // Owner references (for owned resources like NodePools under Clusters) + OwnerID *string `json:"owner_id,omitempty" gorm:"size:255;index"` + OwnerKind *string `json:"owner_kind,omitempty" gorm:"size:63"` + OwnerHref *string `json:"owner_href,omitempty" gorm:"size:500"` + + // Status (conditions-only model with synthetic Available/Ready conditions) + StatusConditions datatypes.JSON `json:"status_conditions" gorm:"type:jsonb"` + + // Audit fields + CreatedBy string `json:"created_by" gorm:"size:255;not null"` + UpdatedBy string `json:"updated_by" gorm:"size:255;not null"` +} + +// TableName specifies the database table name for GORM. +func (Resource) TableName() string { + return "resources" +} + +// ResourceList is a slice of Resource pointers. +type ResourceList []*Resource + +// ResourceIndex maps resource IDs to Resource pointers. +type ResourceIndex map[string]*Resource + +// Index creates a map of resources indexed by ID. +func (l ResourceList) Index() ResourceIndex { + index := ResourceIndex{} + for _, o := range l { + index[o.ID] = o + } + return index +} + +// BeforeCreate is a GORM hook that sets ID, timestamps, and defaults before insert. +func (r *Resource) BeforeCreate(tx *gorm.DB) error { + now := time.Now() + r.ID = NewID() + r.CreatedTime = now + r.UpdatedTime = now + if r.Generation == 0 { + r.Generation = 1 + } + return nil +} + +// BeforeUpdate is a GORM hook that updates the timestamp before update. +func (r *Resource) BeforeUpdate(tx *gorm.DB) error { + r.UpdatedTime = time.Now() + return nil +} + +// IsRoot returns true if this resource has no owner. +func (r *Resource) IsRoot() bool { + return r.OwnerID == nil || *r.OwnerID == "" +} + +// IsOwned returns true if this resource has an owner. +func (r *Resource) IsOwned() bool { + return r.OwnerID != nil && *r.OwnerID != "" +} + +// ResourcePatchRequest represents a PATCH request for a generic resource. +type ResourcePatchRequest struct { + Spec *map[string]interface{} `json:"spec,omitempty"` + Labels *map[string]string `json:"labels,omitempty"` +} + +// ResourceCreateRequest represents a POST request for creating a generic resource. +type ResourceCreateRequest struct { + Kind *string `json:"kind,omitempty"` + Name string `json:"name"` + Spec map[string]interface{} `json:"spec"` + Labels *map[string]string `json:"labels,omitempty"` +} diff --git a/pkg/crd/registry.go b/pkg/crd/registry.go new file mode 100644 index 0000000..28b35c9 --- /dev/null +++ b/pkg/crd/registry.go @@ -0,0 +1,231 @@ +/* +Copyright (c) 2018 Red Hat, 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. +*/ + +// Package crd provides a registry for loading and managing Custom Resource Definitions. + +package crd + +import ( + "fmt" + "os" + "path/filepath" + "sync" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "gopkg.in/yaml.v3" +) + +// Registry holds all loaded CRD definitions and provides lookup methods. +type Registry struct { + mu sync.RWMutex + byKind map[string]*api.ResourceDefinition + byPlural map[string]*api.ResourceDefinition + all []*api.ResourceDefinition +} + +// NewRegistry creates an empty CRD registry. +func NewRegistry() *Registry { + return &Registry{ + byKind: make(map[string]*api.ResourceDefinition), + byPlural: make(map[string]*api.ResourceDefinition), + all: make([]*api.ResourceDefinition, 0), + } +} + +// LoadFromDirectory loads all YAML CRD files from the specified directory. +// Files must have .yaml or .yml extension. +func (r *Registry) LoadFromDirectory(dirPath string) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Check if directory exists + info, err := os.Stat(dirPath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("CRD directory does not exist: %s", dirPath) + } + return fmt.Errorf("failed to stat CRD directory: %w", err) + } + if !info.IsDir() { + return fmt.Errorf("CRD path is not a directory: %s", dirPath) + } + + // Find all YAML files + entries, err := os.ReadDir(dirPath) + if err != nil { + return fmt.Errorf("failed to read CRD directory: %w", err) + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + + ext := filepath.Ext(entry.Name()) + if ext != ".yaml" && ext != ".yml" { + continue + } + + filePath := filepath.Join(dirPath, entry.Name()) + if err := r.loadFile(filePath); err != nil { + return fmt.Errorf("failed to load CRD from %s: %w", filePath, err) + } + } + + return nil +} + +// loadFile loads a single CRD YAML file. +func (r *Registry) loadFile(filePath string) error { + data, err := os.ReadFile(filePath) + if err != nil { + return fmt.Errorf("failed to read file: %w", err) + } + + var def api.ResourceDefinition + if err := yaml.Unmarshal(data, &def); err != nil { + return fmt.Errorf("failed to parse YAML: %w", err) + } + + // Validate required fields + if def.Kind == "" { + return fmt.Errorf("missing required field 'kind'") + } + if def.Plural == "" { + return fmt.Errorf("missing required field 'plural'") + } + if def.Scope == "" { + return fmt.Errorf("missing required field 'scope'") + } + + // Validate scope value + if def.Scope != api.ResourceScopeRoot && def.Scope != api.ResourceScopeOwned { + return fmt.Errorf("invalid scope '%s': must be 'Root' or 'Owned'", def.Scope) + } + + // Validate owned resources have owner configuration + if def.Scope == api.ResourceScopeOwned && def.Owner == nil { + return fmt.Errorf("owned resource '%s' must have 'owner' configuration", def.Kind) + } + + // Set singular default if not provided + if def.Singular == "" { + def.Singular = def.Kind + } + + // Check for duplicates + if _, exists := r.byKind[def.Kind]; exists { + return fmt.Errorf("duplicate kind '%s'", def.Kind) + } + if _, exists := r.byPlural[def.Plural]; exists { + return fmt.Errorf("duplicate plural '%s'", def.Plural) + } + + // Register the CRD + r.byKind[def.Kind] = &def + r.byPlural[def.Plural] = &def + if def.Enabled { + r.all = append(r.all, &def) + } + + return nil +} + +// Register adds a CRD definition programmatically. +func (r *Registry) Register(def *api.ResourceDefinition) error { + r.mu.Lock() + defer r.mu.Unlock() + + if def.Kind == "" || def.Plural == "" { + return fmt.Errorf("kind and plural are required") + } + + if _, exists := r.byKind[def.Kind]; exists { + return fmt.Errorf("duplicate kind '%s'", def.Kind) + } + if _, exists := r.byPlural[def.Plural]; exists { + return fmt.Errorf("duplicate plural '%s'", def.Plural) + } + + r.byKind[def.Kind] = def + r.byPlural[def.Plural] = def + if def.Enabled { + r.all = append(r.all, def) + } + + return nil +} + +// GetByKind returns the CRD definition for the given kind. +func (r *Registry) GetByKind(kind string) (*api.ResourceDefinition, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + def, ok := r.byKind[kind] + return def, ok +} + +// GetByPlural returns the CRD definition for the given plural name. +func (r *Registry) GetByPlural(plural string) (*api.ResourceDefinition, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + def, ok := r.byPlural[plural] + return def, ok +} + +// All returns all enabled CRD definitions. +func (r *Registry) All() []*api.ResourceDefinition { + r.mu.RLock() + defer r.mu.RUnlock() + + result := make([]*api.ResourceDefinition, len(r.all)) + copy(result, r.all) + return result +} + +// Count returns the number of enabled CRD definitions. +func (r *Registry) Count() int { + r.mu.RLock() + defer r.mu.RUnlock() + return len(r.all) +} + +// Global default registry +var defaultRegistry = NewRegistry() + +// Default returns the global default registry. +func Default() *Registry { + return defaultRegistry +} + +// LoadFromDirectory loads CRDs into the default registry. +func LoadFromDirectory(dirPath string) error { + return defaultRegistry.LoadFromDirectory(dirPath) +} + +// GetByKind looks up a CRD by kind in the default registry. +func GetByKind(kind string) (*api.ResourceDefinition, bool) { + return defaultRegistry.GetByKind(kind) +} + +// GetByPlural looks up a CRD by plural name in the default registry. +func GetByPlural(plural string) (*api.ResourceDefinition, bool) { + return defaultRegistry.GetByPlural(plural) +} + +// All returns all enabled CRDs from the default registry. +func All() []*api.ResourceDefinition { + return defaultRegistry.All() +} diff --git a/pkg/dao/resource.go b/pkg/dao/resource.go new file mode 100644 index 0000000..283415f --- /dev/null +++ b/pkg/dao/resource.go @@ -0,0 +1,215 @@ +package dao + +import ( + "bytes" + "context" + + "gorm.io/gorm/clause" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" +) + +// ResourceDao defines the data access interface for generic resources. +type ResourceDao interface { + // Get retrieves a resource by ID. + Get(ctx context.Context, id string) (*api.Resource, error) + + // GetByKindAndID retrieves a resource by kind and ID. + GetByKindAndID(ctx context.Context, kind, id string) (*api.Resource, error) + + // GetByOwner retrieves a resource by kind, owner ID, and resource ID. + GetByOwner(ctx context.Context, kind, ownerID, id string) (*api.Resource, error) + + // GetByOwnerAndName retrieves a resource by kind, owner ID, and name. + GetByOwnerAndName(ctx context.Context, kind, ownerID, name string) (*api.Resource, error) + + // GetByKindAndName retrieves a root resource by kind and name. + GetByKindAndName(ctx context.Context, kind, name string) (*api.Resource, error) + + // Create inserts a new resource. + Create(ctx context.Context, resource *api.Resource) (*api.Resource, error) + + // Replace updates an existing resource with generation tracking. + Replace(ctx context.Context, resource *api.Resource) (*api.Resource, error) + + // Delete soft-deletes a resource by ID. + Delete(ctx context.Context, id string) error + + // DeleteByKindAndID soft-deletes a resource by kind and ID. + DeleteByKindAndID(ctx context.Context, kind, id string) error + + // ListByKind returns all resources of a given kind. + ListByKind(ctx context.Context, kind string, offset, limit int) (api.ResourceList, int64, error) + + // ListByOwner returns all resources of a given kind under an owner. + ListByOwner(ctx context.Context, kind, ownerID string, offset, limit int) (api.ResourceList, int64, error) + + // FindByIDs returns resources matching the given IDs. + FindByIDs(ctx context.Context, ids []string) (api.ResourceList, error) +} + +var _ ResourceDao = &sqlResourceDao{} + +type sqlResourceDao struct { + sessionFactory *db.SessionFactory +} + +// NewResourceDao creates a new ResourceDao instance. +func NewResourceDao(sessionFactory *db.SessionFactory) ResourceDao { + return &sqlResourceDao{sessionFactory: sessionFactory} +} + +func (d *sqlResourceDao) Get(ctx context.Context, id string) (*api.Resource, error) { + g2 := (*d.sessionFactory).New(ctx) + var resource api.Resource + if err := g2.Take(&resource, "id = ?", id).Error; err != nil { + return nil, err + } + return &resource, nil +} + +func (d *sqlResourceDao) GetByKindAndID(ctx context.Context, kind, id string) (*api.Resource, error) { + g2 := (*d.sessionFactory).New(ctx) + var resource api.Resource + if err := g2.Take(&resource, "kind = ? AND id = ?", kind, id).Error; err != nil { + return nil, err + } + return &resource, nil +} + +func (d *sqlResourceDao) GetByOwner(ctx context.Context, kind, ownerID, id string) (*api.Resource, error) { + g2 := (*d.sessionFactory).New(ctx) + var resource api.Resource + if err := g2.Take(&resource, "kind = ? AND owner_id = ? AND id = ?", kind, ownerID, id).Error; err != nil { + return nil, err + } + return &resource, nil +} + +func (d *sqlResourceDao) GetByOwnerAndName(ctx context.Context, kind, ownerID, name string) (*api.Resource, error) { + g2 := (*d.sessionFactory).New(ctx) + var resource api.Resource + if err := g2.Take(&resource, "kind = ? AND owner_id = ? AND name = ?", kind, ownerID, name).Error; err != nil { + return nil, err + } + return &resource, nil +} + +func (d *sqlResourceDao) GetByKindAndName(ctx context.Context, kind, name string) (*api.Resource, error) { + g2 := (*d.sessionFactory).New(ctx) + var resource api.Resource + if err := g2.Take(&resource, "kind = ? AND name = ? AND owner_id IS NULL", kind, name).Error; err != nil { + return nil, err + } + return &resource, nil +} + +func (d *sqlResourceDao) Create(ctx context.Context, resource *api.Resource) (*api.Resource, error) { + g2 := (*d.sessionFactory).New(ctx) + if err := g2.Omit(clause.Associations).Create(resource).Error; err != nil { + db.MarkForRollback(ctx, err) + return nil, err + } + return resource, nil +} + +func (d *sqlResourceDao) Replace(ctx context.Context, resource *api.Resource) (*api.Resource, error) { + g2 := (*d.sessionFactory).New(ctx) + + // Get the existing resource to compare spec + existing, err := d.Get(ctx, resource.ID) + if err != nil { + db.MarkForRollback(ctx, err) + return nil, err + } + + // Compare spec: if changed, increment generation + if !bytes.Equal(existing.Spec, resource.Spec) { + resource.Generation = existing.Generation + 1 + } else { + // Spec unchanged, preserve generation + resource.Generation = existing.Generation + } + + // Save the resource + if err := g2.Omit(clause.Associations).Save(resource).Error; err != nil { + db.MarkForRollback(ctx, err) + return nil, err + } + return resource, nil +} + +func (d *sqlResourceDao) Delete(ctx context.Context, id string) error { + g2 := (*d.sessionFactory).New(ctx) + if err := g2.Omit(clause.Associations).Delete(&api.Resource{Meta: api.Meta{ID: id}}).Error; err != nil { + db.MarkForRollback(ctx, err) + return err + } + return nil +} + +func (d *sqlResourceDao) DeleteByKindAndID(ctx context.Context, kind, id string) error { + g2 := (*d.sessionFactory).New(ctx) + if err := g2.Where("kind = ? AND id = ?", kind, id).Delete(&api.Resource{}).Error; err != nil { + db.MarkForRollback(ctx, err) + return err + } + return nil +} + +func (d *sqlResourceDao) ListByKind(ctx context.Context, kind string, offset, limit int) (api.ResourceList, int64, error) { + g2 := (*d.sessionFactory).New(ctx) + var resources api.ResourceList + var total int64 + + // Count total + if err := g2.Model(&api.Resource{}).Where("kind = ? AND owner_id IS NULL", kind).Count(&total).Error; err != nil { + return nil, 0, err + } + + // Fetch with pagination + query := g2.Where("kind = ? AND owner_id IS NULL", kind).Order("created_time DESC") + if limit > 0 { + query = query.Offset(offset).Limit(limit) + } + if err := query.Find(&resources).Error; err != nil { + return nil, 0, err + } + + return resources, total, nil +} + +func (d *sqlResourceDao) ListByOwner(ctx context.Context, kind, ownerID string, offset, limit int) (api.ResourceList, int64, error) { + g2 := (*d.sessionFactory).New(ctx) + var resources api.ResourceList + var total int64 + + // Count total + if err := g2.Model(&api.Resource{}).Where("kind = ? AND owner_id = ?", kind, ownerID).Count(&total).Error; err != nil { + return nil, 0, err + } + + // Fetch with pagination + query := g2.Where("kind = ? AND owner_id = ?", kind, ownerID).Order("created_time DESC") + if limit > 0 { + query = query.Offset(offset).Limit(limit) + } + if err := query.Find(&resources).Error; err != nil { + return nil, 0, err + } + + return resources, total, nil +} + +func (d *sqlResourceDao) FindByIDs(ctx context.Context, ids []string) (api.ResourceList, error) { + g2 := (*d.sessionFactory).New(ctx) + var resources api.ResourceList + if len(ids) == 0 { + return resources, nil + } + if err := g2.Where("id IN (?)", ids).Find(&resources).Error; err != nil { + return nil, err + } + return resources, nil +} diff --git a/pkg/db/migrations/202602060001_add_resources.go b/pkg/db/migrations/202602060001_add_resources.go new file mode 100644 index 0000000..16f0ed1 --- /dev/null +++ b/pkg/db/migrations/202602060001_add_resources.go @@ -0,0 +1,134 @@ +package migrations + +// Migrations should NEVER use types from other packages. Types can change +// and then migrations run on a _new_ database will fail or behave unexpectedly. +// Instead of importing types, always re-create the type in the migration. + +import ( + "gorm.io/gorm" + + "github.com/go-gormigrate/gormigrate/v2" +) + +func addResources() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "202602060001", + Migrate: func(tx *gorm.DB) error { + // Create generic resources table + // This table stores all CRD-based resources using a single schema. + // The Kind column distinguishes resource types. + createTableSQL := ` + CREATE TABLE IF NOT EXISTS resources ( + id VARCHAR(255) PRIMARY KEY, + created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ NULL, + + -- Core fields + kind VARCHAR(63) NOT NULL, + name VARCHAR(63) NOT NULL, + spec JSONB NOT NULL, + labels JSONB NULL, + href VARCHAR(500), + + -- Version control + generation INTEGER NOT NULL DEFAULT 1, + + -- Owner references (for owned resources) + owner_id VARCHAR(255) NULL, + owner_kind VARCHAR(63) NULL, + owner_href VARCHAR(500) NULL, + + -- Status (conditions-only model) + status_conditions JSONB NULL, + + -- Audit fields + created_by VARCHAR(255) NOT NULL, + updated_by VARCHAR(255) NOT NULL + ); + ` + + if err := tx.Exec(createTableSQL).Error; err != nil { + return err + } + + // Create index on deleted_at for soft deletes + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_resources_deleted_at ON resources(deleted_at);").Error; err != nil { + return err + } + + // Create index on kind for efficient filtering by resource type + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_resources_kind ON resources(kind);").Error; err != nil { + return err + } + + // Create index on owner_id for efficient lookup of owned resources + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_resources_owner_id ON resources(owner_id);").Error; err != nil { + return err + } + + // Create composite index on kind + owner_id for owned resource queries + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_resources_kind_owner ON resources(kind, owner_id);").Error; err != nil { + return err + } + + // Create unique index on (kind, name) for root resources (where owner_id IS NULL) + // This ensures unique names per kind for root-level resources + createRootUniqueIndexSQL := ` + CREATE UNIQUE INDEX IF NOT EXISTS idx_resources_root_kind_name + ON resources(kind, name) + WHERE deleted_at IS NULL AND owner_id IS NULL; + ` + if err := tx.Exec(createRootUniqueIndexSQL).Error; err != nil { + return err + } + + // Create unique index on (owner_id, kind, name) for owned resources + // This ensures unique names per kind within each owner + createOwnedUniqueIndexSQL := ` + CREATE UNIQUE INDEX IF NOT EXISTS idx_resources_owned_kind_name + ON resources(owner_id, kind, name) + WHERE deleted_at IS NULL AND owner_id IS NOT NULL; + ` + if err := tx.Exec(createOwnedUniqueIndexSQL).Error; err != nil { + return err + } + + // Create GIN index on status_conditions for efficient condition queries + if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_resources_status_conditions ON resources USING GIN(status_conditions);").Error; err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + // Drop indexes first + if err := tx.Exec("DROP INDEX IF EXISTS idx_resources_status_conditions;").Error; err != nil { + return err + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_resources_owned_kind_name;").Error; err != nil { + return err + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_resources_root_kind_name;").Error; err != nil { + return err + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_resources_kind_owner;").Error; err != nil { + return err + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_resources_owner_id;").Error; err != nil { + return err + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_resources_kind;").Error; err != nil { + return err + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_resources_deleted_at;").Error; err != nil { + return err + } + // Drop table + if err := tx.Exec("DROP TABLE IF EXISTS resources;").Error; err != nil { + return err + } + return nil + }, + } +} diff --git a/pkg/db/migrations/migration_structs.go b/pkg/db/migrations/migration_structs.go index 00fe82e..e9fad75 100755 --- a/pkg/db/migrations/migration_structs.go +++ b/pkg/db/migrations/migration_structs.go @@ -32,6 +32,7 @@ var MigrationList = []*gormigrate.Migration{ addNodePools(), addAdapterStatus(), addConditionsGinIndex(), + addResources(), // Generic resource table for CRD-driven API } // Model represents the base model struct. All entities will have this struct embedded. diff --git a/pkg/handlers/resource.go b/pkg/handlers/resource.go new file mode 100644 index 0000000..7d0cf13 --- /dev/null +++ b/pkg/handlers/resource.go @@ -0,0 +1,362 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/gorilla/mux" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" +) + +// ResourceHandler handles HTTP requests for generic CRD-based resources. +// It is CRD-aware and adapts behavior based on the resource definition. +type ResourceHandler struct { + resource services.ResourceService + kind string + plural string + isOwned bool + ownerKind string + ownerPathParam string + requiredAdapters []string +} + +// ResourceHandlerConfig contains configuration for creating a ResourceHandler. +type ResourceHandlerConfig struct { + Kind string + Plural string + IsOwned bool + OwnerKind string + OwnerPathParam string + RequiredAdapters []string +} + +// NewResourceHandler creates a new ResourceHandler instance. +func NewResourceHandler( + resourceService services.ResourceService, + cfg ResourceHandlerConfig, +) *ResourceHandler { + return &ResourceHandler{ + resource: resourceService, + kind: cfg.Kind, + plural: cfg.Plural, + isOwned: cfg.IsOwned, + ownerKind: cfg.OwnerKind, + ownerPathParam: cfg.OwnerPathParam, + requiredAdapters: cfg.RequiredAdapters, + } +} + +// Create handles POST requests to create a new resource. +func (h *ResourceHandler) Create(w http.ResponseWriter, r *http.Request) { + var req api.ResourceCreateRequest + cfg := &handlerConfig{ + &req, + []validate{ + validateName(&req, "Name", "name", 3, 63), + }, + func() (interface{}, *errors.ServiceError) { + ctx := r.Context() + + // Convert request to domain model + resource, err := h.convertCreateRequest(&req, r) + if err != nil { + return nil, err + } + + // Create the resource + resource, svcErr := h.resource.Create(ctx, resource, h.requiredAdapters) + if svcErr != nil { + return nil, svcErr + } + + // Return the created resource + return h.presentResource(resource), nil + }, + handleError, + } + + handle(w, r, cfg, http.StatusCreated) +} + +// Get handles GET requests to retrieve a single resource. +func (h *ResourceHandler) Get(w http.ResponseWriter, r *http.Request) { + cfg := &handlerConfig{ + Action: func() (interface{}, *errors.ServiceError) { + ctx := r.Context() + id := mux.Vars(r)["id"] + + var resource *api.Resource + var svcErr *errors.ServiceError + + if h.isOwned { + ownerID := mux.Vars(r)[h.ownerPathParam] + resource, svcErr = h.resource.GetByOwner(ctx, h.kind, ownerID, id) + } else { + resource, svcErr = h.resource.Get(ctx, h.kind, id) + } + + if svcErr != nil { + return nil, svcErr + } + + return h.presentResource(resource), nil + }, + } + + handleGet(w, r, cfg) +} + +// List handles GET requests to list resources. +func (h *ResourceHandler) List(w http.ResponseWriter, r *http.Request) { + cfg := &handlerConfig{ + Action: func() (interface{}, *errors.ServiceError) { + ctx := r.Context() + listArgs := services.NewListArguments(r.URL.Query()) + + var resources api.ResourceList + var total int64 + var svcErr *errors.ServiceError + + // Calculate offset from page and size + offset := (listArgs.Page - 1) * int(listArgs.Size) + limit := int(listArgs.Size) + + if h.isOwned { + ownerID := mux.Vars(r)[h.ownerPathParam] + resources, total, svcErr = h.resource.ListByOwner(ctx, h.kind, ownerID, offset, limit) + } else { + resources, total, svcErr = h.resource.ListByKind(ctx, h.kind, offset, limit) + } + + if svcErr != nil { + return nil, svcErr + } + + // Build response list + items := make([]map[string]interface{}, 0, len(resources)) + for _, resource := range resources { + items = append(items, h.presentResource(resource)) + } + + return map[string]interface{}{ + "kind": h.kind + "List", + "page": listArgs.Page, + "size": len(items), + "total": total, + "items": items, + }, nil + }, + } + + handleList(w, r, cfg) +} + +// Patch handles PATCH requests to update a resource. +func (h *ResourceHandler) Patch(w http.ResponseWriter, r *http.Request) { + var patch api.ResourcePatchRequest + + cfg := &handlerConfig{ + &patch, + []validate{}, + func() (interface{}, *errors.ServiceError) { + ctx := r.Context() + id := mux.Vars(r)["id"] + + // Get existing resource + var found *api.Resource + var svcErr *errors.ServiceError + + if h.isOwned { + ownerID := mux.Vars(r)[h.ownerPathParam] + found, svcErr = h.resource.GetByOwner(ctx, h.kind, ownerID, id) + } else { + found, svcErr = h.resource.Get(ctx, h.kind, id) + } + + if svcErr != nil { + return nil, svcErr + } + + // Apply patch + if patch.Spec != nil { + specJSON, err := json.Marshal(*patch.Spec) + if err != nil { + return nil, errors.GeneralError("Failed to marshal spec: %v", err) + } + found.Spec = specJSON + } + + if patch.Labels != nil { + labelsJSON, err := json.Marshal(*patch.Labels) + if err != nil { + return nil, errors.GeneralError("Failed to marshal labels: %v", err) + } + found.Labels = labelsJSON + } + + // Update user info + found.UpdatedBy = "system@hyperfleet.local" // TODO: Get from auth context + + // Replace the resource + resource, svcErr := h.resource.Replace(ctx, found) + if svcErr != nil { + return nil, svcErr + } + + return h.presentResource(resource), nil + }, + handleError, + } + + handle(w, r, cfg, http.StatusOK) +} + +// Delete handles DELETE requests to remove a resource. +func (h *ResourceHandler) Delete(w http.ResponseWriter, r *http.Request) { + cfg := &handlerConfig{ + Action: func() (interface{}, *errors.ServiceError) { + ctx := r.Context() + id := mux.Vars(r)["id"] + + // Verify resource exists + var svcErr *errors.ServiceError + if h.isOwned { + ownerID := mux.Vars(r)[h.ownerPathParam] + _, svcErr = h.resource.GetByOwner(ctx, h.kind, ownerID, id) + } else { + _, svcErr = h.resource.Get(ctx, h.kind, id) + } + if svcErr != nil { + return nil, svcErr + } + + // Delete the resource + svcErr = h.resource.Delete(ctx, h.kind, id) + if svcErr != nil { + return nil, svcErr + } + + return nil, nil + }, + } + + handleDelete(w, r, cfg, http.StatusNoContent) +} + +// convertCreateRequest converts the API request to a domain Resource model. +func (h *ResourceHandler) convertCreateRequest(req *api.ResourceCreateRequest, r *http.Request) (*api.Resource, *errors.ServiceError) { + // Marshal Spec + specJSON, err := json.Marshal(req.Spec) + if err != nil { + return nil, errors.GeneralError("Failed to marshal spec: %v", err) + } + + // Marshal Labels + labels := make(map[string]string) + if req.Labels != nil { + labels = *req.Labels + } + labelsJSON, err := json.Marshal(labels) + if err != nil { + return nil, errors.GeneralError("Failed to marshal labels: %v", err) + } + + resource := &api.Resource{ + Kind: h.kind, + Name: req.Name, + Spec: specJSON, + Labels: labelsJSON, + Generation: 1, + CreatedBy: "system@hyperfleet.local", // TODO: Get from auth context + UpdatedBy: "system@hyperfleet.local", + } + + // Set owner references for owned resources + if h.isOwned { + ownerID := mux.Vars(r)[h.ownerPathParam] + ownerHref := fmt.Sprintf("/api/hyperfleet/v1/%s/%s", h.plural, ownerID) // Simplified, adjust as needed + resource.OwnerID = &ownerID + resource.OwnerKind = &h.ownerKind + resource.OwnerHref = &ownerHref + } + + // Set Href + if h.isOwned { + ownerID := mux.Vars(r)[h.ownerPathParam] + resource.Href = fmt.Sprintf("/api/hyperfleet/v1/%s/%s/%s", getOwnerPlural(h.ownerKind), ownerID, h.plural) + } else { + resource.Href = fmt.Sprintf("/api/hyperfleet/v1/%s", h.plural) + } + + return resource, nil +} + +// presentResource converts a domain Resource to an API response map. +func (h *ResourceHandler) presentResource(resource *api.Resource) map[string]interface{} { + result := map[string]interface{}{ + "id": resource.ID, + "kind": resource.Kind, + "name": resource.Name, + "href": resource.Href, + "generation": resource.Generation, + "created_time": resource.CreatedTime, + "updated_time": resource.UpdatedTime, + "created_by": resource.CreatedBy, + "updated_by": resource.UpdatedBy, + } + + // Unmarshal and add spec + if len(resource.Spec) > 0 { + var spec map[string]interface{} + if err := json.Unmarshal(resource.Spec, &spec); err == nil { + result["spec"] = spec + } + } + + // Unmarshal and add labels + if len(resource.Labels) > 0 { + var labels map[string]string + if err := json.Unmarshal(resource.Labels, &labels); err == nil { + result["labels"] = labels + } + } + + // Unmarshal and add status conditions + if len(resource.StatusConditions) > 0 { + var conditions []api.ResourceCondition + if err := json.Unmarshal(resource.StatusConditions, &conditions); err == nil { + result["status"] = map[string]interface{}{ + "conditions": conditions, + } + } + } + + // Add owner reference for owned resources + if resource.OwnerID != nil && *resource.OwnerID != "" { + result["owner"] = map[string]interface{}{ + "id": *resource.OwnerID, + "kind": resource.OwnerKind, + "href": resource.OwnerHref, + } + } + + return result +} + +// getOwnerPlural returns the plural form of an owner kind. +// This is a simple mapping; in production, you'd look this up from the CRD registry. +func getOwnerPlural(kind string) string { + plurals := map[string]string{ + "Cluster": "clusters", + "NodePool": "nodepools", + } + if plural, ok := plurals[kind]; ok { + return plural + } + // Default: lowercase + "s" + return kind + "s" +} diff --git a/pkg/services/generic.go b/pkg/services/generic.go index 6a2f4c0..084df1c 100755 --- a/pkg/services/generic.go +++ b/pkg/services/generic.go @@ -47,6 +47,9 @@ var ( "NodePool": { "spec": "spec", // Provider-specific field, not searchable }, + "Resource": { + "spec": "spec", // Generic resource spec is not searchable + }, } allFieldsAllowed = map[string]string{} ) diff --git a/pkg/services/resource.go b/pkg/services/resource.go new file mode 100644 index 0000000..88b2282 --- /dev/null +++ b/pkg/services/resource.go @@ -0,0 +1,318 @@ +package services + +import ( + "context" + "encoding/json" + stderrors "errors" + "strings" + "time" + + "gorm.io/gorm" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" +) + +//go:generate mockgen-v0.6.0 -source=resource.go -package=services -destination=resource_mock.go + +// ResourceService defines the service interface for generic CRD-based resources. +type ResourceService interface { + // Get retrieves a resource by ID. + Get(ctx context.Context, kind, id string) (*api.Resource, *errors.ServiceError) + + // GetByOwner retrieves an owned resource by owner ID and resource ID. + GetByOwner(ctx context.Context, kind, ownerID, id string) (*api.Resource, *errors.ServiceError) + + // Create creates a new resource. + Create(ctx context.Context, resource *api.Resource, requiredAdapters []string) (*api.Resource, *errors.ServiceError) + + // Replace updates an existing resource. + Replace(ctx context.Context, resource *api.Resource) (*api.Resource, *errors.ServiceError) + + // Delete soft-deletes a resource by ID. + Delete(ctx context.Context, kind, id string) *errors.ServiceError + + // ListByKind returns all resources of a given kind. + ListByKind(ctx context.Context, kind string, offset, limit int) (api.ResourceList, int64, *errors.ServiceError) + + // ListByOwner returns all resources of a given kind under an owner. + ListByOwner(ctx context.Context, kind, ownerID string, offset, limit int) (api.ResourceList, int64, *errors.ServiceError) + + // Status aggregation + UpdateResourceStatusFromAdapters(ctx context.Context, kind, resourceID string, requiredAdapters []string) (*api.Resource, *errors.ServiceError) + + // ProcessAdapterStatus handles the business logic for adapter status: + // - If Available condition is "Unknown": returns (nil, nil) indicating no-op + // - Otherwise: upserts the status and triggers aggregation + ProcessAdapterStatus( + ctx context.Context, kind, resourceID string, adapterStatus *api.AdapterStatus, requiredAdapters []string, + ) (*api.AdapterStatus, *errors.ServiceError) + + // Idempotent functions for control plane operations + OnUpsert(ctx context.Context, kind, id string) error + OnDelete(ctx context.Context, kind, id string) error +} + +// NewResourceService creates a new ResourceService instance. +func NewResourceService( + resourceDao dao.ResourceDao, + adapterStatusDao dao.AdapterStatusDao, +) ResourceService { + return &sqlResourceService{ + resourceDao: resourceDao, + adapterStatusDao: adapterStatusDao, + } +} + +var _ ResourceService = &sqlResourceService{} + +type sqlResourceService struct { + resourceDao dao.ResourceDao + adapterStatusDao dao.AdapterStatusDao +} + +func (s *sqlResourceService) Get(ctx context.Context, kind, id string) (*api.Resource, *errors.ServiceError) { + resource, err := s.resourceDao.GetByKindAndID(ctx, kind, id) + if err != nil { + return nil, handleGetError(kind, "id", id, err) + } + return resource, nil +} + +func (s *sqlResourceService) GetByOwner(ctx context.Context, kind, ownerID, id string) (*api.Resource, *errors.ServiceError) { + resource, err := s.resourceDao.GetByOwner(ctx, kind, ownerID, id) + if err != nil { + return nil, handleGetError(kind, "id", id, err) + } + return resource, nil +} + +func (s *sqlResourceService) Create(ctx context.Context, resource *api.Resource, requiredAdapters []string) (*api.Resource, *errors.ServiceError) { + if resource.Generation == 0 { + resource.Generation = 1 + } + + resource, err := s.resourceDao.Create(ctx, resource) + if err != nil { + return nil, handleCreateError(resource.Kind, err) + } + + // Trigger status aggregation after creation + updatedResource, svcErr := s.UpdateResourceStatusFromAdapters(ctx, resource.Kind, resource.ID, requiredAdapters) + if svcErr != nil { + return nil, svcErr + } + + return updatedResource, nil +} + +func (s *sqlResourceService) Replace(ctx context.Context, resource *api.Resource) (*api.Resource, *errors.ServiceError) { + resource, err := s.resourceDao.Replace(ctx, resource) + if err != nil { + return nil, handleUpdateError(resource.Kind, err) + } + return resource, nil +} + +func (s *sqlResourceService) Delete(ctx context.Context, kind, id string) *errors.ServiceError { + if err := s.resourceDao.DeleteByKindAndID(ctx, kind, id); err != nil { + return handleDeleteError(kind, errors.GeneralError("Unable to delete resource: %s", err)) + } + return nil +} + +func (s *sqlResourceService) ListByKind(ctx context.Context, kind string, offset, limit int) (api.ResourceList, int64, *errors.ServiceError) { + resources, total, err := s.resourceDao.ListByKind(ctx, kind, offset, limit) + if err != nil { + return nil, 0, errors.GeneralError("Unable to list %s resources: %s", kind, err) + } + return resources, total, nil +} + +func (s *sqlResourceService) ListByOwner(ctx context.Context, kind, ownerID string, offset, limit int) (api.ResourceList, int64, *errors.ServiceError) { + resources, total, err := s.resourceDao.ListByOwner(ctx, kind, ownerID, offset, limit) + if err != nil { + return nil, 0, errors.GeneralError("Unable to list %s resources for owner %s: %s", kind, ownerID, err) + } + return resources, total, nil +} + +func (s *sqlResourceService) OnUpsert(ctx context.Context, kind, id string) error { + resource, err := s.resourceDao.GetByKindAndID(ctx, kind, id) + if err != nil { + return err + } + + ctx = logger.WithResourceID(ctx, resource.ID) + ctx = logger.WithResourceType(ctx, resource.Kind) + logger.Info(ctx, "Perform idempotent operations on resource") + + return nil +} + +func (s *sqlResourceService) OnDelete(ctx context.Context, kind, id string) error { + ctx = logger.WithResourceID(ctx, id) + ctx = logger.WithResourceType(ctx, kind) + logger.Info(ctx, "Resource has been deleted") + return nil +} + +// UpdateResourceStatusFromAdapters aggregates adapter statuses into resource status. +// It reuses the existing BuildSyntheticConditions logic. +func (s *sqlResourceService) UpdateResourceStatusFromAdapters( + ctx context.Context, kind, resourceID string, requiredAdapters []string, +) (*api.Resource, *errors.ServiceError) { + // Get the resource + resource, err := s.resourceDao.GetByKindAndID(ctx, kind, resourceID) + if err != nil { + return nil, handleGetError(kind, "id", resourceID, err) + } + + // Get all adapter statuses for this resource + adapterStatuses, err := s.adapterStatusDao.FindByResource(ctx, kind, resourceID) + if err != nil { + return nil, errors.GeneralError("Failed to get adapter statuses: %s", err) + } + + now := time.Now() + + // Build the list of adapter ResourceConditions + adapterConditions := []api.ResourceCondition{} + + for _, adapterStatus := range adapterStatuses { + // Unmarshal Conditions from JSONB + var conditions []api.AdapterCondition + if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil { + continue // Skip if can't unmarshal + } + + // Find the "Available" condition + var availableCondition *api.AdapterCondition + for i := range conditions { + if conditions[i].Type == "Available" { + availableCondition = &conditions[i] + break + } + } + + if availableCondition == nil { + // No Available condition, skip this adapter + continue + } + + // Convert to ResourceCondition + condResource := api.ResourceCondition{ + Type: MapAdapterToConditionType(adapterStatus.Adapter), + Status: api.ResourceConditionStatus(availableCondition.Status), + Reason: availableCondition.Reason, + Message: availableCondition.Message, + ObservedGeneration: adapterStatus.ObservedGeneration, + LastTransitionTime: availableCondition.LastTransitionTime, + } + + // Set CreatedTime with nil check + if adapterStatus.CreatedTime != nil { + condResource.CreatedTime = *adapterStatus.CreatedTime + } + + // Set LastUpdatedTime with nil check + if adapterStatus.LastReportTime != nil { + condResource.LastUpdatedTime = *adapterStatus.LastReportTime + } + + adapterConditions = append(adapterConditions, condResource) + } + + // Compute synthetic Available and Ready conditions + availableCondition, readyCondition := BuildSyntheticConditions( + resource.StatusConditions, + adapterStatuses, + requiredAdapters, + resource.Generation, + now, + ) + + // Combine synthetic conditions with adapter conditions + // Put Available and Ready first + allConditions := []api.ResourceCondition{availableCondition, readyCondition} + allConditions = append(allConditions, adapterConditions...) + + // Marshal conditions to JSON + conditionsJSON, err := json.Marshal(allConditions) + if err != nil { + return nil, errors.GeneralError("Failed to marshal conditions: %s", err) + } + resource.StatusConditions = conditionsJSON + + // Save the updated resource + resource, err = s.resourceDao.Replace(ctx, resource) + if err != nil { + return nil, handleUpdateError(kind, err) + } + + return resource, nil +} + +// ProcessAdapterStatus handles the business logic for adapter status. +// If Available condition is "Unknown", returns (nil, nil) indicating no-op. +// Otherwise, upserts the status and triggers aggregation. +func (s *sqlResourceService) ProcessAdapterStatus( + ctx context.Context, kind, resourceID string, adapterStatus *api.AdapterStatus, requiredAdapters []string, +) (*api.AdapterStatus, *errors.ServiceError) { + existingStatus, findErr := s.adapterStatusDao.FindByResourceAndAdapter( + ctx, kind, resourceID, adapterStatus.Adapter, + ) + if findErr != nil && !stderrors.Is(findErr, gorm.ErrRecordNotFound) { + if !strings.Contains(findErr.Error(), errors.CodeNotFoundGeneric) { + return nil, errors.GeneralError("Failed to get adapter status: %s", findErr) + } + } + if existingStatus != nil && adapterStatus.ObservedGeneration < existingStatus.ObservedGeneration { + // Discard stale status updates (older observed_generation) + return nil, nil + } + + // Parse conditions from the adapter status + var conditions []api.AdapterCondition + if len(adapterStatus.Conditions) > 0 { + if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil { + return nil, errors.GeneralError("Failed to unmarshal adapter status conditions: %s", err) + } + } + + // Find the "Available" condition + hasAvailableCondition := false + for _, cond := range conditions { + if cond.Type != "Available" { + continue + } + + hasAvailableCondition = true + if cond.Status == api.AdapterConditionUnknown { + // Available condition is "Unknown", return nil to indicate no-op + return nil, nil + } + } + + // Upsert the adapter status + upsertedStatus, err := s.adapterStatusDao.Upsert(ctx, adapterStatus) + if err != nil { + return nil, handleCreateError("AdapterStatus", err) + } + + // Only trigger aggregation when the adapter reported an Available condition + if hasAvailableCondition { + if _, aggregateErr := s.UpdateResourceStatusFromAdapters( + ctx, kind, resourceID, requiredAdapters, + ); aggregateErr != nil { + // Log error but don't fail the request - the status will be computed on next update + ctx = logger.WithResourceID(ctx, resourceID) + ctx = logger.WithResourceType(ctx, kind) + logger.WithError(ctx, aggregateErr).Warn("Failed to aggregate resource status") + } + } + + return upsertedStatus, nil +} diff --git a/plugins/resources/plugin.go b/plugins/resources/plugin.go new file mode 100644 index 0000000..b562f0c --- /dev/null +++ b/plugins/resources/plugin.go @@ -0,0 +1,163 @@ +// Package resources provides a dynamic plugin that loads CRD definitions and registers routes. +// Adding a new resource type only requires adding a YAML file to the config/crds directory. +package resources + +import ( + "net/http" + "os" + + "github.com/gorilla/mux" + "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" + "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments/registry" + "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/server" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/crd" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/handlers" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" +) + +const ( + // DefaultCRDPath is the default path for CRD YAML files + DefaultCRDPath = "config/crds" + // CRDPathEnvVar is the environment variable to override the CRD path + CRDPathEnvVar = "CRD_CONFIG_PATH" +) + +// ServiceLocator creates a ResourceService instance +type ServiceLocator func() services.ResourceService + +// NewServiceLocator creates a new service locator for resources +func NewServiceLocator(env *environments.Env) ServiceLocator { + return func() services.ResourceService { + return services.NewResourceService( + dao.NewResourceDao(&env.Database.SessionFactory), + dao.NewAdapterStatusDao(&env.Database.SessionFactory), + ) + } +} + +// Service retrieves the ResourceService from the services registry +func Service(s *environments.Services) services.ResourceService { + if s == nil { + return nil + } + if obj := s.GetService("Resources"); obj != nil { + locator := obj.(ServiceLocator) + return locator() + } + return nil +} + +// getCRDPath returns the path to CRD configuration files +func getCRDPath() string { + if path := os.Getenv(CRDPathEnvVar); path != "" { + return path + } + return DefaultCRDPath +} + +func init() { + // Load CRDs from filesystem + crdPath := getCRDPath() + if err := crd.LoadFromDirectory(crdPath); err != nil { + // Log warning but don't fail - CRDs might not be present in all environments + logger.With(nil, "crd_path", crdPath).Info( + "CRD directory not found or failed to load, generic resource API disabled") + } else { + logger.With(nil, "crd_count", crd.Default().Count()).Info( + "Loaded CRD definitions") + } + + // Service registration + registry.RegisterService("Resources", func(env interface{}) interface{} { + return NewServiceLocator(env.(*environments.Env)) + }) + + // Dynamic route registration based on loaded CRDs + server.RegisterRoutes("resources", func( + apiV1Router *mux.Router, + services server.ServicesInterface, + authMiddleware auth.JWTMiddleware, + authzMiddleware auth.AuthorizationMiddleware, + ) { + envServices := services.(*environments.Services) + resourceService := Service(envServices) + + if resourceService == nil { + return + } + + // Register routes for each enabled CRD + for _, def := range crd.All() { + registerResourceRoutes(apiV1Router, def, resourceService, authMiddleware, authzMiddleware) + } + }) + + // Presenter registration for Resource type + presenters.RegisterPath(api.Resource{}, "resources") + presenters.RegisterPath(&api.Resource{}, "resources") + presenters.RegisterKind(api.Resource{}, "Resource") + presenters.RegisterKind(&api.Resource{}, "Resource") +} + +// registerResourceRoutes registers HTTP routes for a single CRD definition +func registerResourceRoutes( + apiV1Router *mux.Router, + def *api.ResourceDefinition, + resourceService services.ResourceService, + authMiddleware auth.JWTMiddleware, + authzMiddleware auth.AuthorizationMiddleware, +) { + handlerCfg := handlers.ResourceHandlerConfig{ + Kind: def.Kind, + Plural: def.Plural, + IsOwned: def.IsOwned(), + OwnerKind: def.GetOwnerKind(), + OwnerPathParam: def.GetOwnerPathParam(), + RequiredAdapters: def.StatusConfig.RequiredAdapters, + } + + handler := handlers.NewResourceHandler(resourceService, handlerCfg) + + var router *mux.Router + + if def.IsOwned() { + // Owned resources are nested under their owner + // e.g., /clusters/{cluster_id}/nodepools + ownerPlural := getOwnerPlural(def.GetOwnerKind()) + pathPrefix := "/" + ownerPlural + "/{" + def.GetOwnerPathParam() + "}/" + def.Plural + router = apiV1Router.PathPrefix(pathPrefix).Subrouter() + } else { + // Root resources at top level + // e.g., /clusters + router = apiV1Router.PathPrefix("/" + def.Plural).Subrouter() + } + + // Register standard CRUD routes + router.HandleFunc("", handler.List).Methods(http.MethodGet) + router.HandleFunc("", handler.Create).Methods(http.MethodPost) + router.HandleFunc("/{id}", handler.Get).Methods(http.MethodGet) + router.HandleFunc("/{id}", handler.Patch).Methods(http.MethodPatch) + router.HandleFunc("/{id}", handler.Delete).Methods(http.MethodDelete) + + // Apply authentication and authorization middleware + router.Use(authMiddleware.AuthenticateAccountJWT) + router.Use(authzMiddleware.AuthorizeApi) + + logger.With(nil, "kind", def.Kind).Info( + "Registered routes for resource type") +} + +// getOwnerPlural returns the plural form of an owner kind. +// This looks up the CRD definition for the owner to get its plural. +func getOwnerPlural(kind string) string { + if def, found := crd.GetByKind(kind); found { + return def.Plural + } + // Fallback: lowercase + "s" + return kind + "s" +} diff --git a/scripts/test-api.sh b/scripts/test-api.sh new file mode 100755 index 0000000..ef2069f --- /dev/null +++ b/scripts/test-api.sh @@ -0,0 +1,140 @@ +#!/bin/bash +# Test script for HyperFleet API +# Usage: ./scripts/test-api.sh [API_URL] + +set -e + +API_URL="${1:-http://localhost:8000}" +API_BASE="$API_URL/api/hyperfleet/v1" + +echo "=== HyperFleet API Test Script ===" +echo "API URL: $API_BASE" +echo "" + +# Generate unique suffix for resource names +SUFFIX=$(date +%s | tail -c 6) + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +success() { echo -e "${GREEN}✓ $1${NC}"; } +error() { echo -e "${RED}✗ $1${NC}"; exit 1; } +info() { echo -e "${YELLOW}→ $1${NC}"; } + +# Check if jq is available +if ! command -v jq &> /dev/null; then + echo "jq is required but not installed. Install with: sudo dnf install jq" + exit 1 +fi + +# 1. Create a Cluster +info "Creating cluster: test-cluster-$SUFFIX" +CLUSTER_RESPONSE=$(curl -s -X POST "$API_BASE/clusters" \ + -H "Content-Type: application/json" \ + -d "{ + \"apiVersion\": \"hyperfleet.io/v1\", + \"kind\": \"Cluster\", + \"name\": \"test-cluster-$SUFFIX\", + \"spec\": { + \"region\": \"us-east-1\", + \"version\": \"4.14\", + \"provider\": \"aws\" + } + }") + +echo "$CLUSTER_RESPONSE" | jq . + +CLUSTER_ID=$(echo "$CLUSTER_RESPONSE" | jq -r '.metadata.id // .id // empty') +if [ -z "$CLUSTER_ID" ]; then + error "Failed to create cluster or extract ID" +fi +success "Cluster created with ID: $CLUSTER_ID" +echo "" + +# 2. Get the Cluster +info "Fetching cluster..." +curl -s "$API_BASE/clusters/$CLUSTER_ID" | jq . +success "Cluster fetched" +echo "" + +# 3. Create a NodePool +info "Creating nodepool: worker-pool-$SUFFIX" +NODEPOOL_RESPONSE=$(curl -s -X POST "$API_BASE/clusters/$CLUSTER_ID/nodepools" \ + -H "Content-Type: application/json" \ + -d "{ + \"apiVersion\": \"hyperfleet.io/v1\", + \"kind\": \"NodePool\", + \"name\": \"worker-pool-$SUFFIX\", + \"spec\": { + \"replicas\": 3, + \"instanceType\": \"m5.xlarge\", + \"autoScaling\": { + \"enabled\": true, + \"minReplicas\": 1, + \"maxReplicas\": 10 + } + } + }") + +echo "$NODEPOOL_RESPONSE" | jq . + +NODEPOOL_ID=$(echo "$NODEPOOL_RESPONSE" | jq -r '.metadata.id // .id // empty') +if [ -z "$NODEPOOL_ID" ]; then + error "Failed to create nodepool or extract ID" +fi +success "NodePool created with ID: $NODEPOOL_ID" +echo "" + +# 4. Create an IDP +info "Creating IDP: corporate-sso-$SUFFIX" +IDP_RESPONSE=$(curl -s -X POST "$API_BASE/clusters/$CLUSTER_ID/idps" \ + -H "Content-Type: application/json" \ + -d "{ + \"apiVersion\": \"hyperfleet.io/v1\", + \"kind\": \"IDP\", + \"name\": \"corporate-sso-$SUFFIX\", + \"spec\": { + \"type\": \"OIDC\", + \"issuerURL\": \"https://sso.example.com\", + \"clientID\": \"hyperfleet-client\", + \"clientSecret\": \"secret-placeholder\" + } + }") + +echo "$IDP_RESPONSE" | jq . + +IDP_ID=$(echo "$IDP_RESPONSE" | jq -r '.metadata.id // .id // empty') +if [ -z "$IDP_ID" ]; then + error "Failed to create IDP or extract ID" +fi +success "IDP created with ID: $IDP_ID" +echo "" + +# 5. List all resources +info "Listing all clusters..." +curl -s "$API_BASE/clusters" | jq . +echo "" + +info "Listing nodepools for cluster $CLUSTER_ID..." +curl -s "$API_BASE/clusters/$CLUSTER_ID/nodepools" | jq . +echo "" + +info "Listing IDPs for cluster $CLUSTER_ID..." +curl -s "$API_BASE/clusters/$CLUSTER_ID/idps" | jq . +echo "" + +# 6. Summary +echo "=== Test Summary ===" +success "Cluster ID: $CLUSTER_ID" +success "NodePool ID: $NODEPOOL_ID" +success "IDP ID: $IDP_ID" +echo "" + +# 7. Cleanup prompt +echo "To clean up test resources, run:" +echo " curl -X DELETE $API_BASE/clusters/$CLUSTER_ID/idps/$IDP_ID" +echo " curl -X DELETE $API_BASE/clusters/$CLUSTER_ID/nodepools/$NODEPOOL_ID" +echo " curl -X DELETE $API_BASE/clusters/$CLUSTER_ID" From 7aa813d32514fc71c06d91c4bf7e316cef2ffc5b Mon Sep 17 00:00:00 2001 From: Ciaran Roche Date: Mon, 9 Feb 2026 12:05:33 +0000 Subject: [PATCH 2/6] Remove cluster/nodepool specific code, use CRD-driven generic resources - Remove all cluster-specific and nodepool-specific handlers, services, DAOs, presenters, and API types - Remove old database migrations for clusters/node_pools tables - Add migration to drop legacy cluster/nodepool tables - Update CRD registry to load resource definitions from Kubernetes API instead of filesystem (config/crds directory removed) - Add hyperfleet.io annotations to Helm CRDs for API configuration: - hyperfleet.io/scope: Root or Owned - hyperfleet.io/owner-kind: Parent resource kind - hyperfleet.io/owner-path-param: URL path parameter - hyperfleet.io/required-adapters: Comma-separated adapter list - hyperfleet.io/enabled: Enable/disable flag - Update RBAC to allow listing CRDs cluster-wide - Add generic test factory for Resource instances - Update Dockerfile to remove config/crds copy The generic resources table now handles all resource types dynamically based on CRD definitions installed in the Kubernetes cluster. Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 3 +- charts/crds/cluster-crd.yaml | 4 + charts/crds/idp-crd.yaml | 6 + charts/crds/nodepool-crd.yaml | 6 + charts/templates/rbac/clusterrole.yaml | 6 +- cmd/hyperfleet-api/main.go | 3 - config/crds/cluster.yaml | 21 - config/crds/idp.yaml | 24 - config/crds/nodepool.yaml | 22 - go.mod | 57 +- go.sum | 149 ++-- pkg/api/cluster_types.go | 66 -- pkg/api/cluster_types_test.go | 175 ---- pkg/api/node_pool_types.go | 82 -- pkg/api/node_pool_types_test.go | 215 ----- pkg/api/presenters/cluster.go | 118 --- pkg/api/presenters/cluster_test.go | 411 --------- pkg/api/presenters/node_pool.go | 123 --- pkg/api/presenters/node_pool_test.go | 460 ----------- pkg/crd/registry.go | 187 +++-- pkg/dao/cluster.go | 101 --- pkg/dao/mocks/cluster.go | 51 -- pkg/dao/mocks/node_pool.go | 51 -- pkg/dao/node_pool.go | 101 --- .../migrations/202511111044_add_clusters.go | 80 -- .../migrations/202511111055_add_node_pools.go | 101 --- .../202601210001_add_conditions_gin_index.go | 46 -- ...2602070001_drop_cluster_nodepool_tables.go | 63 ++ pkg/db/migrations/migration_structs.go | 11 +- pkg/handlers/cluster.go | 168 ---- pkg/handlers/cluster_nodepools.go | 177 ---- pkg/handlers/cluster_nodepools_test.go | 203 ----- pkg/handlers/cluster_status.go | 115 --- pkg/handlers/node_pool.go | 180 ---- pkg/handlers/nodepool_status.go | 116 --- pkg/handlers/resource.go | 14 +- pkg/services/cluster.go | 300 ------- pkg/services/cluster_test.go | 742 ----------------- pkg/services/generic.go | 6 - pkg/services/generic_test.go | 4 +- pkg/services/node_pool.go | 301 ------- pkg/services/node_pool_test.go | 528 ------------ plugins/clusters/plugin.go | 100 --- plugins/nodePools/plugin.go | 76 -- plugins/resources/plugin.go | 31 +- test/factories/clusters.go | 176 ---- test/factories/node_pools.go | 196 ----- test/factories/resources.go | 224 +++++ test/integration/adapter_status_test.go | 570 ------------- test/integration/clusters_test.go | 780 ------------------ test/integration/node_pools_test.go | 321 ------- test/integration/search_field_mapping_test.go | 369 --------- 52 files changed, 573 insertions(+), 7867 deletions(-) delete mode 100644 config/crds/cluster.yaml delete mode 100644 config/crds/idp.yaml delete mode 100644 config/crds/nodepool.yaml delete mode 100644 pkg/api/cluster_types.go delete mode 100644 pkg/api/cluster_types_test.go delete mode 100644 pkg/api/node_pool_types.go delete mode 100644 pkg/api/node_pool_types_test.go delete mode 100644 pkg/api/presenters/cluster.go delete mode 100644 pkg/api/presenters/cluster_test.go delete mode 100644 pkg/api/presenters/node_pool.go delete mode 100644 pkg/api/presenters/node_pool_test.go delete mode 100644 pkg/dao/cluster.go delete mode 100644 pkg/dao/mocks/cluster.go delete mode 100644 pkg/dao/mocks/node_pool.go delete mode 100644 pkg/dao/node_pool.go delete mode 100644 pkg/db/migrations/202511111044_add_clusters.go delete mode 100644 pkg/db/migrations/202511111055_add_node_pools.go delete mode 100644 pkg/db/migrations/202601210001_add_conditions_gin_index.go create mode 100644 pkg/db/migrations/202602070001_drop_cluster_nodepool_tables.go delete mode 100644 pkg/handlers/cluster.go delete mode 100644 pkg/handlers/cluster_nodepools.go delete mode 100644 pkg/handlers/cluster_nodepools_test.go delete mode 100644 pkg/handlers/cluster_status.go delete mode 100644 pkg/handlers/node_pool.go delete mode 100644 pkg/handlers/nodepool_status.go delete mode 100644 pkg/services/cluster.go delete mode 100644 pkg/services/cluster_test.go delete mode 100644 pkg/services/node_pool.go delete mode 100644 pkg/services/node_pool_test.go delete mode 100644 plugins/clusters/plugin.go delete mode 100644 plugins/nodePools/plugin.go delete mode 100644 test/factories/clusters.go delete mode 100644 test/factories/node_pools.go create mode 100644 test/factories/resources.go delete mode 100644 test/integration/adapter_status_test.go delete mode 100644 test/integration/clusters_test.go delete mode 100644 test/integration/node_pools_test.go delete mode 100644 test/integration/search_field_mapping_test.go diff --git a/Dockerfile b/Dockerfile index 3e57291..d3bcdbf 100755 --- a/Dockerfile +++ b/Dockerfile @@ -33,8 +33,7 @@ COPY --from=builder /build/bin/hyperfleet-api /app/hyperfleet-api # Copy OpenAPI schema for validation (uses the source spec, not the generated one) COPY --from=builder /build/openapi/openapi.yaml /app/openapi/openapi.yaml -# Copy CRD definitions for generic resource API -COPY --from=builder /build/config/crds /app/config/crds +# CRD definitions are now loaded from Kubernetes API at runtime # Set default schema path (can be overridden by Helm for provider-specific schemas) ENV OPENAPI_SCHEMA_PATH=/app/openapi/openapi.yaml diff --git a/charts/crds/cluster-crd.yaml b/charts/crds/cluster-crd.yaml index 7ddd26f..a78e349 100644 --- a/charts/crds/cluster-crd.yaml +++ b/charts/crds/cluster-crd.yaml @@ -5,6 +5,10 @@ metadata: labels: app.kubernetes.io/name: hyperfleet-api app.kubernetes.io/part-of: hyperfleet + annotations: + hyperfleet.io/scope: "Root" + hyperfleet.io/required-adapters: "validation,dns,pullsecret,hypershift" + hyperfleet.io/enabled: "true" spec: group: hyperfleet.io names: diff --git a/charts/crds/idp-crd.yaml b/charts/crds/idp-crd.yaml index 926e630..346888d 100644 --- a/charts/crds/idp-crd.yaml +++ b/charts/crds/idp-crd.yaml @@ -5,6 +5,12 @@ metadata: labels: app.kubernetes.io/name: hyperfleet-api app.kubernetes.io/part-of: hyperfleet + annotations: + hyperfleet.io/scope: "Owned" + hyperfleet.io/owner-kind: "Cluster" + hyperfleet.io/owner-path-param: "cluster_id" + hyperfleet.io/required-adapters: "validation" + hyperfleet.io/enabled: "true" spec: group: hyperfleet.io names: diff --git a/charts/crds/nodepool-crd.yaml b/charts/crds/nodepool-crd.yaml index 907ca02..1302cd9 100644 --- a/charts/crds/nodepool-crd.yaml +++ b/charts/crds/nodepool-crd.yaml @@ -5,6 +5,12 @@ metadata: labels: app.kubernetes.io/name: hyperfleet-api app.kubernetes.io/part-of: hyperfleet + annotations: + hyperfleet.io/scope: "Owned" + hyperfleet.io/owner-kind: "Cluster" + hyperfleet.io/owner-path-param: "cluster_id" + hyperfleet.io/required-adapters: "validation,hypershift" + hyperfleet.io/enabled: "true" spec: group: hyperfleet.io names: diff --git a/charts/templates/rbac/clusterrole.yaml b/charts/templates/rbac/clusterrole.yaml index 0b7273c..b8691bd 100644 --- a/charts/templates/rbac/clusterrole.yaml +++ b/charts/templates/rbac/clusterrole.yaml @@ -6,7 +6,7 @@ metadata: labels: {{- include "hyperfleet-api.labels" . | nindent 4 }} rules: - # Read access to CRD definitions + # Read access to CRD definitions (list requires cluster-wide access, filtering done in code) - apiGroups: - apiextensions.k8s.io resources: @@ -15,10 +15,6 @@ rules: - get - list - watch - resourceNames: - - clusters.hyperfleet.io - - nodepools.hyperfleet.io - - idps.hyperfleet.io # Read access to HyperFleet resources - apiGroups: - hyperfleet.io diff --git a/cmd/hyperfleet-api/main.go b/cmd/hyperfleet-api/main.go index 0d6aa3f..feb7f9b 100755 --- a/cmd/hyperfleet-api/main.go +++ b/cmd/hyperfleet-api/main.go @@ -13,11 +13,8 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" // Import plugins to trigger their init() functions - // _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/events" // REMOVED: Events plugin no longer exists _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/adapterStatus" - _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/clusters" _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/generic" - _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/nodePools" _ "github.com/openshift-hyperfleet/hyperfleet-api/plugins/resources" // Generic CRD-driven resource API ) diff --git a/config/crds/cluster.yaml b/config/crds/cluster.yaml deleted file mode 100644 index 798baa6..0000000 --- a/config/crds/cluster.yaml +++ /dev/null @@ -1,21 +0,0 @@ -# Cluster Resource Definition -# This defines a root-level Cluster resource for the generic resource API. -# Adding this file enables the following routes: -# GET /api/hyperfleet/v1/clusters -# POST /api/hyperfleet/v1/clusters -# GET /api/hyperfleet/v1/clusters/{id} -# PATCH /api/hyperfleet/v1/clusters/{id} -# DELETE /api/hyperfleet/v1/clusters/{id} - -apiVersion: hyperfleet.io/v1 -kind: Cluster -plural: clusters -singular: cluster -scope: Root -statusConfig: - requiredAdapters: - - validation - - dns - - pullsecret - - hypershift -enabled: true diff --git a/config/crds/idp.yaml b/config/crds/idp.yaml deleted file mode 100644 index 38e8b7d..0000000 --- a/config/crds/idp.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# IDP (Identity Provider) Resource Definition -# This is an example of how easy it is to add a new resource type. -# Simply create this YAML file and restart the service - routes are automatically registered. -# -# This enables the following routes: -# GET /api/hyperfleet/v1/clusters/{cluster_id}/idps -# POST /api/hyperfleet/v1/clusters/{cluster_id}/idps -# GET /api/hyperfleet/v1/clusters/{cluster_id}/idps/{id} -# PATCH /api/hyperfleet/v1/clusters/{cluster_id}/idps/{id} -# DELETE /api/hyperfleet/v1/clusters/{cluster_id}/idps/{id} - -apiVersion: hyperfleet.io/v1 -kind: IDP -plural: idps -singular: idp -scope: Owned -owner: - kind: Cluster - pathParam: cluster_id -statusConfig: - requiredAdapters: - - validation - - idp-controller -enabled: true diff --git a/config/crds/nodepool.yaml b/config/crds/nodepool.yaml deleted file mode 100644 index 3a82eda..0000000 --- a/config/crds/nodepool.yaml +++ /dev/null @@ -1,22 +0,0 @@ -# NodePool Resource Definition -# This defines an owned resource under Cluster for the generic resource API. -# Adding this file enables the following routes: -# GET /api/hyperfleet/v1/clusters/{cluster_id}/nodepools -# POST /api/hyperfleet/v1/clusters/{cluster_id}/nodepools -# GET /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{id} -# PATCH /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{id} -# DELETE /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{id} - -apiVersion: hyperfleet.io/v1 -kind: NodePool -plural: nodepools -singular: nodepool -scope: Owned -owner: - kind: Cluster - pathParam: cluster_id -statusConfig: - requiredAdapters: - - validation - - hypershift -enabled: true diff --git a/go.mod b/go.mod index 3702583..64ac42a 100755 --- a/go.mod +++ b/go.mod @@ -1,17 +1,13 @@ module github.com/openshift-hyperfleet/hyperfleet-api -go 1.24.0 - -toolchain go1.24.9 +go 1.25.0 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/Masterminds/squirrel v1.1.0 github.com/auth0/go-jwt-middleware v0.0.0-20190805220309-36081240882b github.com/bxcodec/faker/v3 v3.2.0 - github.com/docker/go-healthcheck v0.1.0 github.com/getkin/kin-openapi v0.133.0 - github.com/ghodss/yaml v1.0.0 github.com/go-gormigrate/gormigrate/v2 v2.0.0 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/google/uuid v1.6.0 @@ -21,12 +17,12 @@ require ( github.com/lib/pq v1.10.9 github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103 github.com/oapi-codegen/runtime v1.1.2 - github.com/onsi/gomega v1.27.1 + github.com/onsi/gomega v1.38.2 github.com/openshift-online/ocm-sdk-go v0.1.334 - github.com/prometheus/client_golang v1.16.0 + github.com/prometheus/client_golang v1.23.2 github.com/segmentio/ksuid v1.0.4 - github.com/spf13/cobra v0.0.5 - github.com/spf13/pflag v1.0.5 + github.com/spf13/cobra v1.10.0 + github.com/spf13/pflag v1.0.9 github.com/testcontainers/testcontainers-go v0.33.0 github.com/testcontainers/testcontainers-go/modules/postgres v0.33.0 github.com/yaacov/tree-search-language v0.0.0-20190923184055-1c2dad2e354b @@ -40,42 +36,48 @@ require ( gorm.io/datatypes v1.2.7 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.30.0 + k8s.io/apiextensions-apiserver v0.35.0 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 ) require ( dario.cat/mergo v1.0.2 // indirect filippo.io/edwards25519 v1.1.0 // indirect - github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/antlr/antlr4 v0.0.0-20190518164840-edae2a1c9b4b // indirect github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // 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.1 // indirect github.com/dgrijalva/jwt-go v3.2.0+incompatible // indirect github.com/distribution/reference v0.6.0 // indirect - github.com/docker/distribution v2.8.1+incompatible // indirect github.com/docker/docker v28.5.1+incompatible // indirect github.com/docker/go-connections v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect github.com/go-openapi/swag v0.23.0 // indirect github.com/go-sql-driver/mysql v1.8.1 // indirect github.com/golang/glog v1.2.5 // indirect - github.com/golang/protobuf v1.5.4 // indirect + github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/gorilla/css v1.0.0 // indirect - github.com/inconshreveable/mousetrap v1.0.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -89,7 +91,6 @@ require ( github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mailru/easyjson v0.7.7 // indirect - github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/microcosm-cc/bluemonday v1.0.25 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.1.0 // indirect @@ -100,9 +101,10 @@ require ( 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.2 // 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/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 @@ -110,9 +112,9 @@ require ( github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect - github.com/prometheus/client_model v0.3.0 // indirect - github.com/prometheus/common v0.42.0 // indirect - github.com/prometheus/procfs v0.10.1 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/shirou/gopsutil/v3 v3.23.12 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/sirupsen/logrus v1.9.3 // indirect @@ -121,21 +123,34 @@ require ( github.com/tklauser/numcpus v0.6.1 // indirect github.com/urfave/negroni v1.0.0 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.45.0 // indirect golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gorm.io/driver/mysql v1.5.6 // indirect + k8s.io/api v0.35.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index abbab1c..f4db500 100755 --- a/go.sum +++ b/go.sum @@ -39,13 +39,15 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 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-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= -github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs= +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/Masterminds/squirrel v1.1.0 h1:baP1qLdoQCeTw3ifCdOq2dkYc6vGcmRdaociKLbEJXs= github.com/Masterminds/squirrel v1.1.0/go.mod h1:yaPeOnPG5ZRwL9oKdTsO/prlkPbXWZlRVMQ/gGlzIuA= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= @@ -65,7 +67,6 @@ github.com/antlr/antlr4 v0.0.0-20190518164840-edae2a1c9b4b/go.mod h1:T7PbCXFs94r github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= -github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/auth0/go-jwt-middleware v0.0.0-20190805220309-36081240882b h1:CvoEHGmxWl5kONC5icxwqV899dkf4VjOScbxLpllEnw= github.com/auth0/go-jwt-middleware v0.0.0-20190805220309-36081240882b/go.mod h1:LWMyo4iOLWXHGdBki7NIht1kHru/0wM179h+d3g8ATM= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= @@ -78,8 +79,8 @@ github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvF github.com/bxcodec/faker/v3 v3.2.0 h1:L3cTa9Tptyk0jsF/R6RooDZwxwA8dDi6IWdkIu8jwKo= github.com/bxcodec/faker/v3 v3.2.0/go.mod h1:gF31YgnMSMKgkvl+fyEo1xuSMbEuieyqfeslGYFjneM= github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -101,15 +102,13 @@ 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/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE= -github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk= -github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk= github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= 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 v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY= +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= @@ -122,19 +121,17 @@ github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumC github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= 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/distribution v2.8.1+incompatible h1:Q50tZOPR6T/hjNsyc9g8/syEs6bk8XXApsHjKukMl68= -github.com/docker/distribution v2.8.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= github.com/docker/docker v28.5.1+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-healthcheck v0.1.0 h1:6ZrRr63F5LLsPwSlbZgjgoxNu+o1VlMIhCQWgbfrgU0= -github.com/docker/go-healthcheck v0.1.0/go.mod h1:3v7a0338vhH6WnYFtUd66S+9QK3M6xK4sKr7gGrht6o= 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/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= github.com/eapache/queue v1.1.0/go.mod h1:6eCeP0CKFpHLu8blIFXhExK/dRa7WDZfr6jVFPTqq+I= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -147,10 +144,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +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/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= -github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= -github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -169,8 +166,12 @@ 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-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.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= github.com/go-sql-driver/mysql v1.4.1/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= @@ -179,7 +180,10 @@ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9 github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +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/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -224,12 +228,12 @@ github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +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.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -255,6 +259,8 @@ github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -276,13 +282,12 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnV github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= -github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hokaccha/go-prettyjson v0.0.0-20180920040306-f579f869bbfe/go.mod h1:pFlLw2CfqZiIBOx6BuCeRLCrfxBJipTY0nIOF/VbGcI= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= -github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= -github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= +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.7 h1:hYPTpeWfrJ1OT+2j6cvBScbhl0TkdwGM4bc66onUSOQ= github.com/itchyny/gojq v0.12.7/go.mod h1:ZdvNHVlzPgUf8pgjnuDTmGfHA/21KoutQUJ3An/xNuw= github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU= @@ -392,6 +397,7 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxv github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 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= @@ -400,6 +406,8 @@ github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= 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/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= @@ -415,7 +423,6 @@ github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= -github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= 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.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= @@ -435,8 +442,6 @@ github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71 github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= -github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= -github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103 h1:Z/i1e+gTZrmcGeZyWckaLfucYG6KYOXLWo4co8pZYNY= github.com/mendsley/gojwk v0.0.0-20141217222730-4d5ec6e58103/go.mod h1:o9YPB5aGP8ob35Vy6+vyq3P3bWe7NQWzf+JLiXCiMaE= github.com/microcosm-cc/bluemonday v1.0.18/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM= @@ -444,8 +449,6 @@ github.com/microcosm-cc/bluemonday v1.0.25 h1:4NEwSfiJ+Wva0VxN5B8OwMicaJvD8r9tlJ github.com/microcosm-cc/bluemonday v1.0.25/go.mod h1:ZIOjCQp1OrzBBPIJmfX4qDYFuhU02nx4bn030ixfHLE= github.com/microsoft/go-mssqldb v1.7.2 h1:CHkFJiObW7ItKTJfHo1QX7QBBD1iV+mn1eOyRP3b/PA= github.com/microsoft/go-mssqldb v1.7.2/go.mod h1:kOvZKUdrhhFQmxLZqbwUV0rHkNkZpthMITIb2Ko1IoA= -github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= 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= @@ -467,12 +470,15 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +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/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= @@ -491,16 +497,16 @@ github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= -github.com/onsi/ginkgo/v2 v2.8.1 h1:xFTEVwOFa1D/Ty24Ws1npBWkDYEV9BqZrsDxVrVkrrU= -github.com/onsi/ginkgo/v2 v2.8.1/go.mod h1:N1/NbDngAFcSLdyZ+/aYTYGSlq9qMCS/cNKGJjy+csc= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.27.1 h1:rfztXRbg6nv/5f+Raen9RcGoSecHIFgBBLQK3Wdj754= -github.com/onsi/gomega v1.27.1/go.mod h1:aHX5xOykVYzWOV4WqQy0sy8BQptgukenXpCXfadcIAw= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= 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= @@ -508,7 +514,6 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/openshift-online/ocm-sdk-go v0.1.334 h1:45WSkXEsmpGekMa9kO6NpEG8PW5/gfmMekr7kL+1KvQ= github.com/openshift-online/ocm-sdk-go v0.1.334/go.mod h1:KYOw8kAKAHyPrJcQoVR82CneQ4ofC02Na4cXXaTq4Nw= github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= -github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY= @@ -526,30 +531,30 @@ github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5Fsn github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.0/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= github.com/prometheus/client_golang v1.12.1/go.mod h1:3Z9XVyYiZYEO+YQWt3RD2R3jrbd179Rt297l4aS6nDY= -github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= -github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +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.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190115171406-56726106282f/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= -github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +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.2.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.32.1/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= -github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190117184657-bf6a532e95b1/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= -github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rcrowley/go-metrics v0.0.0-20181016184325-3113b8401b8a/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -557,7 +562,7 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= -github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= @@ -580,21 +585,19 @@ github.com/smarty/assertions v1.15.0 h1:cR//PqUBUiQRakZWqBiFFQ9wb8emQGDb0HeGdqGB github.com/smarty/assertions v1.15.0/go.mod h1:yABtdzeQs6l1brC900WlRNwj6ZR55d7B+E8C6HtKdec= github.com/smartystreets/goconvey v1.8.1 h1:qGjIddxOk4grTu9JPOU31tVfq3cNdBlNa5sSznIX1xY= github.com/smartystreets/goconvey v1.8.1/go.mod h1:+/u4qLyY6x1jReYOp7GOM2FSt8aP9CzCZL03bI28W60= -github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= -github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE= -github.com/spf13/cobra v0.0.5 h1:f0B+LkLX6DtmRH1isoNA9VTtNUK9K8xYd28JNNfOv/s= -github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU= -github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo= -github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s= +github.com/spf13/cobra v1.10.0 h1:a5/WeUlSDCvV5a45ljW2ZFtV0bTDpkfSAj3uqB6Sc+0= +github.com/spf13/cobra v1.10.0/go.mod h1:9dhySC7dnTtEiqzmqfkLj47BslqLCUPMXjG2lj/NgoE= +github.com/spf13/pflag v1.0.8/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= 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.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= 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= @@ -602,6 +605,7 @@ github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5 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.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -614,16 +618,16 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA 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/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= 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/xdg/scram v0.0.0-20180814205039-7eeb5667e42c/go.mod h1:lB8K/P019DLNhemzwFU4jHLhdvlE6uDZjXFejJXr49I= github.com/xdg/stringprep v1.0.0/go.mod h1:Jhud4/sHMO4oL310DaZAKk9ZaJ08SJfe+sJh0HrGL1Y= -github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q= github.com/yaacov/tree-search-language v0.0.0-20190923184055-1c2dad2e354b h1:aWR0+NlUGQpFPxpjcYW7oXsN1GnYUVIdB5Act7I6jzc= github.com/yaacov/tree-search-language v0.0.0-20190923184055-1c2dad2e354b/go.mod h1:uXZEzDS1siuQsBuHL1A4gy27xIsnnL06MhqrwvySsIk= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -679,10 +683,11 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM= -go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= -go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= +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= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/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-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE= @@ -734,6 +739,8 @@ golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzB golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -787,6 +794,8 @@ golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -805,7 +814,6 @@ golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20181122145206-62eef0e2fa9b/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= -golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/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-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -884,8 +892,8 @@ golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -936,6 +944,8 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1040,8 +1050,12 @@ gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8 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/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/resty.v1 v1.12.0 h1:CuXP0Pjfw9rOuY6EP+UvtNvt5DSqHpIxILZKT/quCZI= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= @@ -1050,7 +1064,6 @@ 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.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -1086,8 +1099,28 @@ honnef.co/go/tools v0.0.0-20190531162725-42df64e2171a/go.mod h1:wtc9q0E9zm8PjdRM honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +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/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +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/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= +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/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/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= 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/pkg/api/cluster_types.go b/pkg/api/cluster_types.go deleted file mode 100644 index 8d734f7..0000000 --- a/pkg/api/cluster_types.go +++ /dev/null @@ -1,66 +0,0 @@ -package api - -import ( - "time" - - "gorm.io/datatypes" - "gorm.io/gorm" -) - -// Cluster database model -type Cluster struct { - Meta // Contains ID, CreatedTime, UpdatedTime, DeletedTime - - // Core fields - Kind string `json:"kind" gorm:"default:'Cluster'"` - Name string `json:"name" gorm:"uniqueIndex;size:63;not null"` - Spec datatypes.JSON `json:"spec" gorm:"type:jsonb;not null"` - Labels datatypes.JSON `json:"labels,omitempty" gorm:"type:jsonb"` - Href string `json:"href,omitempty" gorm:"size:500"` - - // Version control - Generation int32 `json:"generation" gorm:"default:1;not null"` - - // Status (conditions-only model with synthetic Available/Ready conditions) - StatusConditions datatypes.JSON `json:"status_conditions" gorm:"type:jsonb"` - - // Audit fields - CreatedBy string `json:"created_by" gorm:"size:255;not null"` - UpdatedBy string `json:"updated_by" gorm:"size:255;not null"` -} - -type ClusterList []*Cluster -type ClusterIndex map[string]*Cluster - -func (l ClusterList) Index() ClusterIndex { - index := ClusterIndex{} - for _, o := range l { - index[o.ID] = o - } - return index -} - -func (c *Cluster) BeforeCreate(tx *gorm.DB) error { - now := time.Now() - c.ID = NewID() - c.CreatedTime = now - c.UpdatedTime = now - if c.Generation == 0 { - c.Generation = 1 - } - // Set Href if not already set - if c.Href == "" { - c.Href = "/api/hyperfleet/v1/clusters/" + c.ID - } - return nil -} - -func (c *Cluster) BeforeUpdate(tx *gorm.DB) error { - c.UpdatedTime = time.Now() - return nil -} - -type ClusterPatchRequest struct { - Spec *map[string]interface{} `json:"spec,omitempty"` - Labels *map[string]string `json:"labels,omitempty"` -} diff --git a/pkg/api/cluster_types_test.go b/pkg/api/cluster_types_test.go deleted file mode 100644 index a5cbd8a..0000000 --- a/pkg/api/cluster_types_test.go +++ /dev/null @@ -1,175 +0,0 @@ -package api - -import ( - "testing" - - . "github.com/onsi/gomega" -) - -// TestClusterList_Index tests the Index() method for ClusterList -func TestClusterList_Index(t *testing.T) { - RegisterTestingT(t) - - // Test empty list - emptyList := ClusterList{} - emptyIndex := emptyList.Index() - Expect(len(emptyIndex)).To(Equal(0)) - - // Test single cluster - cluster1 := &Cluster{} - cluster1.ID = "cluster-1" - cluster1.Name = "test-cluster-1" - - singleList := ClusterList{cluster1} - singleIndex := singleList.Index() - Expect(len(singleIndex)).To(Equal(1)) - Expect(singleIndex["cluster-1"]).To(Equal(cluster1)) - - // Test multiple clusters - cluster2 := &Cluster{} - cluster2.ID = "cluster-2" - cluster2.Name = "test-cluster-2" - - cluster3 := &Cluster{} - cluster3.ID = "cluster-3" - cluster3.Name = "test-cluster-3" - - multiList := ClusterList{cluster1, cluster2, cluster3} - multiIndex := multiList.Index() - Expect(len(multiIndex)).To(Equal(3)) - Expect(multiIndex["cluster-1"]).To(Equal(cluster1)) - Expect(multiIndex["cluster-2"]).To(Equal(cluster2)) - Expect(multiIndex["cluster-3"]).To(Equal(cluster3)) - - // Test duplicate IDs (later one overwrites earlier one) - cluster1Duplicate := &Cluster{} - cluster1Duplicate.ID = "cluster-1" - cluster1Duplicate.Name = "duplicate-cluster" - - duplicateList := ClusterList{cluster1, cluster1Duplicate} - duplicateIndex := duplicateList.Index() - Expect(len(duplicateIndex)).To(Equal(1)) - Expect(duplicateIndex["cluster-1"]).To(Equal(cluster1Duplicate)) - Expect(duplicateIndex["cluster-1"].Name).To(Equal("duplicate-cluster")) -} - -// TestCluster_BeforeCreate_IDGeneration tests ID auto-generation -func TestCluster_BeforeCreate_IDGeneration(t *testing.T) { - RegisterTestingT(t) - - cluster := &Cluster{ - Name: "test-cluster", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.ID).ToNot(BeEmpty()) - Expect(len(cluster.ID)).To(BeNumerically(">", 0)) -} - -// TestCluster_BeforeCreate_KindPreservation tests Kind is preserved (not auto-set) -func TestCluster_BeforeCreate_KindPreservation(t *testing.T) { - RegisterTestingT(t) - - // Kind must be set before BeforeCreate (by handler validation) - cluster := &Cluster{ - Name: "test-cluster", - Kind: "Cluster", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Kind).To(Equal("Cluster")) -} - -// TestCluster_BeforeCreate_KindPreserved tests Kind is not overwritten -func TestCluster_BeforeCreate_KindPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test Kind preservation - cluster := &Cluster{ - Name: "test-cluster", - Kind: "CustomCluster", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Kind).To(Equal("CustomCluster")) -} - -// TestCluster_BeforeCreate_GenerationDefault tests Generation default value -func TestCluster_BeforeCreate_GenerationDefault(t *testing.T) { - RegisterTestingT(t) - - // Test default Generation - cluster := &Cluster{ - Name: "test-cluster", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Generation).To(Equal(int32(1))) -} - -// TestCluster_BeforeCreate_GenerationPreserved tests Generation is not overwritten -func TestCluster_BeforeCreate_GenerationPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test Generation preservation - cluster := &Cluster{ - Name: "test-cluster", - Generation: 5, - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Generation).To(Equal(int32(5))) -} - -// TestCluster_BeforeCreate_HrefGeneration tests Href auto-generation -func TestCluster_BeforeCreate_HrefGeneration(t *testing.T) { - RegisterTestingT(t) - - // Test Href generation - cluster := &Cluster{ - Name: "test-cluster", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Href).To(Equal("/api/hyperfleet/v1/clusters/" + cluster.ID)) -} - -// TestCluster_BeforeCreate_HrefPreserved tests Href is not overwritten -func TestCluster_BeforeCreate_HrefPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test Href preservation - cluster := &Cluster{ - Name: "test-cluster", - Href: "/custom/href", - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(cluster.Href).To(Equal("/custom/href")) -} - -// TestCluster_BeforeCreate_Complete tests all defaults set together -func TestCluster_BeforeCreate_Complete(t *testing.T) { - RegisterTestingT(t) - - cluster := &Cluster{ - Name: "test-cluster", - Kind: "Cluster", // Kind must be set before BeforeCreate - } - - err := cluster.BeforeCreate(nil) - Expect(err).To(BeNil()) - - // Verify all defaults - Expect(cluster.ID).ToNot(BeEmpty()) - Expect(cluster.Kind).To(Equal("Cluster")) // Kind is preserved, not auto-set - Expect(cluster.Generation).To(Equal(int32(1))) - Expect(cluster.Href).To(Equal("/api/hyperfleet/v1/clusters/" + cluster.ID)) -} diff --git a/pkg/api/node_pool_types.go b/pkg/api/node_pool_types.go deleted file mode 100644 index 64af77d..0000000 --- a/pkg/api/node_pool_types.go +++ /dev/null @@ -1,82 +0,0 @@ -package api - -import ( - "fmt" - "time" - - "gorm.io/datatypes" - "gorm.io/gorm" -) - -// NodePool database model -type NodePool struct { - Meta // Contains ID, CreatedTime, UpdatedTime, DeletedAt - - // Core fields - Kind string `json:"kind" gorm:"default:'NodePool'"` - Name string `json:"name" gorm:"size:255;not null"` - Spec datatypes.JSON `json:"spec" gorm:"type:jsonb;not null"` - Labels datatypes.JSON `json:"labels,omitempty" gorm:"type:jsonb"` - Href string `json:"href,omitempty" gorm:"size:500"` - - // Version control - Generation int32 `json:"generation" gorm:"default:1;not null"` - - // Owner references (expanded) - OwnerID string `json:"owner_id" gorm:"size:255;not null;index"` - OwnerKind string `json:"owner_kind" gorm:"size:50;not null"` - OwnerHref string `json:"owner_href,omitempty" gorm:"size:500"` - - // Foreign key relationship - Cluster *Cluster `gorm:"foreignKey:OwnerID;references:ID"` - - // Status (conditions-only model with synthetic Available/Ready conditions) - StatusConditions datatypes.JSON `json:"status_conditions" gorm:"type:jsonb"` - - // Audit fields - CreatedBy string `json:"created_by" gorm:"size:255;not null"` - UpdatedBy string `json:"updated_by" gorm:"size:255;not null"` -} - -type NodePoolList []*NodePool -type NodePoolIndex map[string]*NodePool - -func (l NodePoolList) Index() NodePoolIndex { - index := NodePoolIndex{} - for _, o := range l { - index[o.ID] = o - } - return index -} - -func (np *NodePool) BeforeCreate(tx *gorm.DB) error { - now := time.Now() - np.ID = NewID() - np.CreatedTime = now - np.UpdatedTime = now - if np.Generation == 0 { - np.Generation = 1 - } - if np.OwnerKind == "" { - np.OwnerKind = "Cluster" - } - // Set Href if not already set - if np.Href == "" { - np.Href = fmt.Sprintf("/api/hyperfleet/v1/clusters/%s/nodepools/%s", np.OwnerID, np.ID) - } - // Set OwnerHref if not already set - if np.OwnerHref == "" { - np.OwnerHref = "/api/hyperfleet/v1/clusters/" + np.OwnerID - } - return nil -} - -func (np *NodePool) BeforeUpdate(tx *gorm.DB) error { - np.UpdatedTime = time.Now() - return nil -} - -type NodePoolPatchRequest struct { - Spec *map[string]interface{} `json:"spec,omitempty"` - Labels *map[string]string `json:"labels,omitempty"` -} diff --git a/pkg/api/node_pool_types_test.go b/pkg/api/node_pool_types_test.go deleted file mode 100644 index 97f23c4..0000000 --- a/pkg/api/node_pool_types_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package api - -import ( - "testing" - - . "github.com/onsi/gomega" -) - -// TestNodePoolList_Index tests the Index() method for NodePoolList -func TestNodePoolList_Index(t *testing.T) { - RegisterTestingT(t) - - // Test empty list - emptyList := NodePoolList{} - emptyIndex := emptyList.Index() - Expect(len(emptyIndex)).To(Equal(0)) - - // Test single nodepool - nodepool1 := &NodePool{} - nodepool1.ID = "nodepool-1" - nodepool1.Name = "test-nodepool-1" - - singleList := NodePoolList{nodepool1} - singleIndex := singleList.Index() - Expect(len(singleIndex)).To(Equal(1)) - Expect(singleIndex["nodepool-1"]).To(Equal(nodepool1)) - - // Test multiple nodepools - nodepool2 := &NodePool{} - nodepool2.ID = "nodepool-2" - nodepool2.Name = "test-nodepool-2" - - nodepool3 := &NodePool{} - nodepool3.ID = "nodepool-3" - nodepool3.Name = "test-nodepool-3" - - multiList := NodePoolList{nodepool1, nodepool2, nodepool3} - multiIndex := multiList.Index() - Expect(len(multiIndex)).To(Equal(3)) - Expect(multiIndex["nodepool-1"]).To(Equal(nodepool1)) - Expect(multiIndex["nodepool-2"]).To(Equal(nodepool2)) - Expect(multiIndex["nodepool-3"]).To(Equal(nodepool3)) - - // Test duplicate IDs (later one overwrites earlier one) - nodepool1Duplicate := &NodePool{} - nodepool1Duplicate.ID = "nodepool-1" - nodepool1Duplicate.Name = "duplicate-nodepool" - - duplicateList := NodePoolList{nodepool1, nodepool1Duplicate} - duplicateIndex := duplicateList.Index() - Expect(len(duplicateIndex)).To(Equal(1)) - Expect(duplicateIndex["nodepool-1"]).To(Equal(nodepool1Duplicate)) - Expect(duplicateIndex["nodepool-1"].Name).To(Equal("duplicate-nodepool")) -} - -// TestNodePool_BeforeCreate_IDGeneration tests ID auto-generation -func TestNodePool_BeforeCreate_IDGeneration(t *testing.T) { - RegisterTestingT(t) - - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-123", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.ID).ToNot(BeEmpty()) - Expect(len(nodepool.ID)).To(BeNumerically(">", 0)) -} - -// TestNodePool_BeforeCreate_KindPreservation tests Kind is preserved (not auto-set) -func TestNodePool_BeforeCreate_KindPreservation(t *testing.T) { - RegisterTestingT(t) - - // Kind must be set before BeforeCreate (by handler validation) - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-123", - Kind: "NodePool", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.Kind).To(Equal("NodePool")) -} - -// TestNodePool_BeforeCreate_KindPreserved tests Kind is not overwritten -func TestNodePool_BeforeCreate_KindPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test Kind preservation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-123", - Kind: "CustomNodePool", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.Kind).To(Equal("CustomNodePool")) -} - -// TestNodePool_BeforeCreate_OwnerKindDefault tests OwnerKind default value -func TestNodePool_BeforeCreate_OwnerKindDefault(t *testing.T) { - RegisterTestingT(t) - - // Test default OwnerKind - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-123", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.OwnerKind).To(Equal("Cluster")) -} - -// TestNodePool_BeforeCreate_OwnerKindPreserved tests OwnerKind is not overwritten -func TestNodePool_BeforeCreate_OwnerKindPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test OwnerKind preservation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "custom-owner-123", - OwnerKind: "CustomOwner", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.OwnerKind).To(Equal("CustomOwner")) -} - -// TestNodePool_BeforeCreate_HrefGeneration tests Href auto-generation -func TestNodePool_BeforeCreate_HrefGeneration(t *testing.T) { - RegisterTestingT(t) - - // Test Href generation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-abc", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-abc/nodepools/" + nodepool.ID)) -} - -// TestNodePool_BeforeCreate_HrefPreserved tests Href is not overwritten -func TestNodePool_BeforeCreate_HrefPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test Href preservation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-abc", - Href: "/custom/href", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.Href).To(Equal("/custom/href")) -} - -// TestNodePool_BeforeCreate_OwnerHrefGeneration tests OwnerHref auto-generation -func TestNodePool_BeforeCreate_OwnerHrefGeneration(t *testing.T) { - RegisterTestingT(t) - - // Test OwnerHref generation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-xyz", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.OwnerHref).To(Equal("/api/hyperfleet/v1/clusters/cluster-xyz")) -} - -// TestNodePool_BeforeCreate_OwnerHrefPreserved tests OwnerHref is not overwritten -func TestNodePool_BeforeCreate_OwnerHrefPreserved(t *testing.T) { - RegisterTestingT(t) - - // Test OwnerHref preservation - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-xyz", - OwnerHref: "/custom/owner/href", - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - Expect(nodepool.OwnerHref).To(Equal("/custom/owner/href")) -} - -// TestNodePool_BeforeCreate_Complete tests all defaults set together -func TestNodePool_BeforeCreate_Complete(t *testing.T) { - RegisterTestingT(t) - - nodepool := &NodePool{ - Name: "test-nodepool", - OwnerID: "cluster-complete", - Kind: "NodePool", // Kind must be set before BeforeCreate - } - - err := nodepool.BeforeCreate(nil) - Expect(err).To(BeNil()) - - // Verify all defaults - Expect(nodepool.ID).ToNot(BeEmpty()) - Expect(nodepool.Kind).To(Equal("NodePool")) // Kind is preserved, not auto-set - Expect(nodepool.OwnerKind).To(Equal("Cluster")) - Expect(nodepool.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-complete/nodepools/" + nodepool.ID)) - Expect(nodepool.OwnerHref).To(Equal("/api/hyperfleet/v1/clusters/cluster-complete")) -} diff --git a/pkg/api/presenters/cluster.go b/pkg/api/presenters/cluster.go deleted file mode 100644 index 3aa2393..0000000 --- a/pkg/api/presenters/cluster.go +++ /dev/null @@ -1,118 +0,0 @@ -package presenters - -import ( - "encoding/json" - "fmt" - - openapi_types "github.com/oapi-codegen/runtime/types" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" -) - -// ConvertCluster converts openapi.ClusterCreateRequest to api.Cluster (GORM model) -func ConvertCluster(req *openapi.ClusterCreateRequest, createdBy string) (*api.Cluster, error) { - // Marshal Spec - specJSON, err := json.Marshal(req.Spec) - if err != nil { - return nil, fmt.Errorf("failed to marshal cluster spec: %w", err) - } - - // Marshal Labels - labels := make(map[string]string) - if req.Labels != nil { - labels = *req.Labels - } - labelsJSON, err := json.Marshal(labels) - if err != nil { - return nil, fmt.Errorf("failed to marshal cluster labels: %w", err) - } - - // Get Kind value, use default if not provided - kind := "Cluster" - if req.Kind != nil { - kind = *req.Kind - } - - return &api.Cluster{ - Kind: kind, - Name: req.Name, - Spec: specJSON, - Labels: labelsJSON, - Generation: 1, - CreatedBy: createdBy, - UpdatedBy: createdBy, - }, nil -} - -// Helper to convert string to openapi_types.Email -func toEmail(s string) openapi_types.Email { - return openapi_types.Email(s) -} - -// PresentCluster converts api.Cluster (GORM model) to openapi.Cluster -func PresentCluster(cluster *api.Cluster) (openapi.Cluster, error) { - // Unmarshal Spec - var spec map[string]interface{} - if len(cluster.Spec) > 0 { - if err := json.Unmarshal(cluster.Spec, &spec); err != nil { - return openapi.Cluster{}, fmt.Errorf("failed to unmarshal cluster spec: %w", err) - } - } - - // Unmarshal Labels - var labels map[string]string - if len(cluster.Labels) > 0 { - if err := json.Unmarshal(cluster.Labels, &labels); err != nil { - return openapi.Cluster{}, fmt.Errorf("failed to unmarshal cluster labels: %w", err) - } - } - - // Unmarshal StatusConditions - var statusConditions []api.ResourceCondition - if len(cluster.StatusConditions) > 0 { - if err := json.Unmarshal(cluster.StatusConditions, &statusConditions); err != nil { - return openapi.Cluster{}, fmt.Errorf("failed to unmarshal cluster status conditions: %w", err) - } - } - - // Generate Href if not set (fallback) - href := cluster.Href - if href == "" { - href = "/api/hyperfleet/v1/clusters/" + cluster.ID - } - - // Convert domain ResourceConditions to openapi format - openapiConditions := make([]openapi.ResourceCondition, len(statusConditions)) - for i, cond := range statusConditions { - openapiConditions[i] = openapi.ResourceCondition{ - CreatedTime: cond.CreatedTime, - LastTransitionTime: cond.LastTransitionTime, - LastUpdatedTime: cond.LastUpdatedTime, - Message: cond.Message, - ObservedGeneration: cond.ObservedGeneration, - Reason: cond.Reason, - Status: openapi.ResourceConditionStatus(cond.Status), - Type: cond.Type, - } - } - - result := openapi.Cluster{ - CreatedBy: toEmail(cluster.CreatedBy), - CreatedTime: cluster.CreatedTime, - Generation: cluster.Generation, - Href: &href, - Id: &cluster.ID, - Kind: util.PtrString(cluster.Kind), - Labels: &labels, - Name: cluster.Name, - Spec: spec, - Status: openapi.ClusterStatus{ - Conditions: openapiConditions, - }, - UpdatedBy: toEmail(cluster.UpdatedBy), - UpdatedTime: cluster.UpdatedTime, - } - - return result, nil -} diff --git a/pkg/api/presenters/cluster_test.go b/pkg/api/presenters/cluster_test.go deleted file mode 100644 index 3a61f3f..0000000 --- a/pkg/api/presenters/cluster_test.go +++ /dev/null @@ -1,411 +0,0 @@ -package presenters - -import ( - "encoding/json" - "testing" - "time" - - openapi_types "github.com/oapi-codegen/runtime/types" - . "github.com/onsi/gomega" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" -) - -const ( - testConditionReady = "Ready" -) - -// Helper function to create test ClusterCreateRequest -func createTestClusterRequest() *openapi.ClusterCreateRequest { - labels := map[string]string{"env": "test"} - - return &openapi.ClusterCreateRequest{ - Labels: &labels, - Kind: util.PtrString("Cluster"), - Name: "test-cluster", - Spec: map[string]interface{}{ - "region": "us-central1", - "provider": "gcp", - }, - } -} - -// TestConvertCluster_Complete tests conversion with all fields populated -func TestConvertCluster_Complete(t *testing.T) { - RegisterTestingT(t) - - req := createTestClusterRequest() - createdBy := "user123" - - result, err := ConvertCluster(req, createdBy) - Expect(err).To(BeNil()) - - // Verify basic fields - Expect(result.Kind).To(Equal("Cluster")) - Expect(result.Name).To(Equal("test-cluster")) - Expect(result.CreatedBy).To(Equal(createdBy)) - Expect(result.UpdatedBy).To(Equal(createdBy)) - - // Verify defaults - Expect(result.Generation).To(Equal(int32(1))) - - // Verify Spec marshaled correctly - var spec map[string]interface{} - err = json.Unmarshal(result.Spec, &spec) - Expect(err).To(BeNil()) - Expect(spec["region"]).To(Equal("us-central1")) - Expect(spec["provider"]).To(Equal("gcp")) - - // Verify Labels marshaled correctly - var labels map[string]string - err = json.Unmarshal(result.Labels, &labels) - Expect(err).To(BeNil()) - Expect(labels["env"]).To(Equal("test")) - - // StatusConditions initialization is handled by the service layer on create, not presenters. - Expect(len(result.StatusConditions)).To(Equal(0)) -} - -// TestConvertCluster_WithLabels tests conversion with labels -func TestConvertCluster_WithLabels(t *testing.T) { - RegisterTestingT(t) - - labels := map[string]string{ - "env": "production", - "team": "platform", - } - - req := &openapi.ClusterCreateRequest{ - Labels: &labels, - Kind: util.PtrString("Cluster"), - Name: "labeled-cluster", - Spec: map[string]interface{}{"test": "spec"}, - } - - result, err := ConvertCluster(req, "user456") - Expect(err).To(BeNil()) - - var resultLabels map[string]string - err = json.Unmarshal(result.Labels, &resultLabels) - Expect(err).To(BeNil()) - Expect(resultLabels["env"]).To(Equal("production")) - Expect(resultLabels["team"]).To(Equal("platform")) -} - -// TestConvertCluster_WithoutLabels tests conversion with nil labels -func TestConvertCluster_WithoutLabels(t *testing.T) { - RegisterTestingT(t) - - req := &openapi.ClusterCreateRequest{ - Labels: nil, // Nil labels - Kind: util.PtrString("Cluster"), - Name: "unlabeled-cluster", - Spec: map[string]interface{}{"test": "spec"}, - } - - result, err := ConvertCluster(req, "user789") - Expect(err).To(BeNil()) - - var resultLabels map[string]string - err = json.Unmarshal(result.Labels, &resultLabels) - Expect(err).To(BeNil()) - Expect(len(resultLabels)).To(Equal(0)) // Empty map -} - -// TestConvertCluster_SpecMarshaling tests complex spec with nested objects -func TestConvertCluster_SpecMarshaling(t *testing.T) { - RegisterTestingT(t) - - complexSpec := map[string]interface{}{ - "provider": "gcp", - "region": "us-east1", - "config": map[string]interface{}{ - "nodes": 3, - "networking": map[string]interface{}{ - "cidr": "10.0.0.0/16", - }, - }, - "tags": []string{"production", "critical"}, - } - - req := &openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: "complex-cluster", - Spec: complexSpec, - } - - result, err := ConvertCluster(req, "user000") - Expect(err).To(BeNil()) - - var resultSpec map[string]interface{} - err = json.Unmarshal(result.Spec, &resultSpec) - Expect(err).To(BeNil()) - Expect(resultSpec["provider"]).To(Equal("gcp")) - Expect(resultSpec["region"]).To(Equal("us-east1")) - - // Verify nested config - config := resultSpec["config"].(map[string]interface{}) - Expect(config["nodes"]).To(BeNumerically("==", 3)) - - networking := config["networking"].(map[string]interface{}) - Expect(networking["cidr"]).To(Equal("10.0.0.0/16")) - - // Verify tags array - tags := resultSpec["tags"].([]interface{}) - Expect(len(tags)).To(Equal(2)) -} - -// TestPresentCluster_Complete tests presentation with all fields -func TestPresentCluster_Complete(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - reason := testConditionReady - message := "Cluster is ready" - - // Create domain ResourceCondition - conditions := []api.ResourceCondition{ - { - ObservedGeneration: 5, - CreatedTime: now, - LastUpdatedTime: now, - Type: "Available", - Status: api.ConditionTrue, - Reason: &reason, - Message: &message, - LastTransitionTime: now, - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - spec := map[string]interface{}{"region": "us-west1"} - specJSON, _ := json.Marshal(spec) - - labels := map[string]string{"env": "staging"} - labelsJSON, _ := json.Marshal(labels) - - cluster := &api.Cluster{ - Kind: "Cluster", - Href: "/api/hyperfleet/v1/clusters/cluster-abc123", - Name: "presented-cluster", - Spec: specJSON, - Labels: labelsJSON, - Generation: 10, - StatusConditions: conditionsJSON, - CreatedBy: "user123@example.com", - UpdatedBy: "user456@example.com", - } - cluster.ID = "cluster-abc123" - cluster.CreatedTime = now - cluster.UpdatedTime = now - - result, err := PresentCluster(cluster) - Expect(err).To(BeNil()) - - // Verify basic fields - Expect(*result.Id).To(Equal("cluster-abc123")) - Expect(*result.Kind).To(Equal("Cluster")) - Expect(*result.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-abc123")) - Expect(result.Name).To(Equal("presented-cluster")) - Expect(result.Generation).To(Equal(int32(10))) - Expect(result.CreatedBy).To(Equal(openapi_types.Email("user123@example.com"))) - Expect(result.UpdatedBy).To(Equal(openapi_types.Email("user456@example.com"))) - - // Verify Spec unmarshaled correctly - Expect(result.Spec["region"]).To(Equal("us-west1")) - - // Verify Labels unmarshaled correctly - Expect((*result.Labels)["env"]).To(Equal("staging")) - - // Verify Status - Expect(len(result.Status.Conditions)).To(Equal(1)) - Expect(result.Status.Conditions[0].Type).To(Equal("Available")) - Expect(result.Status.Conditions[0].Status).To(Equal(openapi.ResourceConditionStatusTrue)) - Expect(*result.Status.Conditions[0].Reason).To(Equal(testConditionReady)) - - // Verify timestamps - Expect(result.CreatedTime.Unix()).To(Equal(now.Unix())) - Expect(result.UpdatedTime.Unix()).To(Equal(now.Unix())) -} - -// TestPresentCluster_HrefGeneration tests that Href is generated if not set -func TestPresentCluster_HrefGeneration(t *testing.T) { - RegisterTestingT(t) - - cluster := &api.Cluster{ - Kind: "Cluster", - Href: "", // Empty Href - Name: "href-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - StatusConditions: []byte("[]"), - } - cluster.ID = "cluster-xyz789" - - result, err := PresentCluster(cluster) - Expect(err).To(BeNil()) - - Expect(*result.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-xyz789")) -} - -// TestPresentCluster_StatusConditionsConversion tests condition conversion -func TestPresentCluster_StatusConditionsConversion(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - reason1 := "Ready" - message1 := "All systems operational" - reason2 := "Degraded" - message2 := "Some components unavailable" - - // Create multiple domain ResourceConditions - conditions := []api.ResourceCondition{ - { - ObservedGeneration: 3, - CreatedTime: now, - LastUpdatedTime: now, - Type: "Available", - Status: api.ConditionTrue, - Reason: &reason1, - Message: &message1, - LastTransitionTime: now, - }, - { - ObservedGeneration: 3, - CreatedTime: now, - LastUpdatedTime: now, - Type: "Progressing", - Status: api.ConditionFalse, - Reason: &reason2, - Message: &message2, - LastTransitionTime: now, - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - cluster := &api.Cluster{ - Kind: "Cluster", - Name: "multi-conditions-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - StatusConditions: conditionsJSON, - } - cluster.ID = "cluster-multi-conditions" - cluster.CreatedTime = now - cluster.UpdatedTime = now - - result, err := PresentCluster(cluster) - Expect(err).To(BeNil()) - - // Verify both conditions converted correctly - Expect(len(result.Status.Conditions)).To(Equal(2)) - - // First condition - Expect(result.Status.Conditions[0].Type).To(Equal("Available")) - Expect(result.Status.Conditions[0].Status).To(Equal(openapi.ResourceConditionStatusTrue)) - Expect(*result.Status.Conditions[0].Reason).To(Equal(testConditionReady)) - Expect(*result.Status.Conditions[0].Message).To(Equal("All systems operational")) - - // Second condition - Expect(result.Status.Conditions[1].Type).To(Equal("Progressing")) - Expect(result.Status.Conditions[1].Status).To(Equal(openapi.ResourceConditionStatusFalse)) - Expect(*result.Status.Conditions[1].Reason).To(Equal("Degraded")) - Expect(*result.Status.Conditions[1].Message).To(Equal("Some components unavailable")) -} - -// TestConvertAndPresentCluster_RoundTrip tests data integrity through convert and present -func TestConvertAndPresentCluster_RoundTrip(t *testing.T) { - RegisterTestingT(t) - - originalReq := createTestClusterRequest() - createdBy := "user999@example.com" - - // Convert from OpenAPI request to domain - cluster, err := ConvertCluster(originalReq, createdBy) - Expect(err).To(BeNil()) - - // Simulate database fields (ID, timestamps) - cluster.ID = "cluster-roundtrip-123" - now := time.Now() - cluster.CreatedTime = now - cluster.UpdatedTime = now - - // Present from domain back to OpenAPI - result, err := PresentCluster(cluster) - Expect(err).To(BeNil()) - - // Verify data integrity - Expect(*result.Id).To(Equal("cluster-roundtrip-123")) - Expect(result.Kind).To(Equal(originalReq.Kind)) - Expect(result.Name).To(Equal(originalReq.Name)) - Expect(result.CreatedBy).To(Equal(openapi_types.Email(createdBy))) - Expect(result.UpdatedBy).To(Equal(openapi_types.Email(createdBy))) - - // Verify Spec preserved - Expect(result.Spec["region"]).To(Equal(originalReq.Spec["region"])) - Expect(result.Spec["provider"]).To(Equal(originalReq.Spec["provider"])) - - // Verify Labels preserved - Expect((*result.Labels)["env"]).To(Equal((*originalReq.Labels)["env"])) - - // Status initialization is handled by the service layer on create, not presenters. - Expect(len(result.Status.Conditions)).To(Equal(0)) -} - -// TestPresentCluster_MalformedSpec tests error handling for malformed Spec JSON -func TestPresentCluster_MalformedSpec(t *testing.T) { - RegisterTestingT(t) - - cluster := &api.Cluster{ - Kind: "Cluster", - Name: "malformed-spec-cluster", - Spec: []byte("{invalid json}"), // Malformed JSON - Labels: []byte("{}"), - StatusConditions: []byte("[]"), - } - cluster.ID = "cluster-malformed-spec" - - _, err := PresentCluster(cluster) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal cluster spec")) -} - -// TestPresentCluster_MalformedLabels tests error handling for malformed Labels JSON -func TestPresentCluster_MalformedLabels(t *testing.T) { - RegisterTestingT(t) - - cluster := &api.Cluster{ - Kind: "Cluster", - Name: "malformed-labels-cluster", - Spec: []byte("{}"), - Labels: []byte("{not valid json"), // Malformed JSON - StatusConditions: []byte("[]"), - } - cluster.ID = "cluster-malformed-labels" - - _, err := PresentCluster(cluster) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal cluster labels")) -} - -// TestPresentCluster_MalformedStatusConditions tests error handling for malformed StatusConditions JSON -func TestPresentCluster_MalformedStatusConditions(t *testing.T) { - RegisterTestingT(t) - - cluster := &api.Cluster{ - Kind: "Cluster", - Name: "malformed-conditions-cluster", - Spec: []byte("{}"), - Labels: []byte("{}"), - StatusConditions: []byte("[{incomplete"), // Malformed JSON - } - cluster.ID = "cluster-malformed-conditions" - - _, err := PresentCluster(cluster) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal cluster status conditions")) -} diff --git a/pkg/api/presenters/node_pool.go b/pkg/api/presenters/node_pool.go deleted file mode 100644 index 5864bac..0000000 --- a/pkg/api/presenters/node_pool.go +++ /dev/null @@ -1,123 +0,0 @@ -package presenters - -import ( - "encoding/json" - "fmt" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" -) - -// ConvertNodePool converts openapi.NodePoolCreateRequest to api.NodePool (GORM model) -func ConvertNodePool(req *openapi.NodePoolCreateRequest, ownerID, createdBy string) (*api.NodePool, error) { - // Marshal Spec - specJSON, err := json.Marshal(req.Spec) - if err != nil { - return nil, fmt.Errorf("failed to marshal nodepool spec: %w", err) - } - - // Marshal Labels - labels := make(map[string]string) - if req.Labels != nil { - labels = *req.Labels - } - labelsJSON, err := json.Marshal(labels) - if err != nil { - return nil, fmt.Errorf("failed to marshal nodepool labels: %w", err) - } - - kind := "NodePool" - if req.Kind != nil { - kind = *req.Kind - } - - return &api.NodePool{ - Kind: kind, - Name: req.Name, - Spec: specJSON, - Labels: labelsJSON, - OwnerID: ownerID, - OwnerKind: "Cluster", - CreatedBy: createdBy, - UpdatedBy: createdBy, - }, nil -} - -// PresentNodePool converts api.NodePool (GORM model) to openapi.NodePool -func PresentNodePool(nodePool *api.NodePool) (openapi.NodePool, error) { - // Unmarshal Spec - var spec map[string]interface{} - if len(nodePool.Spec) > 0 { - if err := json.Unmarshal(nodePool.Spec, &spec); err != nil { - return openapi.NodePool{}, fmt.Errorf("failed to unmarshal nodepool spec: %w", err) - } - } - - // Unmarshal Labels - var labels map[string]string - if len(nodePool.Labels) > 0 { - if err := json.Unmarshal(nodePool.Labels, &labels); err != nil { - return openapi.NodePool{}, fmt.Errorf("failed to unmarshal nodepool labels: %w", err) - } - } - - // Unmarshal StatusConditions - var statusConditions []api.ResourceCondition - if len(nodePool.StatusConditions) > 0 { - if err := json.Unmarshal(nodePool.StatusConditions, &statusConditions); err != nil { - return openapi.NodePool{}, fmt.Errorf("failed to unmarshal nodepool status conditions: %w", err) - } - } - - // Generate Href if not set (fallback) - href := nodePool.Href - if href == "" { - href = fmt.Sprintf("/api/hyperfleet/v1/clusters/%s/nodepools/%s", nodePool.OwnerID, nodePool.ID) - } - - // Generate OwnerHref if not set (fallback) - ownerHref := nodePool.OwnerHref - if ownerHref == "" { - ownerHref = "/api/hyperfleet/v1/clusters/" + nodePool.OwnerID - } - - // Convert domain ResourceConditions to openapi format - openapiConditions := make([]openapi.ResourceCondition, len(statusConditions)) - for i, cond := range statusConditions { - openapiConditions[i] = openapi.ResourceCondition{ - CreatedTime: cond.CreatedTime, - LastTransitionTime: cond.LastTransitionTime, - LastUpdatedTime: cond.LastUpdatedTime, - Message: cond.Message, - ObservedGeneration: cond.ObservedGeneration, - Reason: cond.Reason, - Status: openapi.ResourceConditionStatus(cond.Status), - Type: cond.Type, - } - } - - kind := nodePool.Kind - result := openapi.NodePool{ - CreatedBy: toEmail(nodePool.CreatedBy), - CreatedTime: nodePool.CreatedTime, - Generation: nodePool.Generation, - Href: &href, - Id: &nodePool.ID, - Kind: &kind, - Labels: &labels, - Name: nodePool.Name, - OwnerReferences: openapi.ObjectReference{ - Id: &nodePool.OwnerID, - Kind: &nodePool.OwnerKind, - Href: &ownerHref, - }, - Spec: spec, - Status: openapi.NodePoolStatus{ - Conditions: openapiConditions, - }, - UpdatedBy: toEmail(nodePool.UpdatedBy), - UpdatedTime: nodePool.UpdatedTime, - } - - return result, nil -} diff --git a/pkg/api/presenters/node_pool_test.go b/pkg/api/presenters/node_pool_test.go deleted file mode 100644 index c6a4b1b..0000000 --- a/pkg/api/presenters/node_pool_test.go +++ /dev/null @@ -1,460 +0,0 @@ -package presenters - -import ( - "encoding/json" - "testing" - "time" - - openapi_types "github.com/oapi-codegen/runtime/types" - . "github.com/onsi/gomega" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" -) - -// Helper function to create test NodePoolCreateRequest -func createTestNodePoolRequest() *openapi.NodePoolCreateRequest { - labels := map[string]string{"env": "test"} - kind := "NodePool" - - return &openapi.NodePoolCreateRequest{ - Kind: &kind, - Name: "test-nodepool", - Spec: map[string]interface{}{ - "replicas": 3, - "instanceType": "n1-standard-4", - }, - Labels: &labels, - } -} - -// TestConvertNodePool_Complete tests conversion with all fields populated -func TestConvertNodePool_Complete(t *testing.T) { - RegisterTestingT(t) - - req := createTestNodePoolRequest() - ownerID := "cluster-owner-123" - createdBy := "user456" - - result, err := ConvertNodePool(req, ownerID, createdBy) - Expect(err).To(BeNil()) - - // Verify basic fields - Expect(result.Kind).To(Equal("NodePool")) - Expect(result.Name).To(Equal("test-nodepool")) - Expect(result.OwnerID).To(Equal("cluster-owner-123")) - Expect(result.OwnerKind).To(Equal("Cluster")) - Expect(result.CreatedBy).To(Equal("user456")) - Expect(result.UpdatedBy).To(Equal("user456")) - - // Verify Spec marshaled correctly - var spec map[string]interface{} - err = json.Unmarshal(result.Spec, &spec) - Expect(err).To(BeNil()) - Expect(spec["replicas"]).To(BeNumerically("==", 3)) - Expect(spec["instanceType"]).To(Equal("n1-standard-4")) - - // Verify Labels marshaled correctly - var labels map[string]string - err = json.Unmarshal(result.Labels, &labels) - Expect(err).To(BeNil()) - Expect(labels["env"]).To(Equal("test")) - - // StatusConditions initialization is handled by the service layer on create, not presenters. - Expect(len(result.StatusConditions)).To(Equal(0)) -} - -// TestConvertNodePool_WithKind tests conversion with Kind specified -func TestConvertNodePool_WithKind(t *testing.T) { - RegisterTestingT(t) - - customKind := "CustomNodePool" - req := &openapi.NodePoolCreateRequest{ - Kind: &customKind, - Name: "custom-nodepool", - Spec: map[string]interface{}{"test": "spec"}, - Labels: nil, - } - - result, err := ConvertNodePool(req, "cluster-123", "user789") - Expect(err).To(BeNil()) - - Expect(result.Kind).To(Equal("CustomNodePool")) -} - -// TestConvertNodePool_WithoutKind tests conversion with nil Kind (uses default) -func TestConvertNodePool_WithoutKind(t *testing.T) { - RegisterTestingT(t) - - req := &openapi.NodePoolCreateRequest{ - Kind: nil, // Nil Kind - Name: "default-kind-nodepool", - Spec: map[string]interface{}{"test": "spec"}, - Labels: nil, - } - - result, err := ConvertNodePool(req, "cluster-456", "user000") - Expect(err).To(BeNil()) - - Expect(result.Kind).To(Equal("NodePool")) // Default value -} - -// TestConvertNodePool_WithLabels tests conversion with labels -func TestConvertNodePool_WithLabels(t *testing.T) { - RegisterTestingT(t) - - labels := map[string]string{ - "environment": "production", - "team": "platform", - "region": "us-east", - } - - req := &openapi.NodePoolCreateRequest{ - Name: "labeled-nodepool", - Spec: map[string]interface{}{"test": "spec"}, - Labels: &labels, - } - - result, err := ConvertNodePool(req, "cluster-789", "user111") - Expect(err).To(BeNil()) - - var resultLabels map[string]string - err = json.Unmarshal(result.Labels, &resultLabels) - Expect(err).To(BeNil()) - Expect(resultLabels["environment"]).To(Equal("production")) - Expect(resultLabels["team"]).To(Equal("platform")) - Expect(resultLabels["region"]).To(Equal("us-east")) -} - -// TestConvertNodePool_WithoutLabels tests conversion with nil labels -func TestConvertNodePool_WithoutLabels(t *testing.T) { - RegisterTestingT(t) - - req := &openapi.NodePoolCreateRequest{ - Name: "unlabeled-nodepool", - Spec: map[string]interface{}{"test": "spec"}, - Labels: nil, // Nil labels - } - - result, err := ConvertNodePool(req, "cluster-xyz", "user222") - Expect(err).To(BeNil()) - - var resultLabels map[string]string - err = json.Unmarshal(result.Labels, &resultLabels) - Expect(err).To(BeNil()) - Expect(len(resultLabels)).To(Equal(0)) // Empty map -} - -// TestPresentNodePool_Complete tests presentation with all fields -func TestPresentNodePool_Complete(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - reason := "Ready" - message := "NodePool is ready" - - // Create domain ResourceCondition - conditions := []api.ResourceCondition{ - { - ObservedGeneration: 5, - CreatedTime: now, - LastUpdatedTime: now, - Type: "Available", - Status: api.ConditionTrue, - Reason: &reason, - Message: &message, - LastTransitionTime: now, - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - spec := map[string]interface{}{"replicas": 5} - specJSON, _ := json.Marshal(spec) - - labels := map[string]string{"env": "staging"} - labelsJSON, _ := json.Marshal(labels) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Href: "/api/hyperfleet/v1/clusters/cluster-abc/nodepools/nodepool-xyz", - Name: "presented-nodepool", - Spec: specJSON, - Labels: labelsJSON, - OwnerID: "cluster-abc", - OwnerKind: "Cluster", - OwnerHref: "/api/hyperfleet/v1/clusters/cluster-abc", - StatusConditions: conditionsJSON, - CreatedBy: "user123@example.com", - UpdatedBy: "user456@example.com", - } - nodePool.ID = "nodepool-xyz" - nodePool.CreatedTime = now - nodePool.UpdatedTime = now - - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - // Verify basic fields - Expect(*result.Id).To(Equal("nodepool-xyz")) - Expect(*result.Kind).To(Equal("NodePool")) - Expect(*result.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-abc/nodepools/nodepool-xyz")) - Expect(result.Name).To(Equal("presented-nodepool")) - Expect(result.CreatedBy).To(Equal(openapi_types.Email("user123@example.com"))) - Expect(result.UpdatedBy).To(Equal(openapi_types.Email("user456@example.com"))) - - // Verify Spec unmarshaled correctly - Expect(result.Spec["replicas"]).To(BeNumerically("==", 5)) - - // Verify Labels unmarshaled correctly - Expect((*result.Labels)["env"]).To(Equal("staging")) - - // Verify OwnerReferences - Expect(*result.OwnerReferences.Id).To(Equal("cluster-abc")) - Expect(*result.OwnerReferences.Kind).To(Equal("Cluster")) - Expect(*result.OwnerReferences.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-abc")) - - // Verify Status - Expect(len(result.Status.Conditions)).To(Equal(1)) - Expect(result.Status.Conditions[0].Type).To(Equal("Available")) - Expect(result.Status.Conditions[0].Status).To(Equal(openapi.ResourceConditionStatusTrue)) - - // Verify timestamps - Expect(result.CreatedTime.Unix()).To(Equal(now.Unix())) - Expect(result.UpdatedTime.Unix()).To(Equal(now.Unix())) -} - -// TestPresentNodePool_HrefGeneration tests that Href is generated if not set -func TestPresentNodePool_HrefGeneration(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Href: "", // Empty Href - Name: "href-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - OwnerID: "cluster-owner-456", - StatusConditions: []byte("[]"), - } - nodePool.ID = "nodepool-test-123" - - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - Expect(*result.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-owner-456/nodepools/nodepool-test-123")) -} - -// TestPresentNodePool_OwnerHrefGeneration tests that OwnerHref is generated if not set -func TestPresentNodePool_OwnerHrefGeneration(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "owner-href-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - OwnerID: "cluster-owner-789", - OwnerHref: "", // Empty OwnerHref - StatusConditions: []byte("[]"), - } - nodePool.ID = "nodepool-owner-test" - - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - Expect(*result.OwnerReferences.Href).To(Equal("/api/hyperfleet/v1/clusters/cluster-owner-789")) -} - -// TestPresentNodePool_OwnerReferences tests OwnerReferences are set correctly -func TestPresentNodePool_OwnerReferences(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "owner-ref-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - OwnerID: "cluster-ref-123", - OwnerKind: "Cluster", - StatusConditions: []byte("[]"), - } - nodePool.ID = "nodepool-ref-456" - - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - Expect(result.OwnerReferences.Id).ToNot(BeNil()) - Expect(*result.OwnerReferences.Id).To(Equal("cluster-ref-123")) - Expect(result.OwnerReferences.Kind).ToNot(BeNil()) - Expect(*result.OwnerReferences.Kind).To(Equal("Cluster")) - Expect(result.OwnerReferences.Href).ToNot(BeNil()) -} - -// TestPresentNodePool_StatusConditionsConversion tests condition conversion -func TestPresentNodePool_StatusConditionsConversion(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - reason1 := "Scaling" - message1 := "Scaling in progress" - reason2 := "Healthy" - message2 := "All nodes healthy" - - // Create multiple domain ResourceConditions - conditions := []api.ResourceCondition{ - { - ObservedGeneration: 2, - CreatedTime: now, - LastUpdatedTime: now, - Type: "Progressing", - Status: api.ConditionTrue, - Reason: &reason1, - Message: &message1, - LastTransitionTime: now, - }, - { - ObservedGeneration: 2, - CreatedTime: now, - LastUpdatedTime: now, - Type: "Healthy", - Status: api.ConditionTrue, - Reason: &reason2, - Message: &message2, - LastTransitionTime: now, - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "multi-conditions-test", - Spec: []byte("{}"), - Labels: []byte("{}"), - OwnerID: "cluster-conditions", - StatusConditions: conditionsJSON, - } - nodePool.ID = "nodepool-multi-conditions" - nodePool.CreatedTime = now - nodePool.UpdatedTime = now - - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - // Verify both conditions converted correctly - Expect(len(result.Status.Conditions)).To(Equal(2)) - - // First condition - Expect(result.Status.Conditions[0].Type).To(Equal("Progressing")) - Expect(result.Status.Conditions[0].Status).To(Equal(openapi.ResourceConditionStatusTrue)) - Expect(*result.Status.Conditions[0].Reason).To(Equal("Scaling")) - Expect(*result.Status.Conditions[0].Message).To(Equal("Scaling in progress")) - - // Second condition - Expect(result.Status.Conditions[1].Type).To(Equal("Healthy")) - Expect(result.Status.Conditions[1].Status).To(Equal(openapi.ResourceConditionStatusTrue)) - Expect(*result.Status.Conditions[1].Reason).To(Equal("Healthy")) - Expect(*result.Status.Conditions[1].Message).To(Equal("All nodes healthy")) -} - -// TestConvertAndPresentNodePool_RoundTrip tests data integrity through convert and present -func TestConvertAndPresentNodePool_RoundTrip(t *testing.T) { - RegisterTestingT(t) - - originalReq := createTestNodePoolRequest() - ownerID := "cluster-roundtrip-789" - createdBy := "user-roundtrip@example.com" - - // Convert from OpenAPI request to domain - nodePool, err := ConvertNodePool(originalReq, ownerID, createdBy) - Expect(err).To(BeNil()) - - // Simulate database fields (ID, timestamps) - nodePool.ID = "nodepool-roundtrip-123" - now := time.Now() - nodePool.CreatedTime = now - nodePool.UpdatedTime = now - - // Present from domain back to OpenAPI - result, err := PresentNodePool(nodePool) - Expect(err).To(BeNil()) - - // Verify data integrity - Expect(*result.Id).To(Equal("nodepool-roundtrip-123")) - Expect(*result.Kind).To(Equal(*originalReq.Kind)) - Expect(result.Name).To(Equal(originalReq.Name)) - Expect(result.CreatedBy).To(Equal(openapi_types.Email(createdBy))) - Expect(result.UpdatedBy).To(Equal(openapi_types.Email(createdBy))) - - // Verify Spec preserved - Expect(result.Spec["replicas"]).To(BeNumerically("==", originalReq.Spec["replicas"])) - Expect(result.Spec["instanceType"]).To(Equal(originalReq.Spec["instanceType"])) - - // Verify Labels preserved - Expect((*result.Labels)["env"]).To(Equal((*originalReq.Labels)["env"])) - - // Verify OwnerReferences set - Expect(*result.OwnerReferences.Id).To(Equal(ownerID)) - Expect(*result.OwnerReferences.Kind).To(Equal("Cluster")) - - // Status initialization is handled by the service layer on create, not presenters. - Expect(len(result.Status.Conditions)).To(Equal(0)) -} - -// TestPresentNodePool_MalformedSpec tests error handling for malformed Spec JSON -func TestPresentNodePool_MalformedSpec(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "malformed-spec-nodepool", - Spec: []byte("{invalid json}"), // Malformed JSON - Labels: []byte("{}"), - OwnerID: "cluster-123", - StatusConditions: []byte("[]"), - } - nodePool.ID = "nodepool-malformed-spec" - - _, err := PresentNodePool(nodePool) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal nodepool spec")) -} - -// TestPresentNodePool_MalformedLabels tests error handling for malformed Labels JSON -func TestPresentNodePool_MalformedLabels(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "malformed-labels-nodepool", - Spec: []byte("{}"), - Labels: []byte("{not valid json"), // Malformed JSON - OwnerID: "cluster-456", - StatusConditions: []byte("[]"), - } - nodePool.ID = "nodepool-malformed-labels" - - _, err := PresentNodePool(nodePool) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal nodepool labels")) -} - -// TestPresentNodePool_MalformedStatusConditions tests error handling for malformed StatusConditions JSON -func TestPresentNodePool_MalformedStatusConditions(t *testing.T) { - RegisterTestingT(t) - - nodePool := &api.NodePool{ - Kind: "NodePool", - Name: "malformed-conditions-nodepool", - Spec: []byte("{}"), - Labels: []byte("{}"), - OwnerID: "cluster-789", - StatusConditions: []byte("[{incomplete"), // Malformed JSON - } - nodePool.ID = "nodepool-malformed-conditions" - - _, err := PresentNodePool(nodePool) - - Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("failed to unmarshal nodepool status conditions")) -} diff --git a/pkg/crd/registry.go b/pkg/crd/registry.go index 28b35c9..a12b98f 100644 --- a/pkg/crd/registry.go +++ b/pkg/crd/registry.go @@ -19,13 +19,30 @@ limitations under the License. package crd import ( + "context" "fmt" - "os" - "path/filepath" + "strings" "sync" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextensionsclient "k8s.io/apiextensions-apiserver/pkg/client/clientset/clientset" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "gopkg.in/yaml.v3" +) + +const ( + // HyperfleetGroup is the API group for HyperFleet CRDs + HyperfleetGroup = "hyperfleet.io" + + // Annotation keys for HyperFleet-specific configuration + AnnotationScope = "hyperfleet.io/scope" + AnnotationOwnerKind = "hyperfleet.io/owner-kind" + AnnotationOwnerPathParam = "hyperfleet.io/owner-path-param" + AnnotationRequiredAdapters = "hyperfleet.io/required-adapters" + AnnotationEnabled = "hyperfleet.io/enabled" ) // Registry holds all loaded CRD definitions and provides lookup methods. @@ -45,103 +62,137 @@ func NewRegistry() *Registry { } } -// LoadFromDirectory loads all YAML CRD files from the specified directory. -// Files must have .yaml or .yml extension. -func (r *Registry) LoadFromDirectory(dirPath string) error { +// LoadFromKubernetes loads CRDs from the Kubernetes API server. +// It discovers all CRDs in the hyperfleet.io group and parses their annotations. +func (r *Registry) LoadFromKubernetes(ctx context.Context) error { r.mu.Lock() defer r.mu.Unlock() - // Check if directory exists - info, err := os.Stat(dirPath) + // Create Kubernetes client + config, err := getKubeConfig() if err != nil { - if os.IsNotExist(err) { - return fmt.Errorf("CRD directory does not exist: %s", dirPath) - } - return fmt.Errorf("failed to stat CRD directory: %w", err) + return fmt.Errorf("failed to get kubernetes config: %w", err) } - if !info.IsDir() { - return fmt.Errorf("CRD path is not a directory: %s", dirPath) + + clientset, err := apiextensionsclient.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create apiextensions client: %w", err) } - // Find all YAML files - entries, err := os.ReadDir(dirPath) + // List all CRDs + crdList, err := clientset.ApiextensionsV1().CustomResourceDefinitions().List(ctx, metav1.ListOptions{}) if err != nil { - return fmt.Errorf("failed to read CRD directory: %w", err) + return fmt.Errorf("failed to list CRDs: %w", err) } - for _, entry := range entries { - if entry.IsDir() { + // Filter and process HyperFleet CRDs + for i := range crdList.Items { + crd := &crdList.Items[i] + if crd.Spec.Group != HyperfleetGroup { continue } - ext := filepath.Ext(entry.Name()) - if ext != ".yaml" && ext != ".yml" { - continue + def, err := r.parseCRD(crd) + if err != nil { + return fmt.Errorf("failed to parse CRD %s: %w", crd.Name, err) } - filePath := filepath.Join(dirPath, entry.Name()) - if err := r.loadFile(filePath); err != nil { - return fmt.Errorf("failed to load CRD from %s: %w", filePath, err) + // Register the CRD + r.byKind[def.Kind] = def + r.byPlural[def.Plural] = def + if def.Enabled { + r.all = append(r.all, def) } } return nil } -// loadFile loads a single CRD YAML file. -func (r *Registry) loadFile(filePath string) error { - data, err := os.ReadFile(filePath) - if err != nil { - return fmt.Errorf("failed to read file: %w", err) - } - - var def api.ResourceDefinition - if err := yaml.Unmarshal(data, &def); err != nil { - return fmt.Errorf("failed to parse YAML: %w", err) +// parseCRD converts a Kubernetes CRD to a ResourceDefinition using annotations. +func (r *Registry) parseCRD(crd *apiextensionsv1.CustomResourceDefinition) (*api.ResourceDefinition, error) { + annotations := crd.Annotations + if annotations == nil { + annotations = make(map[string]string) } - // Validate required fields - if def.Kind == "" { - return fmt.Errorf("missing required field 'kind'") - } - if def.Plural == "" { - return fmt.Errorf("missing required field 'plural'") + // Parse scope + scopeStr := annotations[AnnotationScope] + if scopeStr == "" { + scopeStr = "Root" // Default to Root } - if def.Scope == "" { - return fmt.Errorf("missing required field 'scope'") + var scope api.ResourceScope + switch scopeStr { + case "Root": + scope = api.ResourceScopeRoot + case "Owned": + scope = api.ResourceScopeOwned + default: + return nil, fmt.Errorf("invalid scope '%s': must be 'Root' or 'Owned'", scopeStr) } - // Validate scope value - if def.Scope != api.ResourceScopeRoot && def.Scope != api.ResourceScopeOwned { - return fmt.Errorf("invalid scope '%s': must be 'Root' or 'Owned'", def.Scope) + // Parse owner configuration for owned resources + var owner *api.OwnerRef + if scope == api.ResourceScopeOwned { + ownerKind := annotations[AnnotationOwnerKind] + ownerPathParam := annotations[AnnotationOwnerPathParam] + if ownerKind == "" { + return nil, fmt.Errorf("owned resource must have %s annotation", AnnotationOwnerKind) + } + if ownerPathParam == "" { + ownerPathParam = strings.ToLower(ownerKind) + "_id" + } + owner = &api.OwnerRef{ + Kind: ownerKind, + PathParam: ownerPathParam, + } } - // Validate owned resources have owner configuration - if def.Scope == api.ResourceScopeOwned && def.Owner == nil { - return fmt.Errorf("owned resource '%s' must have 'owner' configuration", def.Kind) + // Parse required adapters + var requiredAdapters []string + adaptersStr := annotations[AnnotationRequiredAdapters] + if adaptersStr != "" { + for _, adapter := range strings.Split(adaptersStr, ",") { + adapter = strings.TrimSpace(adapter) + if adapter != "" { + requiredAdapters = append(requiredAdapters, adapter) + } + } } - // Set singular default if not provided - if def.Singular == "" { - def.Singular = def.Kind + // Parse enabled flag + enabledStr := annotations[AnnotationEnabled] + enabled := enabledStr == "" || enabledStr == "true" // Default to enabled + + def := &api.ResourceDefinition{ + APIVersion: HyperfleetGroup + "/v1", + Kind: crd.Spec.Names.Kind, + Plural: crd.Spec.Names.Plural, + Singular: crd.Spec.Names.Singular, + Scope: scope, + Owner: owner, + StatusConfig: api.StatusConfig{ + RequiredAdapters: requiredAdapters, + }, + Enabled: enabled, } - // Check for duplicates - if _, exists := r.byKind[def.Kind]; exists { - return fmt.Errorf("duplicate kind '%s'", def.Kind) - } - if _, exists := r.byPlural[def.Plural]; exists { - return fmt.Errorf("duplicate plural '%s'", def.Plural) - } + return def, nil +} - // Register the CRD - r.byKind[def.Kind] = &def - r.byPlural[def.Plural] = &def - if def.Enabled { - r.all = append(r.all, &def) +// getKubeConfig returns a Kubernetes client config. +// It tries in-cluster config first, then falls back to kubeconfig file. +func getKubeConfig() (*rest.Config, error) { + // Try in-cluster config first + config, err := rest.InClusterConfig() + if err == nil { + return config, nil } - return nil + // Fall back to kubeconfig + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{} + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, configOverrides) + return kubeConfig.ClientConfig() } // Register adds a CRD definition programmatically. @@ -210,9 +261,9 @@ func Default() *Registry { return defaultRegistry } -// LoadFromDirectory loads CRDs into the default registry. -func LoadFromDirectory(dirPath string) error { - return defaultRegistry.LoadFromDirectory(dirPath) +// LoadFromKubernetes loads CRDs into the default registry from Kubernetes API. +func LoadFromKubernetes(ctx context.Context) error { + return defaultRegistry.LoadFromKubernetes(ctx) } // GetByKind looks up a CRD by kind in the default registry. diff --git a/pkg/dao/cluster.go b/pkg/dao/cluster.go deleted file mode 100644 index 6c077df..0000000 --- a/pkg/dao/cluster.go +++ /dev/null @@ -1,101 +0,0 @@ -package dao - -import ( - "bytes" - "context" - - "gorm.io/gorm/clause" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" -) - -type ClusterDao interface { - Get(ctx context.Context, id string) (*api.Cluster, error) - Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) - Replace(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) - Delete(ctx context.Context, id string) error - FindByIDs(ctx context.Context, ids []string) (api.ClusterList, error) - All(ctx context.Context) (api.ClusterList, error) -} - -var _ ClusterDao = &sqlClusterDao{} - -type sqlClusterDao struct { - sessionFactory *db.SessionFactory -} - -func NewClusterDao(sessionFactory *db.SessionFactory) ClusterDao { - return &sqlClusterDao{sessionFactory: sessionFactory} -} - -func (d *sqlClusterDao) Get(ctx context.Context, id string) (*api.Cluster, error) { - g2 := (*d.sessionFactory).New(ctx) - var cluster api.Cluster - if err := g2.Take(&cluster, "id = ?", id).Error; err != nil { - return nil, err - } - return &cluster, nil -} - -func (d *sqlClusterDao) Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) { - g2 := (*d.sessionFactory).New(ctx) - if err := g2.Omit(clause.Associations).Create(cluster).Error; err != nil { - db.MarkForRollback(ctx, err) - return nil, err - } - return cluster, nil -} - -func (d *sqlClusterDao) Replace(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) { - g2 := (*d.sessionFactory).New(ctx) - - // Get the existing cluster to compare spec - existing, err := d.Get(ctx, cluster.ID) - if err != nil { - db.MarkForRollback(ctx, err) - return nil, err - } - - // Compare spec: if changed, increment generation - if !bytes.Equal(existing.Spec, cluster.Spec) { - cluster.Generation = existing.Generation + 1 - } else { - // Spec unchanged, preserve generation - cluster.Generation = existing.Generation - } - - // Save the cluster - if err := g2.Omit(clause.Associations).Save(cluster).Error; err != nil { - db.MarkForRollback(ctx, err) - return nil, err - } - return cluster, nil -} - -func (d *sqlClusterDao) Delete(ctx context.Context, id string) error { - g2 := (*d.sessionFactory).New(ctx) - if err := g2.Omit(clause.Associations).Delete(&api.Cluster{Meta: api.Meta{ID: id}}).Error; err != nil { - db.MarkForRollback(ctx, err) - return err - } - return nil -} - -func (d *sqlClusterDao) FindByIDs(ctx context.Context, ids []string) (api.ClusterList, error) { - g2 := (*d.sessionFactory).New(ctx) - clusters := api.ClusterList{} - if err := g2.Where("id in (?)", ids).Find(&clusters).Error; err != nil { - return nil, err - } - return clusters, nil -} - -func (d *sqlClusterDao) All(ctx context.Context) (api.ClusterList, error) { - g2 := (*d.sessionFactory).New(ctx) - clusters := api.ClusterList{} - if err := g2.Find(&clusters).Error; err != nil { - return nil, err - } - return clusters, nil -} diff --git a/pkg/dao/mocks/cluster.go b/pkg/dao/mocks/cluster.go deleted file mode 100644 index 2836008..0000000 --- a/pkg/dao/mocks/cluster.go +++ /dev/null @@ -1,51 +0,0 @@ -package mocks - -import ( - "context" - - "gorm.io/gorm" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" -) - -var _ dao.ClusterDao = &clusterDaoMock{} - -type clusterDaoMock struct { - clusters api.ClusterList -} - -func NewClusterDao() *clusterDaoMock { - return &clusterDaoMock{} -} - -func (d *clusterDaoMock) Get(ctx context.Context, id string) (*api.Cluster, error) { - for _, cluster := range d.clusters { - if cluster.ID == id { - return cluster, nil - } - } - return nil, gorm.ErrRecordNotFound -} - -func (d *clusterDaoMock) Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) { - d.clusters = append(d.clusters, cluster) - return cluster, nil -} - -func (d *clusterDaoMock) Replace(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) { - return nil, errors.NotImplemented("Cluster").AsError() -} - -func (d *clusterDaoMock) Delete(ctx context.Context, id string) error { - return errors.NotImplemented("Cluster").AsError() -} - -func (d *clusterDaoMock) FindByIDs(ctx context.Context, ids []string) (api.ClusterList, error) { - return nil, errors.NotImplemented("Cluster").AsError() -} - -func (d *clusterDaoMock) All(ctx context.Context) (api.ClusterList, error) { - return d.clusters, nil -} diff --git a/pkg/dao/mocks/node_pool.go b/pkg/dao/mocks/node_pool.go deleted file mode 100644 index d243d52..0000000 --- a/pkg/dao/mocks/node_pool.go +++ /dev/null @@ -1,51 +0,0 @@ -package mocks - -import ( - "context" - - "gorm.io/gorm" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" -) - -var _ dao.NodePoolDao = &nodePoolDaoMock{} - -type nodePoolDaoMock struct { - nodePools api.NodePoolList -} - -func NewNodePoolDao() *nodePoolDaoMock { - return &nodePoolDaoMock{} -} - -func (d *nodePoolDaoMock) Get(ctx context.Context, id string) (*api.NodePool, error) { - for _, nodePool := range d.nodePools { - if nodePool.ID == id { - return nodePool, nil - } - } - return nil, gorm.ErrRecordNotFound -} - -func (d *nodePoolDaoMock) Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) { - d.nodePools = append(d.nodePools, nodePool) - return nodePool, nil -} - -func (d *nodePoolDaoMock) Replace(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) { - return nil, errors.NotImplemented("NodePool").AsError() -} - -func (d *nodePoolDaoMock) Delete(ctx context.Context, id string) error { - return errors.NotImplemented("NodePool").AsError() -} - -func (d *nodePoolDaoMock) FindByIDs(ctx context.Context, ids []string) (api.NodePoolList, error) { - return nil, errors.NotImplemented("NodePool").AsError() -} - -func (d *nodePoolDaoMock) All(ctx context.Context) (api.NodePoolList, error) { - return d.nodePools, nil -} diff --git a/pkg/dao/node_pool.go b/pkg/dao/node_pool.go deleted file mode 100644 index e48c03e..0000000 --- a/pkg/dao/node_pool.go +++ /dev/null @@ -1,101 +0,0 @@ -package dao - -import ( - "bytes" - "context" - - "gorm.io/gorm/clause" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" -) - -type NodePoolDao interface { - Get(ctx context.Context, id string) (*api.NodePool, error) - Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) - Replace(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) - Delete(ctx context.Context, id string) error - FindByIDs(ctx context.Context, ids []string) (api.NodePoolList, error) - All(ctx context.Context) (api.NodePoolList, error) -} - -var _ NodePoolDao = &sqlNodePoolDao{} - -type sqlNodePoolDao struct { - sessionFactory *db.SessionFactory -} - -func NewNodePoolDao(sessionFactory *db.SessionFactory) NodePoolDao { - return &sqlNodePoolDao{sessionFactory: sessionFactory} -} - -func (d *sqlNodePoolDao) Get(ctx context.Context, id string) (*api.NodePool, error) { - g2 := (*d.sessionFactory).New(ctx) - var nodePool api.NodePool - if err := g2.Take(&nodePool, "id = ?", id).Error; err != nil { - return nil, err - } - return &nodePool, nil -} - -func (d *sqlNodePoolDao) Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) { - g2 := (*d.sessionFactory).New(ctx) - if err := g2.Omit(clause.Associations).Create(nodePool).Error; err != nil { - db.MarkForRollback(ctx, err) - return nil, err - } - return nodePool, nil -} - -func (d *sqlNodePoolDao) Replace(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) { - g2 := (*d.sessionFactory).New(ctx) - - // Get the existing nodePool to compare spec - existing, err := d.Get(ctx, nodePool.ID) - if err != nil { - db.MarkForRollback(ctx, err) - return nil, err - } - - // Compare spec: if changed, increment generation - if !bytes.Equal(existing.Spec, nodePool.Spec) { - nodePool.Generation = existing.Generation + 1 - } else { - // Spec unchanged, preserve generation - nodePool.Generation = existing.Generation - } - - // Save the nodePool - if err := g2.Omit(clause.Associations).Save(nodePool).Error; err != nil { - db.MarkForRollback(ctx, err) - return nil, err - } - return nodePool, nil -} - -func (d *sqlNodePoolDao) Delete(ctx context.Context, id string) error { - g2 := (*d.sessionFactory).New(ctx) - if err := g2.Omit(clause.Associations).Delete(&api.NodePool{Meta: api.Meta{ID: id}}).Error; err != nil { - db.MarkForRollback(ctx, err) - return err - } - return nil -} - -func (d *sqlNodePoolDao) FindByIDs(ctx context.Context, ids []string) (api.NodePoolList, error) { - g2 := (*d.sessionFactory).New(ctx) - nodePools := api.NodePoolList{} - if err := g2.Where("id in (?)", ids).Find(&nodePools).Error; err != nil { - return nil, err - } - return nodePools, nil -} - -func (d *sqlNodePoolDao) All(ctx context.Context) (api.NodePoolList, error) { - g2 := (*d.sessionFactory).New(ctx) - nodePools := api.NodePoolList{} - if err := g2.Find(&nodePools).Error; err != nil { - return nil, err - } - return nodePools, nil -} diff --git a/pkg/db/migrations/202511111044_add_clusters.go b/pkg/db/migrations/202511111044_add_clusters.go deleted file mode 100644 index e284aa3..0000000 --- a/pkg/db/migrations/202511111044_add_clusters.go +++ /dev/null @@ -1,80 +0,0 @@ -package migrations - -// Migrations should NEVER use types from other packages. Types can change -// and then migrations run on a _new_ database will fail or behave unexpectedly. -// Instead of importing types, always re-create the type in the migration, as -// is done here, even though the same type is defined in pkg/api - -import ( - "gorm.io/gorm" - - "github.com/go-gormigrate/gormigrate/v2" -) - -func addClusters() *gormigrate.Migration { - return &gormigrate.Migration{ - ID: "202511111044", - Migrate: func(tx *gorm.DB) error { - // Create clusters table - // ClusterStatus is stored as JSONB in status_conditions, and status fields - // are flattened for efficient querying - createTableSQL := ` - CREATE TABLE IF NOT EXISTS clusters ( - id VARCHAR(255) PRIMARY KEY, - created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ NULL, - - -- Core fields - kind VARCHAR(255) NOT NULL DEFAULT 'Cluster', - name VARCHAR(63) NOT NULL, - spec JSONB NOT NULL, - labels JSONB NULL, - href VARCHAR(500), - - -- Version control - generation INTEGER NOT NULL DEFAULT 1, - - -- Status (conditions-only model) - status_conditions JSONB NULL, - - -- Audit fields - created_by VARCHAR(255) NOT NULL, - updated_by VARCHAR(255) NOT NULL - ); - ` - - if err := tx.Exec(createTableSQL).Error; err != nil { - return err - } - - // Create index on deleted_at for soft deletes - if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_clusters_deleted_at ON clusters(deleted_at);").Error; err != nil { - return err - } - - // Create unique index on name (only for non-deleted records) - createIndexSQL := "CREATE UNIQUE INDEX IF NOT EXISTS idx_clusters_name " + - "ON clusters(name) WHERE deleted_at IS NULL;" - if err := tx.Exec(createIndexSQL).Error; err != nil { - return err - } - - return nil - }, - Rollback: func(tx *gorm.DB) error { - // Drop indexes first - if err := tx.Exec("DROP INDEX IF EXISTS idx_clusters_name;").Error; err != nil { - return err - } - if err := tx.Exec("DROP INDEX IF EXISTS idx_clusters_deleted_at;").Error; err != nil { - return err - } - // Drop table - if err := tx.Exec("DROP TABLE IF EXISTS clusters;").Error; err != nil { - return err - } - return nil - }, - } -} diff --git a/pkg/db/migrations/202511111055_add_node_pools.go b/pkg/db/migrations/202511111055_add_node_pools.go deleted file mode 100644 index c719c1d..0000000 --- a/pkg/db/migrations/202511111055_add_node_pools.go +++ /dev/null @@ -1,101 +0,0 @@ -package migrations - -// Migrations should NEVER use types from other packages. Types can change -// and then migrations run on a _new_ database will fail or behave unexpectedly. -// Instead of importing types, always re-create the type in the migration, as -// is done here, even though the same type is defined in pkg/api - -import ( - "gorm.io/gorm" - - "github.com/go-gormigrate/gormigrate/v2" -) - -func addNodePools() *gormigrate.Migration { - return &gormigrate.Migration{ - ID: "202511111055", - Migrate: func(tx *gorm.DB) error { - // Create node_pools table - createTableSQL := ` - CREATE TABLE IF NOT EXISTS node_pools ( - id VARCHAR(255) PRIMARY KEY, - created_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_time TIMESTAMPTZ NOT NULL DEFAULT NOW(), - deleted_at TIMESTAMPTZ NULL, - - -- Core fields - kind VARCHAR(255) NOT NULL DEFAULT 'NodePool', - name VARCHAR(255) NOT NULL, - spec JSONB NOT NULL, - labels JSONB NULL, - href VARCHAR(500), - - -- Owner References (flattened) - owner_id VARCHAR(255) NOT NULL, - owner_kind VARCHAR(50) NOT NULL, - owner_href VARCHAR(500) NULL, - - -- Version control - generation INTEGER NOT NULL DEFAULT 1, - - -- Status (conditions-only model) - status_conditions JSONB NULL, - - -- Audit fields - created_by VARCHAR(255) NOT NULL, - updated_by VARCHAR(255) NOT NULL - ); - ` - - if err := tx.Exec(createTableSQL).Error; err != nil { - return err - } - - // Create index on deleted_at for soft deletes - createIdxSQL := "CREATE INDEX IF NOT EXISTS idx_node_pools_deleted_at " + - "ON node_pools(deleted_at);" - if err := tx.Exec(createIdxSQL).Error; err != nil { - return err - } - - // Create index on owner_id for foreign key lookups - if err := tx.Exec("CREATE INDEX IF NOT EXISTS idx_node_pools_owner_id ON node_pools(owner_id);").Error; err != nil { - return err - } - - // Add foreign key constraint to clusters - addFKSQL := ` - ALTER TABLE node_pools - ADD CONSTRAINT fk_node_pools_clusters - FOREIGN KEY (owner_id) REFERENCES clusters(id) - ON DELETE RESTRICT ON UPDATE RESTRICT; - ` - if err := tx.Exec(addFKSQL).Error; err != nil { - return err - } - - return nil - }, - Rollback: func(tx *gorm.DB) error { - // Drop foreign key constraint first - if err := tx.Exec("ALTER TABLE node_pools DROP CONSTRAINT IF EXISTS fk_node_pools_clusters;").Error; err != nil { - return err - } - - // Drop indexes - if err := tx.Exec("DROP INDEX IF EXISTS idx_node_pools_owner_id;").Error; err != nil { - return err - } - if err := tx.Exec("DROP INDEX IF EXISTS idx_node_pools_deleted_at;").Error; err != nil { - return err - } - - // Drop table - if err := tx.Exec("DROP TABLE IF EXISTS node_pools;").Error; err != nil { - return err - } - - return nil - }, - } -} diff --git a/pkg/db/migrations/202601210001_add_conditions_gin_index.go b/pkg/db/migrations/202601210001_add_conditions_gin_index.go deleted file mode 100644 index 2860f04..0000000 --- a/pkg/db/migrations/202601210001_add_conditions_gin_index.go +++ /dev/null @@ -1,46 +0,0 @@ -package migrations - -import ( - "github.com/go-gormigrate/gormigrate/v2" - "gorm.io/gorm" -) - -// addConditionsGinIndex adds expression indexes on the Ready condition -// within status_conditions JSONB columns for efficient lookups. -func addConditionsGinIndex() *gormigrate.Migration { - return &gormigrate.Migration{ - ID: "202601210001", - Migrate: func(tx *gorm.DB) error { - // Create expression index on clusters for Ready condition lookups - if err := tx.Exec(` - CREATE INDEX IF NOT EXISTS idx_clusters_ready_status - ON clusters USING BTREE (( - jsonb_path_query_first(status_conditions, '$[*] ? (@.type == "Ready")') - )); - `).Error; err != nil { - return err - } - - // Create expression index on node_pools for Ready condition lookups - if err := tx.Exec(` - CREATE INDEX IF NOT EXISTS idx_node_pools_ready_status - ON node_pools USING BTREE (( - jsonb_path_query_first(status_conditions, '$[*] ? (@.type == "Ready")') - )); - `).Error; err != nil { - return err - } - - return nil - }, - Rollback: func(tx *gorm.DB) error { - if err := tx.Exec("DROP INDEX IF EXISTS idx_clusters_ready_status;").Error; err != nil { - return err - } - if err := tx.Exec("DROP INDEX IF EXISTS idx_node_pools_ready_status;").Error; err != nil { - return err - } - return nil - }, - } -} diff --git a/pkg/db/migrations/202602070001_drop_cluster_nodepool_tables.go b/pkg/db/migrations/202602070001_drop_cluster_nodepool_tables.go new file mode 100644 index 0000000..dc08bf6 --- /dev/null +++ b/pkg/db/migrations/202602070001_drop_cluster_nodepool_tables.go @@ -0,0 +1,63 @@ +package migrations + +// This migration drops the legacy clusters and node_pools tables. +// These tables are replaced by the generic 'resources' table that +// handles all CRD-based resource types. + +import ( + "gorm.io/gorm" + + "github.com/go-gormigrate/gormigrate/v2" +) + +func dropClusterNodePoolTables() *gormigrate.Migration { + return &gormigrate.Migration{ + ID: "202602070001", + Migrate: func(tx *gorm.DB) error { + // Drop FK constraint from node_pools to clusters first + if err := tx.Exec("ALTER TABLE IF EXISTS node_pools DROP CONSTRAINT IF EXISTS fk_node_pools_clusters;").Error; err != nil { + return err + } + + // Drop indexes on node_pools + if err := tx.Exec("DROP INDEX IF EXISTS idx_node_pools_owner_id;").Error; err != nil { + return err + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_node_pools_deleted_at;").Error; err != nil { + return err + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_node_pools_status_conditions;").Error; err != nil { + return err + } + + // Drop node_pools table + if err := tx.Exec("DROP TABLE IF EXISTS node_pools;").Error; err != nil { + return err + } + + // Drop indexes on clusters + if err := tx.Exec("DROP INDEX IF EXISTS idx_clusters_name;").Error; err != nil { + return err + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_clusters_deleted_at;").Error; err != nil { + return err + } + if err := tx.Exec("DROP INDEX IF EXISTS idx_clusters_status_conditions;").Error; err != nil { + return err + } + + // Drop clusters table + if err := tx.Exec("DROP TABLE IF EXISTS clusters;").Error; err != nil { + return err + } + + return nil + }, + Rollback: func(tx *gorm.DB) error { + // Rollback would recreate the tables, but since we're removing this functionality, + // we don't provide a rollback. The resources table is the new canonical storage. + // If you need to rollback, restore from backup or re-run the old migrations. + return nil + }, + } +} diff --git a/pkg/db/migrations/migration_structs.go b/pkg/db/migrations/migration_structs.go index e9fad75..3473c58 100755 --- a/pkg/db/migrations/migration_structs.go +++ b/pkg/db/migrations/migration_structs.go @@ -27,12 +27,13 @@ import ( // // 4. Create one function in a separate file that returns your Migration. Add that single function call to this list. var MigrationList = []*gormigrate.Migration{ - // addEvents(), // REMOVED: Events table no longer used - no event-driven components - addClusters(), - addNodePools(), + // Legacy migrations removed: + // - addClusters() - replaced by generic resources table + // - addNodePools() - replaced by generic resources table + // - addConditionsGinIndex() - GIN index now in resources migration addAdapterStatus(), - addConditionsGinIndex(), - addResources(), // Generic resource table for CRD-driven API + addResources(), // Generic resource table for CRD-driven API + dropClusterNodePoolTables(), // Drop legacy tables (safe even if they don't exist) } // Model represents the base model struct. All entities will have this struct embedded. diff --git a/pkg/handlers/cluster.go b/pkg/handlers/cluster.go deleted file mode 100644 index 319524c..0000000 --- a/pkg/handlers/cluster.go +++ /dev/null @@ -1,168 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - - "github.com/gorilla/mux" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -var _ RestHandler = clusterHandler{} - -type clusterHandler struct { - cluster services.ClusterService - generic services.GenericService -} - -func NewClusterHandler(cluster services.ClusterService, generic services.GenericService) *clusterHandler { - return &clusterHandler{ - cluster: cluster, - generic: generic, - } -} - -func (h clusterHandler) Create(w http.ResponseWriter, r *http.Request) { - var req openapi.ClusterCreateRequest - cfg := &handlerConfig{ - &req, - []validate{ - validateEmpty(&req, "Id", "id"), - validateName(&req, "Name", "name", 3, 63), - validateKind(&req, "Kind", "kind", "Cluster"), - }, - func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - // Use the presenters.ConvertCluster helper to convert the request - clusterModel, err := presenters.ConvertCluster(&req, "system@hyperfleet.local") - if err != nil { - return nil, errors.GeneralError("Failed to convert cluster: %v", err) - } - clusterModel, svcErr := h.cluster.Create(ctx, clusterModel) - if svcErr != nil { - return nil, svcErr - } - presented, err := presenters.PresentCluster(clusterModel) - if err != nil { - return nil, errors.GeneralError("Failed to present cluster: %v", err) - } - return presented, nil - }, - handleError, - } - - handle(w, r, cfg, http.StatusCreated) -} - -func (h clusterHandler) Patch(w http.ResponseWriter, r *http.Request) { - var patch api.ClusterPatchRequest - - cfg := &handlerConfig{ - &patch, - []validate{}, - func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - id := mux.Vars(r)["id"] - found, err := h.cluster.Get(ctx, id) - if err != nil { - return nil, err - } - - if patch.Spec != nil { - specJSON, err := json.Marshal(*patch.Spec) - if err != nil { - return nil, errors.GeneralError("Failed to marshal spec: %v", err) - } - found.Spec = specJSON - } - - clusterModel, err := h.cluster.Replace(ctx, found) - if err != nil { - return nil, err - } - presented, presErr := presenters.PresentCluster(clusterModel) - if presErr != nil { - return nil, errors.GeneralError("Failed to present cluster: %v", presErr) - } - return presented, nil - }, - handleError, - } - - handle(w, r, cfg, http.StatusOK) -} - -func (h clusterHandler) List(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - - listArgs := services.NewListArguments(r.URL.Query()) - var clusters []api.Cluster - paging, err := h.generic.List(ctx, "username", listArgs, &clusters) - if err != nil { - return nil, err - } - clusterList := openapi.ClusterList{ - Kind: "ClusterList", - Page: int32(paging.Page), - Size: int32(paging.Size), - Total: int32(paging.Total), - Items: []openapi.Cluster{}, - } - - for _, cluster := range clusters { - presented, err := presenters.PresentCluster(&cluster) - if err != nil { - return nil, errors.GeneralError("Failed to present cluster: %v", err) - } - clusterList.Items = append(clusterList.Items, presented) - } - if listArgs.Fields != nil { - filteredItems, err := presenters.SliceFilter(listArgs.Fields, clusterList.Items) - if err != nil { - return nil, err - } - return filteredItems, nil - } - return clusterList, nil - }, - } - - handleList(w, r, cfg) -} - -func (h clusterHandler) Get(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - id := mux.Vars(r)["id"] - ctx := r.Context() - cluster, err := h.cluster.Get(ctx, id) - if err != nil { - return nil, err - } - - presented, presErr := presenters.PresentCluster(cluster) - if presErr != nil { - return nil, errors.GeneralError("Failed to present cluster: %v", presErr) - } - return presented, nil - }, - } - - handleGet(w, r, cfg) -} - -func (h clusterHandler) Delete(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - return nil, errors.NotImplemented("delete") - }, - } - handleDelete(w, r, cfg, http.StatusNoContent) -} diff --git a/pkg/handlers/cluster_nodepools.go b/pkg/handlers/cluster_nodepools.go deleted file mode 100644 index 03347a6..0000000 --- a/pkg/handlers/cluster_nodepools.go +++ /dev/null @@ -1,177 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gorilla/mux" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -type clusterNodePoolsHandler struct { - clusterService services.ClusterService - nodePoolService services.NodePoolService - generic services.GenericService -} - -func NewClusterNodePoolsHandler( - clusterService services.ClusterService, - nodePoolService services.NodePoolService, - generic services.GenericService, -) *clusterNodePoolsHandler { - return &clusterNodePoolsHandler{ - clusterService: clusterService, - nodePoolService: nodePoolService, - generic: generic, - } -} - -// List returns all nodepools for a cluster -func (h clusterNodePoolsHandler) List(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - - // Verify cluster exists - _, err := h.clusterService.Get(ctx, clusterID) - if err != nil { - return nil, err - } - - // Get nodepools with owner_id = clusterID - listArgs := services.NewListArguments(r.URL.Query()) - // Add filter for owner_id - if listArgs.Search == "" { - listArgs.Search = "owner_id = '" + clusterID + "'" - } else { - listArgs.Search = listArgs.Search + " AND owner_id = '" + clusterID + "'" - } - - var nodePools []api.NodePool - paging, err := h.generic.List(ctx, "username", listArgs, &nodePools) - if err != nil { - return nil, err - } - - // Build list response - items := make([]openapi.NodePool, 0, len(nodePools)) - for _, nodePool := range nodePools { - presented, err := presenters.PresentNodePool(&nodePool) - if err != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", err) - } - items = append(items, presented) - } - - nodePoolList := struct { - Kind string `json:"kind"` - Page int32 `json:"page"` - Size int32 `json:"size"` - Total int32 `json:"total"` - Items []openapi.NodePool `json:"items"` - }{ - Kind: "NodePoolList", - Page: int32(paging.Page), - Size: int32(paging.Size), - Total: int32(paging.Total), - Items: items, - } - - if listArgs.Fields != nil { - filteredItems, err := presenters.SliceFilter(listArgs.Fields, nodePoolList.Items) - if err != nil { - return nil, err - } - return filteredItems, nil - } - return nodePoolList, nil - }, - } - - handleList(w, r, cfg) -} - -// Get returns a specific nodepool for a cluster -func (h clusterNodePoolsHandler) Get(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - nodePoolID := mux.Vars(r)["nodepool_id"] - - // Verify cluster exists - _, err := h.clusterService.Get(ctx, clusterID) - if err != nil { - return nil, err - } - - // Get nodepool - nodePool, err := h.nodePoolService.Get(ctx, nodePoolID) - if err != nil { - return nil, err - } - - // Verify nodepool belongs to this cluster - if nodePool.OwnerID != clusterID { - return nil, errors.NotFound("NodePool '%s' not found for cluster '%s'", nodePoolID, clusterID) - } - - presented, presErr := presenters.PresentNodePool(nodePool) - if presErr != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", presErr) - } - return presented, nil - }, - } - - handleGet(w, r, cfg) -} - -// Create creates a new nodepool for a cluster -func (h clusterNodePoolsHandler) Create(w http.ResponseWriter, r *http.Request) { - var req openapi.NodePoolCreateRequest - cfg := &handlerConfig{ - &req, - []validate{ - validateEmpty(&req, "Id", "id"), - validateName(&req, "Name", "name", 1, 255), - validateKind(&req, "Kind", "kind", "NodePool"), - }, - func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - - // Verify cluster exists - cluster, err := h.clusterService.Get(ctx, clusterID) - if err != nil { - return nil, err - } - - // Use the presenters.ConvertNodePool helper to convert the request - nodePoolModel, convErr := presenters.ConvertNodePool(&req, cluster.ID, "system@hyperfleet.local") - if convErr != nil { - return nil, errors.GeneralError("Failed to convert nodepool: %v", convErr) - } - - // Create nodepool - nodePoolModel, err = h.nodePoolService.Create(ctx, nodePoolModel) - if err != nil { - return nil, err - } - - presented, presErr := presenters.PresentNodePool(nodePoolModel) - if presErr != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", presErr) - } - return presented, nil - }, - handleError, - } - - handle(w, r, cfg, http.StatusCreated) -} diff --git a/pkg/handlers/cluster_nodepools_test.go b/pkg/handlers/cluster_nodepools_test.go deleted file mode 100644 index 90b4f2f..0000000 --- a/pkg/handlers/cluster_nodepools_test.go +++ /dev/null @@ -1,203 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - "time" - - "github.com/gorilla/mux" - . "github.com/onsi/gomega" - "go.uber.org/mock/gomock" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -func TestClusterNodePoolsHandler_Get(t *testing.T) { - RegisterTestingT(t) - - now := time.Now() - clusterID := "test-cluster-123" - nodePoolID := "test-nodepool-456" - - tests := []struct { - name string - clusterID string - nodePoolID string - setupMocks func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, *services.MockGenericService, - ) - expectedStatusCode int - expectedError bool - }{ - { - name: "Success - Get nodepool by cluster and nodepool ID", - clusterID: clusterID, - nodePoolID: nodePoolID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, *services.MockGenericService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - - mockClusterSvc.EXPECT().Get(gomock.Any(), clusterID).Return(&api.Cluster{ - Meta: api.Meta{ - ID: clusterID, - CreatedTime: now, - UpdatedTime: now, - }, - Name: "test-cluster", - }, nil) - - mockNodePoolSvc.EXPECT().Get(gomock.Any(), nodePoolID).Return(&api.NodePool{ - Meta: api.Meta{ - ID: nodePoolID, - CreatedTime: now, - UpdatedTime: now, - }, - Kind: "NodePool", - Name: "test-nodepool", - OwnerID: clusterID, - Spec: []byte("{}"), - Labels: []byte("{}"), - StatusConditions: []byte("[]"), - CreatedBy: "user@example.com", - UpdatedBy: "user@example.com", - }, nil) - - return mockClusterSvc, mockNodePoolSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusOK, - expectedError: false, - }, - { - name: "Error - Cluster not found", - clusterID: "non-existent", - nodePoolID: nodePoolID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, *services.MockGenericService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - - mockClusterSvc.EXPECT().Get(gomock.Any(), "non-existent").Return(nil, errors.NotFound("Cluster not found")) - - return mockClusterSvc, mockNodePoolSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusNotFound, - expectedError: true, - }, - { - name: "Error - NodePool not found", - clusterID: clusterID, - nodePoolID: "non-existent", - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, *services.MockGenericService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - - mockClusterSvc.EXPECT().Get(gomock.Any(), clusterID).Return(&api.Cluster{ - Meta: api.Meta{ - ID: clusterID, - CreatedTime: now, - UpdatedTime: now, - }, - Name: "test-cluster", - }, nil) - - mockNodePoolSvc.EXPECT().Get(gomock.Any(), "non-existent").Return(nil, errors.NotFound("NodePool not found")) - - return mockClusterSvc, mockNodePoolSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusNotFound, - expectedError: true, - }, - { - name: "Error - NodePool belongs to different cluster", - clusterID: clusterID, - nodePoolID: nodePoolID, - setupMocks: func(ctrl *gomock.Controller) ( - *services.MockClusterService, *services.MockNodePoolService, *services.MockGenericService, - ) { - mockClusterSvc := services.NewMockClusterService(ctrl) - mockNodePoolSvc := services.NewMockNodePoolService(ctrl) - mockGenericSvc := services.NewMockGenericService(ctrl) - - mockClusterSvc.EXPECT().Get(gomock.Any(), clusterID).Return(&api.Cluster{ - Meta: api.Meta{ - ID: clusterID, - CreatedTime: now, - UpdatedTime: now, - }, - Name: "test-cluster", - }, nil) - - mockNodePoolSvc.EXPECT().Get(gomock.Any(), nodePoolID).Return(&api.NodePool{ - Meta: api.Meta{ - ID: nodePoolID, - CreatedTime: now, - UpdatedTime: now, - }, - Kind: "NodePool", - Name: "test-nodepool", - OwnerID: "different-cluster-789", // Different cluster - }, nil) - - return mockClusterSvc, mockNodePoolSvc, mockGenericSvc - }, - expectedStatusCode: http.StatusNotFound, - expectedError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - RegisterTestingT(t) - - // Create gomock controller - ctrl := gomock.NewController(t) - defer ctrl.Finish() - - // Setup mocks - mockClusterSvc, mockNodePoolSvc, mockGenericSvc := tt.setupMocks(ctrl) - - // Create handler - handler := NewClusterNodePoolsHandler(mockClusterSvc, mockNodePoolSvc, mockGenericSvc) - - // Create request - reqURL := "/api/hyperfleet/v1/clusters/" + tt.clusterID + "/nodepools/" + tt.nodePoolID - req := httptest.NewRequest(http.MethodGet, reqURL, nil) - req = mux.SetURLVars(req, map[string]string{ - "id": tt.clusterID, - "nodepool_id": tt.nodePoolID, - }) - - // Create response recorder - rr := httptest.NewRecorder() - - // Call handler - handler.Get(rr, req) - - // Check status code - Expect(rr.Code).To(Equal(tt.expectedStatusCode)) - - if !tt.expectedError { - // Parse response - var response openapi.NodePool - err := json.Unmarshal(rr.Body.Bytes(), &response) - Expect(err).NotTo(HaveOccurred()) - Expect(*response.Id).To(Equal(nodePoolID)) - Expect(response.Kind).NotTo(BeNil()) - Expect(*response.Kind).To(Equal("NodePool")) - } - }) - } -} diff --git a/pkg/handlers/cluster_status.go b/pkg/handlers/cluster_status.go deleted file mode 100644 index 9a7a363..0000000 --- a/pkg/handlers/cluster_status.go +++ /dev/null @@ -1,115 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gorilla/mux" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -type clusterStatusHandler struct { - adapterStatusService services.AdapterStatusService - clusterService services.ClusterService -} - -func NewClusterStatusHandler( - adapterStatusService services.AdapterStatusService, - clusterService services.ClusterService, -) *clusterStatusHandler { - return &clusterStatusHandler{ - adapterStatusService: adapterStatusService, - clusterService: clusterService, - } -} - -// List returns all adapter statuses for a cluster with pagination -func (h clusterStatusHandler) List(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - listArgs := services.NewListArguments(r.URL.Query()) - - // Fetch adapter statuses with pagination - adapterStatuses, total, err := h.adapterStatusService.FindByResourcePaginated(ctx, "Cluster", clusterID, listArgs) - if err != nil { - return nil, err - } - - // Convert to OpenAPI models - items := make([]openapi.AdapterStatus, 0, len(adapterStatuses)) - for _, as := range adapterStatuses { - presented, presErr := presenters.PresentAdapterStatus(as) - if presErr != nil { - return nil, errors.GeneralError("Failed to present adapter status: %v", presErr) - } - items = append(items, presented) - } - - // Return list response with pagination metadata - response := openapi.AdapterStatusList{ - Kind: "AdapterStatusList", - Items: items, - Page: int32(listArgs.Page), - Size: int32(len(items)), - Total: int32(total), - } - - return response, nil - }, - } - - handleList(w, r, cfg) -} - -// Create creates or updates an adapter status for a cluster -func (h clusterStatusHandler) Create(w http.ResponseWriter, r *http.Request) { - var req openapi.AdapterStatusCreateRequest - - cfg := &handlerConfig{ - &req, - []validate{ - validateNotEmpty(&req, "Adapter", "adapter"), - }, - func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - clusterID := mux.Vars(r)["id"] - - // Verify cluster exists - _, err := h.clusterService.Get(ctx, clusterID) - if err != nil { - return nil, err - } - - // Create adapter status from request - newStatus, convErr := presenters.ConvertAdapterStatus("Cluster", clusterID, &req) - if convErr != nil { - return nil, errors.GeneralError("Failed to convert adapter status: %v", convErr) - } - - // Process adapter status (handles Unknown status and upsert + aggregation) - adapterStatus, err := h.clusterService.ProcessAdapterStatus(ctx, clusterID, newStatus) - if err != nil { - return nil, err - } - - // If result is nil, return nil to signal 204 No Content - if adapterStatus == nil { - return nil, nil - } - - status, presErr := presenters.PresentAdapterStatus(adapterStatus) - if presErr != nil { - return nil, errors.GeneralError("Failed to present adapter status: %v", presErr) - } - return &status, nil - }, - handleError, - } - - handleCreateWithNoContent(w, r, cfg) -} diff --git a/pkg/handlers/node_pool.go b/pkg/handlers/node_pool.go deleted file mode 100644 index efdf4f5..0000000 --- a/pkg/handlers/node_pool.go +++ /dev/null @@ -1,180 +0,0 @@ -package handlers - -import ( - "encoding/json" - "net/http" - - "github.com/gorilla/mux" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -var _ RestHandler = nodePoolHandler{} - -type nodePoolHandler struct { - nodePool services.NodePoolService - generic services.GenericService -} - -func NewNodePoolHandler(nodePool services.NodePoolService, generic services.GenericService) *nodePoolHandler { - return &nodePoolHandler{ - nodePool: nodePool, - generic: generic, - } -} - -func (h nodePoolHandler) Create(w http.ResponseWriter, r *http.Request) { - var req openapi.NodePoolCreateRequest - cfg := &handlerConfig{ - &req, - []validate{ - validateEmpty(&req, "Id", "id"), - }, - func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - // For standalone nodepools, owner_id would need to come from somewhere - // This is likely not a supported use case, but using empty string for now - nodePoolModel, convErr := presenters.ConvertNodePool(&req, "", "system@hyperfleet.local") - if convErr != nil { - return nil, errors.GeneralError("Failed to convert nodepool: %v", convErr) - } - nodePoolModel, err := h.nodePool.Create(ctx, nodePoolModel) - if err != nil { - return nil, err - } - presented, presErr := presenters.PresentNodePool(nodePoolModel) - if presErr != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", presErr) - } - return presented, nil - }, - handleError, - } - - handle(w, r, cfg, http.StatusCreated) -} - -func (h nodePoolHandler) Patch(w http.ResponseWriter, r *http.Request) { - var patch api.NodePoolPatchRequest - - cfg := &handlerConfig{ - &patch, - []validate{}, - func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - id := mux.Vars(r)["id"] - found, err := h.nodePool.Get(ctx, id) - if err != nil { - return nil, err - } - - if patch.Spec != nil { - specJSON, err := json.Marshal(*patch.Spec) - if err != nil { - return nil, errors.GeneralError("Failed to marshal spec: %v", err) - } - found.Spec = specJSON - } - // Note: OwnerID should not be changed after creation - // if patch.OwnerID != nil { - // found.OwnerID = *patch.OwnerID - // } - - nodePoolModel, err := h.nodePool.Replace(ctx, found) - if err != nil { - return nil, err - } - presented, presErr := presenters.PresentNodePool(nodePoolModel) - if presErr != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", presErr) - } - return presented, nil - }, - handleError, - } - - handle(w, r, cfg, http.StatusOK) -} - -func (h nodePoolHandler) List(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - - listArgs := services.NewListArguments(r.URL.Query()) - var nodePools []api.NodePool - paging, err := h.generic.List(ctx, "username", listArgs, &nodePools) - if err != nil { - return nil, err - } - // Build list response manually since there's no NodePoolList in OpenAPI - items := make([]openapi.NodePool, 0, len(nodePools)) - - for _, nodePool := range nodePools { - presented, err := presenters.PresentNodePool(&nodePool) - if err != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", err) - } - items = append(items, presented) - } - - nodePoolList := struct { - Kind string `json:"kind"` - Page int32 `json:"page"` - Size int32 `json:"size"` - Total int32 `json:"total"` - Items []openapi.NodePool `json:"items"` - }{ - Kind: "NodePoolList", - Page: int32(paging.Page), - Size: int32(paging.Size), - Total: int32(paging.Total), - Items: items, - } - if listArgs.Fields != nil { - filteredItems, err := presenters.SliceFilter(listArgs.Fields, nodePoolList.Items) - if err != nil { - return nil, err - } - return filteredItems, nil - } - return nodePoolList, nil - }, - } - - handleList(w, r, cfg) -} - -func (h nodePoolHandler) Get(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - id := mux.Vars(r)["id"] - ctx := r.Context() - nodePool, err := h.nodePool.Get(ctx, id) - if err != nil { - return nil, err - } - - presented, presErr := presenters.PresentNodePool(nodePool) - if presErr != nil { - return nil, errors.GeneralError("Failed to present nodepool: %v", presErr) - } - return presented, nil - }, - } - - handleGet(w, r, cfg) -} - -func (h nodePoolHandler) Delete(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - return nil, errors.NotImplemented("delete") - }, - } - handleDelete(w, r, cfg, http.StatusNoContent) -} diff --git a/pkg/handlers/nodepool_status.go b/pkg/handlers/nodepool_status.go deleted file mode 100644 index e6ded0c..0000000 --- a/pkg/handlers/nodepool_status.go +++ /dev/null @@ -1,116 +0,0 @@ -package handlers - -import ( - "net/http" - - "github.com/gorilla/mux" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" -) - -type nodePoolStatusHandler struct { - adapterStatusService services.AdapterStatusService - nodePoolService services.NodePoolService -} - -func NewNodePoolStatusHandler( - adapterStatusService services.AdapterStatusService, - nodePoolService services.NodePoolService, -) *nodePoolStatusHandler { - return &nodePoolStatusHandler{ - adapterStatusService: adapterStatusService, - nodePoolService: nodePoolService, - } -} - -// List returns all adapter statuses for a nodepool with pagination -func (h nodePoolStatusHandler) List(w http.ResponseWriter, r *http.Request) { - cfg := &handlerConfig{ - Action: func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - nodePoolID := mux.Vars(r)[logger.FieldNodePoolID] - listArgs := services.NewListArguments(r.URL.Query()) - - // Fetch adapter statuses with pagination - adapterStatuses, total, err := h.adapterStatusService.FindByResourcePaginated(ctx, "NodePool", nodePoolID, listArgs) - if err != nil { - return nil, err - } - - // Convert to OpenAPI models - items := make([]openapi.AdapterStatus, 0, len(adapterStatuses)) - for _, as := range adapterStatuses { - presented, presErr := presenters.PresentAdapterStatus(as) - if presErr != nil { - return nil, errors.GeneralError("Failed to present adapter status: %v", presErr) - } - items = append(items, presented) - } - - // Return list response with pagination metadata - response := openapi.AdapterStatusList{ - Kind: "AdapterStatusList", - Items: items, - Page: int32(listArgs.Page), - Size: int32(len(items)), - Total: int32(total), - } - - return response, nil - }, - } - - handleList(w, r, cfg) -} - -// Create creates or updates an adapter status for a nodepool -func (h nodePoolStatusHandler) Create(w http.ResponseWriter, r *http.Request) { - var req openapi.AdapterStatusCreateRequest - - cfg := &handlerConfig{ - &req, - []validate{ - validateNotEmpty(&req, "Adapter", "adapter"), - }, - func() (interface{}, *errors.ServiceError) { - ctx := r.Context() - nodePoolID := mux.Vars(r)[logger.FieldNodePoolID] - - // Verify nodepool exists - _, err := h.nodePoolService.Get(ctx, nodePoolID) - if err != nil { - return nil, err - } - - // Create adapter status from request - newStatus, convErr := presenters.ConvertAdapterStatus("NodePool", nodePoolID, &req) - if convErr != nil { - return nil, errors.GeneralError("Failed to convert adapter status: %v", convErr) - } - - // Process adapter status (handles Unknown status and upsert + aggregation) - adapterStatus, err := h.nodePoolService.ProcessAdapterStatus(ctx, nodePoolID, newStatus) - if err != nil { - return nil, err - } - - // If result is nil, return nil to signal 204 No Content - if adapterStatus == nil { - return nil, nil - } - - status, presErr := presenters.PresentAdapterStatus(adapterStatus) - if presErr != nil { - return nil, errors.GeneralError("Failed to present adapter status: %v", presErr) - } - return &status, nil - }, - handleError, - } - - handleCreateWithNoContent(w, r, cfg) -} diff --git a/pkg/handlers/resource.go b/pkg/handlers/resource.go index 7d0cf13..6e94132 100644 --- a/pkg/handlers/resource.go +++ b/pkg/handlers/resource.go @@ -4,10 +4,12 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "github.com/gorilla/mux" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/crd" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" ) @@ -348,15 +350,11 @@ func (h *ResourceHandler) presentResource(resource *api.Resource) map[string]int } // getOwnerPlural returns the plural form of an owner kind. -// This is a simple mapping; in production, you'd look this up from the CRD registry. +// It looks up the plural from the CRD registry. func getOwnerPlural(kind string) string { - plurals := map[string]string{ - "Cluster": "clusters", - "NodePool": "nodepools", - } - if plural, ok := plurals[kind]; ok { - return plural + if def, found := crd.GetByKind(kind); found { + return def.Plural } // Default: lowercase + "s" - return kind + "s" + return strings.ToLower(kind) + "s" } diff --git a/pkg/services/cluster.go b/pkg/services/cluster.go deleted file mode 100644 index 9d85135..0000000 --- a/pkg/services/cluster.go +++ /dev/null @@ -1,300 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - stderrors "errors" - "strings" - "time" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" - "gorm.io/gorm" -) - -//go:generate mockgen-v0.6.0 -source=cluster.go -package=services -destination=cluster_mock.go - -type ClusterService interface { - Get(ctx context.Context, id string) (*api.Cluster, *errors.ServiceError) - Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, *errors.ServiceError) - Replace(ctx context.Context, cluster *api.Cluster) (*api.Cluster, *errors.ServiceError) - Delete(ctx context.Context, id string) *errors.ServiceError - All(ctx context.Context) (api.ClusterList, *errors.ServiceError) - - FindByIDs(ctx context.Context, ids []string) (api.ClusterList, *errors.ServiceError) - - // Status aggregation - UpdateClusterStatusFromAdapters(ctx context.Context, clusterID string) (*api.Cluster, *errors.ServiceError) - - // ProcessAdapterStatus handles the business logic for adapter status: - // - If Available condition is "Unknown": returns (nil, nil) indicating no-op - // - Otherwise: upserts the status and triggers aggregation - ProcessAdapterStatus( - ctx context.Context, clusterID string, adapterStatus *api.AdapterStatus, - ) (*api.AdapterStatus, *errors.ServiceError) - - // idempotent functions for the control plane, but can also be called synchronously by any actor - OnUpsert(ctx context.Context, id string) error - OnDelete(ctx context.Context, id string) error -} - -func NewClusterService( - clusterDao dao.ClusterDao, - adapterStatusDao dao.AdapterStatusDao, - adapterConfig *config.AdapterRequirementsConfig, -) ClusterService { - return &sqlClusterService{ - clusterDao: clusterDao, - adapterStatusDao: adapterStatusDao, - adapterConfig: adapterConfig, - } -} - -var _ ClusterService = &sqlClusterService{} - -type sqlClusterService struct { - clusterDao dao.ClusterDao - adapterStatusDao dao.AdapterStatusDao - adapterConfig *config.AdapterRequirementsConfig -} - -func (s *sqlClusterService) Get(ctx context.Context, id string) (*api.Cluster, *errors.ServiceError) { - cluster, err := s.clusterDao.Get(ctx, id) - if err != nil { - return nil, handleGetError("Cluster", "id", id, err) - } - return cluster, nil -} - -func (s *sqlClusterService) Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, *errors.ServiceError) { - if cluster.Generation == 0 { - cluster.Generation = 1 - } - - cluster, err := s.clusterDao.Create(ctx, cluster) - if err != nil { - return nil, handleCreateError("Cluster", err) - } - - updatedCluster, svcErr := s.UpdateClusterStatusFromAdapters(ctx, cluster.ID) - if svcErr != nil { - return nil, svcErr - } - - // REMOVED: Event creation - no event-driven components - return updatedCluster, nil -} - -func (s *sqlClusterService) Replace(ctx context.Context, cluster *api.Cluster) (*api.Cluster, *errors.ServiceError) { - cluster, err := s.clusterDao.Replace(ctx, cluster) - if err != nil { - return nil, handleUpdateError("Cluster", err) - } - - // REMOVED: Event creation - no event-driven components - return cluster, nil -} - -func (s *sqlClusterService) Delete(ctx context.Context, id string) *errors.ServiceError { - if err := s.clusterDao.Delete(ctx, id); err != nil { - return handleDeleteError("Cluster", errors.GeneralError("Unable to delete cluster: %s", err)) - } - - // REMOVED: Event creation - no event-driven components - return nil -} - -func (s *sqlClusterService) FindByIDs(ctx context.Context, ids []string) (api.ClusterList, *errors.ServiceError) { - clusters, err := s.clusterDao.FindByIDs(ctx, ids) - if err != nil { - return nil, errors.GeneralError("Unable to get all clusters: %s", err) - } - return clusters, nil -} - -func (s *sqlClusterService) All(ctx context.Context) (api.ClusterList, *errors.ServiceError) { - clusters, err := s.clusterDao.All(ctx) - if err != nil { - return nil, errors.GeneralError("Unable to get all clusters: %s", err) - } - return clusters, nil -} - -func (s *sqlClusterService) OnUpsert(ctx context.Context, id string) error { - cluster, err := s.clusterDao.Get(ctx, id) - if err != nil { - return err - } - - ctx = logger.WithClusterID(ctx, cluster.ID) - logger.Info(ctx, "Perform idempotent operations on cluster") - - return nil -} - -func (s *sqlClusterService) OnDelete(ctx context.Context, id string) error { - ctx = logger.WithClusterID(ctx, id) - logger.Info(ctx, "Cluster has been deleted") - return nil -} - -// UpdateClusterStatusFromAdapters aggregates adapter statuses into cluster status -func (s *sqlClusterService) UpdateClusterStatusFromAdapters( - ctx context.Context, clusterID string, -) (*api.Cluster, *errors.ServiceError) { - // Get the cluster - cluster, err := s.clusterDao.Get(ctx, clusterID) - if err != nil { - return nil, handleGetError("Cluster", "id", clusterID, err) - } - - // Get all adapter statuses for this cluster - adapterStatuses, err := s.adapterStatusDao.FindByResource(ctx, "Cluster", clusterID) - if err != nil { - return nil, errors.GeneralError("Failed to get adapter statuses: %s", err) - } - - now := time.Now() - - // Build the list of adapter ResourceConditions - adapterConditions := []api.ResourceCondition{} - - for _, adapterStatus := range adapterStatuses { - // Unmarshal Conditions from JSONB - var conditions []api.AdapterCondition - if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil { - continue // Skip if can't unmarshal - } - - // Find the "Available" condition - var availableCondition *api.AdapterCondition - for i := range conditions { - if conditions[i].Type == "Available" { - availableCondition = &conditions[i] - break - } - } - - if availableCondition == nil { - // No Available condition, skip this adapter - continue - } - - // Convert to ResourceCondition - condResource := api.ResourceCondition{ - Type: MapAdapterToConditionType(adapterStatus.Adapter), - Status: api.ResourceConditionStatus(availableCondition.Status), - Reason: availableCondition.Reason, - Message: availableCondition.Message, - ObservedGeneration: adapterStatus.ObservedGeneration, - LastTransitionTime: availableCondition.LastTransitionTime, - } - - // Set CreatedTime with nil check - if adapterStatus.CreatedTime != nil { - condResource.CreatedTime = *adapterStatus.CreatedTime - } - - // Set LastUpdatedTime with nil check - if adapterStatus.LastReportTime != nil { - condResource.LastUpdatedTime = *adapterStatus.LastReportTime - } - - adapterConditions = append(adapterConditions, condResource) - } - - // Compute synthetic Available and Ready conditions - availableCondition, readyCondition := BuildSyntheticConditions( - cluster.StatusConditions, - adapterStatuses, - s.adapterConfig.RequiredClusterAdapters, - cluster.Generation, - now, - ) - - // Combine synthetic conditions with adapter conditions - // Put Available and Ready first - allConditions := []api.ResourceCondition{availableCondition, readyCondition} - allConditions = append(allConditions, adapterConditions...) - - // Marshal conditions to JSON - conditionsJSON, err := json.Marshal(allConditions) - if err != nil { - return nil, errors.GeneralError("Failed to marshal conditions: %s", err) - } - cluster.StatusConditions = conditionsJSON - - // Save the updated cluster - cluster, err = s.clusterDao.Replace(ctx, cluster) - if err != nil { - return nil, handleUpdateError("Cluster", err) - } - - return cluster, nil -} - -// ProcessAdapterStatus handles the business logic for adapter status: -// - If Available condition is "Unknown": returns (nil, nil) indicating no-op -// - Otherwise: upserts the status and triggers aggregation -func (s *sqlClusterService) ProcessAdapterStatus( - ctx context.Context, clusterID string, adapterStatus *api.AdapterStatus, -) (*api.AdapterStatus, *errors.ServiceError) { - existingStatus, findErr := s.adapterStatusDao.FindByResourceAndAdapter( - ctx, "Cluster", clusterID, adapterStatus.Adapter, - ) - if findErr != nil && !stderrors.Is(findErr, gorm.ErrRecordNotFound) { - if !strings.Contains(findErr.Error(), errors.CodeNotFoundGeneric) { - return nil, errors.GeneralError("Failed to get adapter status: %s", findErr) - } - } - if existingStatus != nil && adapterStatus.ObservedGeneration < existingStatus.ObservedGeneration { - // Discard stale status updates (older observed_generation). - return nil, nil - } - - // Parse conditions from the adapter status - var conditions []api.AdapterCondition - if len(adapterStatus.Conditions) > 0 { - if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil { - return nil, errors.GeneralError("Failed to unmarshal adapter status conditions: %s", err) - } - } - - // Find the "Available" condition - hasAvailableCondition := false - for _, cond := range conditions { - if cond.Type != "Available" { - continue - } - - hasAvailableCondition = true - if cond.Status == api.AdapterConditionUnknown { - // Available condition is "Unknown", return nil to indicate no-op - return nil, nil - } - } - - // Upsert the adapter status - upsertedStatus, err := s.adapterStatusDao.Upsert(ctx, adapterStatus) - if err != nil { - return nil, handleCreateError("AdapterStatus", err) - } - - // Only trigger aggregation when the adapter reported an Available condition. - // If the adapter status doesn't include Available (e.g. it only reports Ready/Progressing), - // saving it should not overwrite the cluster's synthetic Available/Ready conditions. - if hasAvailableCondition { - if _, aggregateErr := s.UpdateClusterStatusFromAdapters( - ctx, clusterID, - ); aggregateErr != nil { - // Log error but don't fail the request - the status will be computed on next update - ctx = logger.WithClusterID(ctx, clusterID) - logger.WithError(ctx, aggregateErr).Warn("Failed to aggregate cluster status") - } - } - - return upsertedStatus, nil -} diff --git a/pkg/services/cluster_test.go b/pkg/services/cluster_test.go deleted file mode 100644 index 4c9f436..0000000 --- a/pkg/services/cluster_test.go +++ /dev/null @@ -1,742 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - "testing" - "time" - - . "github.com/onsi/gomega" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" -) - -const ( - testClusterID = "test-cluster-id" -) - -// Mock implementations for testing ProcessAdapterStatus - -type mockClusterDao struct { - clusters map[string]*api.Cluster -} - -func newMockClusterDao() *mockClusterDao { - return &mockClusterDao{ - clusters: make(map[string]*api.Cluster), - } -} - -func (d *mockClusterDao) Get(ctx context.Context, id string) (*api.Cluster, error) { - if c, ok := d.clusters[id]; ok { - return c, nil - } - return nil, errors.NotFound("Cluster").AsError() -} - -func (d *mockClusterDao) Create(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) { - d.clusters[cluster.ID] = cluster - return cluster, nil -} - -func (d *mockClusterDao) Replace(ctx context.Context, cluster *api.Cluster) (*api.Cluster, error) { - d.clusters[cluster.ID] = cluster - return cluster, nil -} - -func (d *mockClusterDao) Delete(ctx context.Context, id string) error { - delete(d.clusters, id) - return nil -} - -func (d *mockClusterDao) FindByIDs(ctx context.Context, ids []string) (api.ClusterList, error) { - var result api.ClusterList - for _, id := range ids { - if c, ok := d.clusters[id]; ok { - result = append(result, c) - } - } - return result, nil -} - -func (d *mockClusterDao) All(ctx context.Context) (api.ClusterList, error) { - var result api.ClusterList - for _, c := range d.clusters { - result = append(result, c) - } - return result, nil -} - -var _ dao.ClusterDao = &mockClusterDao{} - -type mockAdapterStatusDao struct { - statuses map[string]*api.AdapterStatus -} - -func newMockAdapterStatusDao() *mockAdapterStatusDao { - return &mockAdapterStatusDao{ - statuses: make(map[string]*api.AdapterStatus), - } -} - -func (d *mockAdapterStatusDao) Get(ctx context.Context, id string) (*api.AdapterStatus, error) { - if s, ok := d.statuses[id]; ok { - return s, nil - } - return nil, errors.NotFound("AdapterStatus").AsError() -} - -func (d *mockAdapterStatusDao) Create(ctx context.Context, status *api.AdapterStatus) (*api.AdapterStatus, error) { - d.statuses[status.ID] = status - return status, nil -} - -func (d *mockAdapterStatusDao) Replace(ctx context.Context, status *api.AdapterStatus) (*api.AdapterStatus, error) { - d.statuses[status.ID] = status - return status, nil -} - -func (d *mockAdapterStatusDao) Upsert(ctx context.Context, status *api.AdapterStatus) (*api.AdapterStatus, error) { - key := status.ResourceType + ":" + status.ResourceID + ":" + status.Adapter - status.ID = key - d.statuses[key] = status - return status, nil -} - -func (d *mockAdapterStatusDao) Delete(ctx context.Context, id string) error { - delete(d.statuses, id) - return nil -} - -func (d *mockAdapterStatusDao) FindByResource( - ctx context.Context, - resourceType, resourceID string, -) (api.AdapterStatusList, error) { - var result api.AdapterStatusList - for _, s := range d.statuses { - if s.ResourceType == resourceType && s.ResourceID == resourceID { - result = append(result, s) - } - } - return result, nil -} - -func (d *mockAdapterStatusDao) FindByResourcePaginated( - ctx context.Context, - resourceType, resourceID string, - offset, limit int, -) (api.AdapterStatusList, int64, error) { - statuses, _ := d.FindByResource(ctx, resourceType, resourceID) - return statuses, int64(len(statuses)), nil -} - -func (d *mockAdapterStatusDao) FindByResourceAndAdapter( - ctx context.Context, - resourceType, resourceID, adapter string, -) (*api.AdapterStatus, error) { - for _, s := range d.statuses { - if s.ResourceType == resourceType && s.ResourceID == resourceID && s.Adapter == adapter { - return s, nil - } - } - return nil, errors.NotFound("AdapterStatus").AsError() -} - -func (d *mockAdapterStatusDao) All(ctx context.Context) (api.AdapterStatusList, error) { - var result api.AdapterStatusList - for _, s := range d.statuses { - result = append(result, s) - } - return result, nil -} - -var _ dao.AdapterStatusDao = &mockAdapterStatusDao{} - -// TestProcessAdapterStatus_UnknownCondition tests that Unknown Available condition returns nil (no-op) -func TestProcessAdapterStatus_UnknownCondition(t *testing.T) { - RegisterTestingT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := config.NewAdapterRequirementsConfig() - service := NewClusterService(clusterDao, adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create adapter status with Available=Unknown - conditions := []api.AdapterCondition{ - { - Type: conditionTypeAvailable, - Status: api.AdapterConditionUnknown, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - adapterStatus := &api.AdapterStatus{ - ResourceType: "Cluster", - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - Expect(err).To(BeNil()) - Expect(result).To(BeNil(), "ProcessAdapterStatus should return nil for Unknown status") - - // Verify nothing was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, "Cluster", clusterID) - Expect(len(storedStatuses)).To(Equal(0), "No status should be stored for Unknown") -} - -// TestProcessAdapterStatus_TrueCondition tests that True Available condition upserts and aggregates -func TestProcessAdapterStatus_TrueCondition(t *testing.T) { - RegisterTestingT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := config.NewAdapterRequirementsConfig() - service := NewClusterService(clusterDao, adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create the cluster first - cluster := &api.Cluster{ - Generation: 1, - } - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - Expect(svcErr).To(BeNil()) - - // Create adapter status with Available=True - conditions := []api.AdapterCondition{ - { - Type: conditionTypeAvailable, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: "Cluster", - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - CreatedTime: &now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil(), "ProcessAdapterStatus should return the upserted status") - Expect(result.Adapter).To(Equal("test-adapter")) - - // Verify the status was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, "Cluster", clusterID) - Expect(len(storedStatuses)).To(Equal(1), "Status should be stored for True condition") -} - -// TestProcessAdapterStatus_FalseCondition tests that False Available condition upserts and aggregates -func TestProcessAdapterStatus_FalseCondition(t *testing.T) { - RegisterTestingT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := config.NewAdapterRequirementsConfig() - service := NewClusterService(clusterDao, adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create the cluster first - cluster := &api.Cluster{ - Generation: 1, - } - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - Expect(svcErr).To(BeNil()) - - // Create adapter status with Available=False - conditions := []api.AdapterCondition{ - { - Type: conditionTypeAvailable, - Status: api.AdapterConditionFalse, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: "Cluster", - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - CreatedTime: &now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil(), "ProcessAdapterStatus should return the upserted status") - - // Verify the status was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, "Cluster", clusterID) - Expect(len(storedStatuses)).To(Equal(1), "Status should be stored for False condition") -} - -// TestProcessAdapterStatus_NoAvailableCondition tests when there's no Available condition -func TestProcessAdapterStatus_NoAvailableCondition(t *testing.T) { - RegisterTestingT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := config.NewAdapterRequirementsConfig() - service := NewClusterService(clusterDao, adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create the cluster first - fixedNow := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) - initialConditions := []api.ResourceCondition{ - { - Type: conditionTypeAvailable, - Status: api.ConditionFalse, - ObservedGeneration: 1, - LastTransitionTime: fixedNow, - CreatedTime: fixedNow, - LastUpdatedTime: fixedNow, - }, - { - Type: "Ready", - Status: api.ConditionFalse, - ObservedGeneration: 7, - LastTransitionTime: fixedNow, - CreatedTime: fixedNow, - LastUpdatedTime: fixedNow, - }, - } - initialConditionsJSON, _ := json.Marshal(initialConditions) - - cluster := &api.Cluster{ - Generation: 7, - StatusConditions: initialConditionsJSON, - } - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - Expect(svcErr).To(BeNil()) - initialClusterStatusConditions := api.Cluster{}.StatusConditions - initialClusterStatusConditions = append(initialClusterStatusConditions, cluster.StatusConditions...) - - // Create adapter status with Health condition (no Available) - conditions := []api.AdapterCondition{ - { - Type: "Health", - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: "Cluster", - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - CreatedTime: &now, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil(), "ProcessAdapterStatus should proceed when no Available condition") - - // Verify the status was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, "Cluster", clusterID) - Expect(len(storedStatuses)).To(Equal(1), "Status should be stored when no Available condition") - - // Verify that saving a non-Available condition did not overwrite cluster Available/Ready - storedCluster, _ := clusterDao.Get(ctx, clusterID) - Expect(storedCluster.StatusConditions).To(Equal(initialClusterStatusConditions), - "Cluster status conditions should not be overwritten when adapter status lacks Available") -} - -// TestProcessAdapterStatus_MultipleConditions_AvailableUnknown tests multiple conditions with Available=Unknown -func TestProcessAdapterStatus_MultipleConditions_AvailableUnknown(t *testing.T) { - RegisterTestingT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := config.NewAdapterRequirementsConfig() - service := NewClusterService(clusterDao, adapterStatusDao, config) - - ctx := context.Background() - clusterID := testClusterID - - // Create adapter status with multiple conditions including Available=Unknown - conditions := []api.AdapterCondition{ - { - Type: "Ready", - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: conditionTypeAvailable, - Status: api.AdapterConditionUnknown, - LastTransitionTime: time.Now(), - }, - { - Type: "Progressing", - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - adapterStatus := &api.AdapterStatus{ - ResourceType: "Cluster", - ResourceID: clusterID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - } - - result, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - - Expect(err).To(BeNil()) - Expect(result).To(BeNil(), "ProcessAdapterStatus should return nil when Available=Unknown") - - // Verify nothing was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, "Cluster", clusterID) - Expect(len(storedStatuses)).To(Equal(0), "No status should be stored for Unknown") -} - -func TestClusterAvailableReadyTransitions(t *testing.T) { - RegisterTestingT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := config.NewAdapterRequirementsConfig() - // Keep this small so we can cover transitions succinctly. - adapterConfig.RequiredClusterAdapters = []string{"validation", "dns"} - - service := NewClusterService(clusterDao, adapterStatusDao, adapterConfig) - - ctx := context.Background() - clusterID := testClusterID - - cluster := &api.Cluster{Generation: 1} - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - Expect(svcErr).To(BeNil()) - - getSynth := func() (api.ResourceCondition, api.ResourceCondition) { - stored, getErr := clusterDao.Get(ctx, clusterID) - Expect(getErr).To(BeNil()) - - var conds []api.ResourceCondition - Expect(json.Unmarshal(stored.StatusConditions, &conds)).To(Succeed()) - Expect(len(conds)).To(BeNumerically(">=", 2)) - - var available, ready *api.ResourceCondition - for i := range conds { - switch conds[i].Type { - case conditionTypeAvailable: - available = &conds[i] - case conditionTypeReady: - ready = &conds[i] - } - } - Expect(available).ToNot(BeNil()) - Expect(ready).ToNot(BeNil()) - return *available, *ready - } - - upsert := func(adapter string, available api.AdapterConditionStatus, observedGen int32) { - conditions := []api.AdapterCondition{ - {Type: conditionTypeAvailable, Status: available, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - - adapterStatus := &api.AdapterStatus{ - ResourceType: "Cluster", - ResourceID: clusterID, - Adapter: adapter, - ObservedGeneration: observedGen, - Conditions: conditionsJSON, - CreatedTime: &now, - LastReportTime: &now, - } - - _, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - Expect(err).To(BeNil()) - } - - // No adapter statuses yet. - _, err := service.UpdateClusterStatusFromAdapters(ctx, clusterID) - Expect(err).To(BeNil()) - avail, ready := getSynth() - Expect(avail.Status).To(Equal(api.ConditionFalse)) - Expect(avail.ObservedGeneration).To(Equal(int32(1))) - Expect(ready.Status).To(Equal(api.ConditionFalse)) - Expect(ready.ObservedGeneration).To(Equal(int32(1))) - - // Partial adapters: still not Available/Ready. - upsert("validation", api.AdapterConditionTrue, 1) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionFalse)) - Expect(ready.Status).To(Equal(api.ConditionFalse)) - - // All required adapters available at gen=1 => Available=True, Ready=True. - upsert("dns", api.AdapterConditionTrue, 1) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionTrue)) - Expect(avail.ObservedGeneration).To(Equal(int32(1))) - Expect(ready.Status).To(Equal(api.ConditionTrue)) - Expect(ready.ObservedGeneration).To(Equal(int32(1))) - - // Bump resource generation => Ready flips to False; Available remains True. - clusterDao.clusters[clusterID].Generation = 2 - _, err = service.UpdateClusterStatusFromAdapters(ctx, clusterID) - Expect(err).To(BeNil()) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionTrue)) - Expect(avail.ObservedGeneration).To(Equal(int32(1))) - Expect(ready.Status).To(Equal(api.ConditionFalse)) - Expect(ready.ObservedGeneration).To(Equal(int32(2))) - - // One adapter updates to gen=2 => Ready still False; Available still True (minObservedGeneration still 1). - upsert("validation", api.AdapterConditionTrue, 2) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionTrue)) - Expect(avail.ObservedGeneration).To(Equal(int32(1))) - Expect(ready.Status).To(Equal(api.ConditionFalse)) - - // One adapter updates to gen=1 => Ready still False; Available still True (minObservedGeneration still 1). - // This is an edge case where an adapter reports a gen=1 status after a gen=2 status. - // Since we don't allow downgrading observed generations, we should not overwrite the cluster conditions. - // And Available should remain True, but in reality it should be False. - // This should be an unexpected edge case, since once a resource changes generation, - // all adapters should report a gen=2 status. - // So, while we are keeping Available True for gen=1, - // there should be soon an update to gen=2, which will overwrite the Available condition. - upsert("validation", api.AdapterConditionFalse, 1) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionTrue)) // <-- this is the edge case - Expect(avail.ObservedGeneration).To(Equal(int32(1))) - Expect(ready.Status).To(Equal(api.ConditionFalse)) - - // All required adapters at gen=2 => Ready becomes True, Available minObservedGeneration becomes 2. - upsert("dns", api.AdapterConditionTrue, 2) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionTrue)) - Expect(avail.ObservedGeneration).To(Equal(int32(2))) - Expect(ready.Status).To(Equal(api.ConditionTrue)) - - // One required adapter goes False => both Available and Ready become False. - upsert("dns", api.AdapterConditionFalse, 2) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionFalse)) - Expect(avail.ObservedGeneration).To(Equal(int32(0))) - Expect(ready.Status).To(Equal(api.ConditionFalse)) - - // Available=Unknown is a no-op (does not store, does not overwrite cluster conditions). - prevStatus := api.Cluster{}.StatusConditions - prevStatus = append(prevStatus, clusterDao.clusters[clusterID].StatusConditions...) - unknownConds := []api.AdapterCondition{ - {Type: conditionTypeAvailable, Status: api.AdapterConditionUnknown, LastTransitionTime: time.Now()}, - } - unknownJSON, _ := json.Marshal(unknownConds) - unknownStatus := &api.AdapterStatus{ - ResourceType: "Cluster", - ResourceID: clusterID, - Adapter: "dns", - Conditions: unknownJSON, - } - result, svcErr := service.ProcessAdapterStatus(ctx, clusterID, unknownStatus) - Expect(svcErr).To(BeNil()) - Expect(result).To(BeNil()) - Expect(clusterDao.clusters[clusterID].StatusConditions).To(Equal(prevStatus)) -} - -func TestClusterStaleAdapterStatusUpdatePolicy(t *testing.T) { - RegisterTestingT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := config.NewAdapterRequirementsConfig() - adapterConfig.RequiredClusterAdapters = []string{"validation", "dns"} - - service := NewClusterService(clusterDao, adapterStatusDao, adapterConfig) - - ctx := context.Background() - clusterID := testClusterID - - cluster := &api.Cluster{Generation: 2} - cluster.ID = clusterID - _, svcErr := service.Create(ctx, cluster) - Expect(svcErr).To(BeNil()) - - getAvailable := func() api.ResourceCondition { - stored, getErr := clusterDao.Get(ctx, clusterID) - Expect(getErr).To(BeNil()) - - var conds []api.ResourceCondition - Expect(json.Unmarshal(stored.StatusConditions, &conds)).To(Succeed()) - for i := range conds { - if conds[i].Type == conditionTypeAvailable { - return conds[i] - } - } - Expect(true).To(BeFalse(), "Available condition not found") - return api.ResourceCondition{} - } - - upsert := func(adapter string, available api.AdapterConditionStatus, observedGen int32) { - conditions := []api.AdapterCondition{ - {Type: conditionTypeAvailable, Status: available, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - - adapterStatus := &api.AdapterStatus{ - ResourceType: "Cluster", - ResourceID: clusterID, - Adapter: adapter, - ObservedGeneration: observedGen, - Conditions: conditionsJSON, - CreatedTime: &now, - LastReportTime: &now, - } - - _, err := service.ProcessAdapterStatus(ctx, clusterID, adapterStatus) - Expect(err).To(BeNil()) - } - - // Current generation statuses => Available=True at observed_generation=2. - upsert("validation", api.AdapterConditionTrue, 2) - upsert("dns", api.AdapterConditionTrue, 2) - available := getAvailable() - Expect(available.Status).To(Equal(api.ConditionTrue)) - Expect(available.ObservedGeneration).To(Equal(int32(2))) - - // Stale True should not override newer True. - upsert("validation", api.AdapterConditionTrue, 1) - available = getAvailable() - Expect(available.Status).To(Equal(api.ConditionTrue)) - Expect(available.ObservedGeneration).To(Equal(int32(2))) - - // Stale False is more restrictive and should override. - upsert("validation", api.AdapterConditionFalse, 1) - available = getAvailable() - Expect(available.Status).To(Equal(api.ConditionTrue)) - Expect(available.ObservedGeneration).To(Equal(int32(2))) -} - -func TestClusterSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) { - RegisterTestingT(t) - - clusterDao := newMockClusterDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := config.NewAdapterRequirementsConfig() - adapterConfig.RequiredClusterAdapters = []string{"validation"} - - service := NewClusterService(clusterDao, adapterStatusDao, adapterConfig) - - ctx := context.Background() - clusterID := testClusterID - - fixedNow := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - initialConditions := []api.ResourceCondition{ - { - Type: conditionTypeAvailable, - Status: api.ConditionFalse, - ObservedGeneration: 1, - LastTransitionTime: fixedNow, - CreatedTime: fixedNow, - LastUpdatedTime: fixedNow, - }, - { - Type: "Ready", - Status: api.ConditionFalse, - ObservedGeneration: 1, - LastTransitionTime: fixedNow, - CreatedTime: fixedNow, - LastUpdatedTime: fixedNow, - }, - } - initialConditionsJSON, _ := json.Marshal(initialConditions) - - cluster := &api.Cluster{ - Generation: 1, - StatusConditions: initialConditionsJSON, - } - cluster.ID = clusterID - created, svcErr := service.Create(ctx, cluster) - Expect(svcErr).To(BeNil()) - - var createdConds []api.ResourceCondition - Expect(json.Unmarshal(created.StatusConditions, &createdConds)).To(Succeed()) - Expect(len(createdConds)).To(BeNumerically(">=", 2)) - - var createdAvailable, createdReady *api.ResourceCondition - for i := range createdConds { - switch createdConds[i].Type { - case conditionTypeAvailable: - createdAvailable = &createdConds[i] - case conditionTypeReady: - createdReady = &createdConds[i] - } - } - Expect(createdAvailable).ToNot(BeNil()) - Expect(createdReady).ToNot(BeNil()) - Expect(createdAvailable.CreatedTime).To(Equal(fixedNow)) - Expect(createdAvailable.LastTransitionTime).To(Equal(fixedNow)) - Expect(createdAvailable.LastUpdatedTime).To(Equal(fixedNow)) - Expect(createdReady.CreatedTime).To(Equal(fixedNow)) - Expect(createdReady.LastTransitionTime).To(Equal(fixedNow)) - Expect(createdReady.LastUpdatedTime).To(Equal(fixedNow)) - - updated, err := service.UpdateClusterStatusFromAdapters(ctx, clusterID) - Expect(err).To(BeNil()) - - var updatedConds []api.ResourceCondition - Expect(json.Unmarshal(updated.StatusConditions, &updatedConds)).To(Succeed()) - Expect(len(updatedConds)).To(BeNumerically(">=", 2)) - - var updatedAvailable, updatedReady *api.ResourceCondition - for i := range updatedConds { - switch updatedConds[i].Type { - case conditionTypeAvailable: - updatedAvailable = &updatedConds[i] - case conditionTypeReady: - updatedReady = &updatedConds[i] - } - } - Expect(updatedAvailable).ToNot(BeNil()) - Expect(updatedReady).ToNot(BeNil()) - Expect(updatedAvailable.CreatedTime).To(Equal(fixedNow)) - Expect(updatedAvailable.LastTransitionTime).To(Equal(fixedNow)) - Expect(updatedAvailable.LastUpdatedTime).To(Equal(fixedNow)) - Expect(updatedReady.CreatedTime).To(Equal(fixedNow)) - Expect(updatedReady.LastTransitionTime).To(Equal(fixedNow)) - Expect(updatedReady.LastUpdatedTime).To(Equal(fixedNow)) -} diff --git a/pkg/services/generic.go b/pkg/services/generic.go index 084df1c..8c6ddd9 100755 --- a/pkg/services/generic.go +++ b/pkg/services/generic.go @@ -41,12 +41,6 @@ type sqlGenericService struct { var ( SearchDisallowedFields = map[string]map[string]string{ - "Cluster": { - "spec": "spec", // Provider-specific field, not searchable - }, - "NodePool": { - "spec": "spec", // Provider-specific field, not searchable - }, "Resource": { "spec": "spec", // Generic resource spec is not searchable }, diff --git a/pkg/services/generic_test.go b/pkg/services/generic_test.go index bddf025..af334bb 100755 --- a/pkg/services/generic_test.go +++ b/pkg/services/generic_test.go @@ -37,7 +37,7 @@ func TestSQLTranslation(t *testing.T) { }, } for _, test := range tests { - var list []api.Cluster + var list []api.Resource search := test["search"].(string) errorMsg := test["error"].(string) listCtx, model, serviceErr := genericService.newListContext( @@ -73,7 +73,7 @@ func TestSQLTranslation(t *testing.T) { }, } for _, test := range tests { - var list []api.Cluster + var list []api.Resource search := test["search"].(string) sqlReal := test["sql"].(string) valuesReal := test["values"].(types.GomegaMatcher) diff --git a/pkg/services/node_pool.go b/pkg/services/node_pool.go deleted file mode 100644 index 30dee7a..0000000 --- a/pkg/services/node_pool.go +++ /dev/null @@ -1,301 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - stderrors "errors" - "strings" - "time" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" - "gorm.io/gorm" -) - -//go:generate mockgen-v0.6.0 -source=node_pool.go -package=services -destination=node_pool_mock.go - -type NodePoolService interface { - Get(ctx context.Context, id string) (*api.NodePool, *errors.ServiceError) - Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, *errors.ServiceError) - Replace(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, *errors.ServiceError) - Delete(ctx context.Context, id string) *errors.ServiceError - All(ctx context.Context) (api.NodePoolList, *errors.ServiceError) - - FindByIDs(ctx context.Context, ids []string) (api.NodePoolList, *errors.ServiceError) - - // Status aggregation - UpdateNodePoolStatusFromAdapters(ctx context.Context, nodePoolID string) (*api.NodePool, *errors.ServiceError) - - // ProcessAdapterStatus handles the business logic for adapter status: - // - If Available condition is "Unknown": returns (nil, nil) indicating no-op - // - Otherwise: upserts the status and triggers aggregation - ProcessAdapterStatus( - ctx context.Context, nodePoolID string, adapterStatus *api.AdapterStatus, - ) (*api.AdapterStatus, *errors.ServiceError) - - // idempotent functions for the control plane, but can also be called synchronously by any actor - OnUpsert(ctx context.Context, id string) error - OnDelete(ctx context.Context, id string) error -} - -func NewNodePoolService( - nodePoolDao dao.NodePoolDao, - adapterStatusDao dao.AdapterStatusDao, - adapterConfig *config.AdapterRequirementsConfig, -) NodePoolService { - return &sqlNodePoolService{ - nodePoolDao: nodePoolDao, - adapterStatusDao: adapterStatusDao, - adapterConfig: adapterConfig, - } -} - -var _ NodePoolService = &sqlNodePoolService{} - -type sqlNodePoolService struct { - nodePoolDao dao.NodePoolDao - adapterStatusDao dao.AdapterStatusDao - adapterConfig *config.AdapterRequirementsConfig -} - -func (s *sqlNodePoolService) Get(ctx context.Context, id string) (*api.NodePool, *errors.ServiceError) { - nodePool, err := s.nodePoolDao.Get(ctx, id) - if err != nil { - return nil, handleGetError("NodePool", "id", id, err) - } - return nodePool, nil -} - -func (s *sqlNodePoolService) Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, *errors.ServiceError) { - if nodePool.Generation == 0 { - nodePool.Generation = 1 - } - - nodePool, err := s.nodePoolDao.Create(ctx, nodePool) - if err != nil { - return nil, handleCreateError("NodePool", err) - } - - updatedNodePool, svcErr := s.UpdateNodePoolStatusFromAdapters(ctx, nodePool.ID) - if svcErr != nil { - return nil, svcErr - } - - // REMOVED: Event creation - no event-driven components - return updatedNodePool, nil -} - -func (s *sqlNodePoolService) Replace( - ctx context.Context, nodePool *api.NodePool, -) (*api.NodePool, *errors.ServiceError) { - nodePool, err := s.nodePoolDao.Replace(ctx, nodePool) - if err != nil { - return nil, handleUpdateError("NodePool", err) - } - - // REMOVED: Event creation - no event-driven components - return nodePool, nil -} - -func (s *sqlNodePoolService) Delete(ctx context.Context, id string) *errors.ServiceError { - if err := s.nodePoolDao.Delete(ctx, id); err != nil { - return handleDeleteError("NodePool", errors.GeneralError("Unable to delete nodePool: %s", err)) - } - - // REMOVED: Event creation - no event-driven components - return nil -} - -func (s *sqlNodePoolService) FindByIDs(ctx context.Context, ids []string) (api.NodePoolList, *errors.ServiceError) { - nodePools, err := s.nodePoolDao.FindByIDs(ctx, ids) - if err != nil { - return nil, errors.GeneralError("Unable to get all nodePools: %s", err) - } - return nodePools, nil -} - -func (s *sqlNodePoolService) All(ctx context.Context) (api.NodePoolList, *errors.ServiceError) { - nodePools, err := s.nodePoolDao.All(ctx) - if err != nil { - return nil, errors.GeneralError("Unable to get all nodePools: %s", err) - } - return nodePools, nil -} - -func (s *sqlNodePoolService) OnUpsert(ctx context.Context, id string) error { - nodePool, err := s.nodePoolDao.Get(ctx, id) - if err != nil { - return err - } - - logger.With(ctx, logger.FieldNodePoolID, nodePool.ID). - Info("Perform idempotent operations on node pool") - - return nil -} - -func (s *sqlNodePoolService) OnDelete(ctx context.Context, id string) error { - logger.With(ctx, logger.FieldNodePoolID, id).Info("Node pool has been deleted") - return nil -} - -// UpdateNodePoolStatusFromAdapters aggregates adapter statuses into nodepool status -func (s *sqlNodePoolService) UpdateNodePoolStatusFromAdapters( - ctx context.Context, nodePoolID string, -) (*api.NodePool, *errors.ServiceError) { - // Get the nodepool - nodePool, err := s.nodePoolDao.Get(ctx, nodePoolID) - if err != nil { - return nil, handleGetError("NodePool", "id", nodePoolID, err) - } - - // Get all adapter statuses for this nodepool - adapterStatuses, err := s.adapterStatusDao.FindByResource(ctx, "NodePool", nodePoolID) - if err != nil { - return nil, errors.GeneralError("Failed to get adapter statuses: %s", err) - } - - now := time.Now() - - // Build the list of adapter ResourceConditions - adapterConditions := []api.ResourceCondition{} - - for _, adapterStatus := range adapterStatuses { - // Unmarshal Conditions from JSONB - var conditions []api.AdapterCondition - if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil { - continue // Skip if can't unmarshal - } - - // Find the "Available" condition - var availableCondition *api.AdapterCondition - for i := range conditions { - if conditions[i].Type == conditionTypeAvailable { - availableCondition = &conditions[i] - break - } - } - - if availableCondition == nil { - // No Available condition, skip this adapter - continue - } - - // Convert to ResourceCondition - condResource := api.ResourceCondition{ - Type: MapAdapterToConditionType(adapterStatus.Adapter), - Status: api.ResourceConditionStatus(availableCondition.Status), - Reason: availableCondition.Reason, - Message: availableCondition.Message, - ObservedGeneration: adapterStatus.ObservedGeneration, - LastTransitionTime: availableCondition.LastTransitionTime, - } - - // Set CreatedTime with nil check - if adapterStatus.CreatedTime != nil { - condResource.CreatedTime = *adapterStatus.CreatedTime - } - - // Set LastUpdatedTime with nil check - if adapterStatus.LastReportTime != nil { - condResource.LastUpdatedTime = *adapterStatus.LastReportTime - } - - adapterConditions = append(adapterConditions, condResource) - } - - // Compute synthetic Available and Ready conditions - availableCondition, readyCondition := BuildSyntheticConditions( - nodePool.StatusConditions, - adapterStatuses, - s.adapterConfig.RequiredNodePoolAdapters, - nodePool.Generation, - now, - ) - - // Combine synthetic conditions with adapter conditions - // Put Available and Ready first - allConditions := []api.ResourceCondition{availableCondition, readyCondition} - allConditions = append(allConditions, adapterConditions...) - - // Marshal conditions to JSON - conditionsJSON, err := json.Marshal(allConditions) - if err != nil { - return nil, errors.GeneralError("Failed to marshal conditions: %s", err) - } - nodePool.StatusConditions = conditionsJSON - - // Save the updated nodepool - nodePool, err = s.nodePoolDao.Replace(ctx, nodePool) - if err != nil { - return nil, handleUpdateError("NodePool", err) - } - - return nodePool, nil -} - -// ProcessAdapterStatus handles the business logic for adapter status: -// - If Available condition is "Unknown": returns (nil, nil) indicating no-op -// - Otherwise: upserts the status and triggers aggregation -func (s *sqlNodePoolService) ProcessAdapterStatus( - ctx context.Context, nodePoolID string, adapterStatus *api.AdapterStatus, -) (*api.AdapterStatus, *errors.ServiceError) { - existingStatus, findErr := s.adapterStatusDao.FindByResourceAndAdapter( - ctx, "NodePool", nodePoolID, adapterStatus.Adapter, - ) - if findErr != nil && !stderrors.Is(findErr, gorm.ErrRecordNotFound) { - if !strings.Contains(findErr.Error(), errors.CodeNotFoundGeneric) { - return nil, errors.GeneralError("Failed to get adapter status: %s", findErr) - } - } - if existingStatus != nil && adapterStatus.ObservedGeneration < existingStatus.ObservedGeneration { - // Discard stale status updates (older observed_generation). - return nil, nil - } - - // Parse conditions from the adapter status - var conditions []api.AdapterCondition - if len(adapterStatus.Conditions) > 0 { - if err := json.Unmarshal(adapterStatus.Conditions, &conditions); err != nil { - return nil, errors.GeneralError("Failed to unmarshal adapter status conditions: %s", err) - } - } - - // Find the "Available" condition - hasAvailableCondition := false - for _, cond := range conditions { - if cond.Type != conditionTypeAvailable { - continue - } - - hasAvailableCondition = true - if cond.Status == api.AdapterConditionUnknown { - // Available condition is "Unknown", return nil to indicate no-op - return nil, nil - } - } - - // Upsert the adapter status - upsertedStatus, err := s.adapterStatusDao.Upsert(ctx, adapterStatus) - if err != nil { - return nil, handleCreateError("AdapterStatus", err) - } - - // Only trigger aggregation when the adapter reported an Available condition. - // If the adapter status doesn't include Available, saving it should not overwrite - // the nodepool's synthetic Available/Ready conditions. - if hasAvailableCondition { - if _, aggregateErr := s.UpdateNodePoolStatusFromAdapters( - ctx, nodePoolID, - ); aggregateErr != nil { - // Log error but don't fail the request - the status will be computed on next update - logger.With(ctx, logger.FieldNodePoolID, nodePoolID). - WithError(aggregateErr).Warn("Failed to aggregate nodepool status") - } - } - - return upsertedStatus, nil -} diff --git a/pkg/services/node_pool_test.go b/pkg/services/node_pool_test.go deleted file mode 100644 index 6fd8b51..0000000 --- a/pkg/services/node_pool_test.go +++ /dev/null @@ -1,528 +0,0 @@ -package services - -import ( - "context" - "encoding/json" - "testing" - "time" - - . "github.com/onsi/gomega" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" -) - -const ( - testNodePoolID = "test-nodepool-id" -) - -// Mock implementations for testing NodePool ProcessAdapterStatus - -type mockNodePoolDao struct { - nodePools map[string]*api.NodePool -} - -func newMockNodePoolDao() *mockNodePoolDao { - return &mockNodePoolDao{ - nodePools: make(map[string]*api.NodePool), - } -} - -func (d *mockNodePoolDao) Get(ctx context.Context, id string) (*api.NodePool, error) { - if np, ok := d.nodePools[id]; ok { - return np, nil - } - return nil, errors.NotFound("NodePool").AsError() -} - -func (d *mockNodePoolDao) Create(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) { - d.nodePools[nodePool.ID] = nodePool - return nodePool, nil -} - -func (d *mockNodePoolDao) Replace(ctx context.Context, nodePool *api.NodePool) (*api.NodePool, error) { - d.nodePools[nodePool.ID] = nodePool - return nodePool, nil -} - -func (d *mockNodePoolDao) Delete(ctx context.Context, id string) error { - delete(d.nodePools, id) - return nil -} - -func (d *mockNodePoolDao) FindByIDs(ctx context.Context, ids []string) (api.NodePoolList, error) { - var result api.NodePoolList - for _, id := range ids { - if np, ok := d.nodePools[id]; ok { - result = append(result, np) - } - } - return result, nil -} - -func (d *mockNodePoolDao) All(ctx context.Context) (api.NodePoolList, error) { - var result api.NodePoolList - for _, np := range d.nodePools { - result = append(result, np) - } - return result, nil -} - -var _ dao.NodePoolDao = &mockNodePoolDao{} - -// TestNodePoolProcessAdapterStatus_UnknownCondition tests that Unknown Available condition returns nil (no-op) -func TestNodePoolProcessAdapterStatus_UnknownCondition(t *testing.T) { - RegisterTestingT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := config.NewAdapterRequirementsConfig() - service := NewNodePoolService(nodePoolDao, adapterStatusDao, config) - - ctx := context.Background() - nodePoolID := testNodePoolID - - // Create adapter status with Available=Unknown - conditions := []api.AdapterCondition{ - { - Type: conditionTypeAvailable, - Status: api.AdapterConditionUnknown, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - adapterStatus := &api.AdapterStatus{ - ResourceType: "NodePool", - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - } - - result, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - - Expect(err).To(BeNil()) - Expect(result).To(BeNil(), "ProcessAdapterStatus should return nil for Unknown status") - - // Verify nothing was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, "NodePool", nodePoolID) - Expect(len(storedStatuses)).To(Equal(0), "No status should be stored for Unknown") -} - -// TestNodePoolProcessAdapterStatus_TrueCondition tests that True Available condition upserts and aggregates -func TestNodePoolProcessAdapterStatus_TrueCondition(t *testing.T) { - RegisterTestingT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := config.NewAdapterRequirementsConfig() - service := NewNodePoolService(nodePoolDao, adapterStatusDao, config) - - ctx := context.Background() - nodePoolID := testNodePoolID - - // Create the nodepool first - nodePool := &api.NodePool{ - Generation: 1, - } - nodePool.ID = nodePoolID - _, svcErr := service.Create(ctx, nodePool) - Expect(svcErr).To(BeNil()) - - // Create adapter status with Available=True - conditions := []api.AdapterCondition{ - { - Type: conditionTypeAvailable, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - now := time.Now() - adapterStatus := &api.AdapterStatus{ - ResourceType: "NodePool", - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - CreatedTime: &now, - } - - result, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - - Expect(err).To(BeNil()) - Expect(result).ToNot(BeNil(), "ProcessAdapterStatus should return the upserted status") - Expect(result.Adapter).To(Equal("test-adapter")) - - // Verify the status was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, "NodePool", nodePoolID) - Expect(len(storedStatuses)).To(Equal(1), "Status should be stored for True condition") -} - -// TestNodePoolProcessAdapterStatus_MultipleConditions_AvailableUnknown tests multiple conditions with Available=Unknown -func TestNodePoolProcessAdapterStatus_MultipleConditions_AvailableUnknown(t *testing.T) { - RegisterTestingT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - config := config.NewAdapterRequirementsConfig() - service := NewNodePoolService(nodePoolDao, adapterStatusDao, config) - - ctx := context.Background() - nodePoolID := testNodePoolID - - // Create adapter status with multiple conditions including Available=Unknown - conditions := []api.AdapterCondition{ - { - Type: conditionTypeReady, - Status: api.AdapterConditionTrue, - LastTransitionTime: time.Now(), - }, - { - Type: conditionTypeAvailable, - Status: api.AdapterConditionUnknown, - LastTransitionTime: time.Now(), - }, - } - conditionsJSON, _ := json.Marshal(conditions) - - adapterStatus := &api.AdapterStatus{ - ResourceType: "NodePool", - ResourceID: nodePoolID, - Adapter: "test-adapter", - Conditions: conditionsJSON, - } - - result, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - - Expect(err).To(BeNil()) - Expect(result).To(BeNil(), "ProcessAdapterStatus should return nil when Available=Unknown") - - // Verify nothing was stored - storedStatuses, _ := adapterStatusDao.FindByResource(ctx, "NodePool", nodePoolID) - Expect(len(storedStatuses)).To(Equal(0), "No status should be stored for Unknown") -} - -func TestNodePoolAvailableReadyTransitions(t *testing.T) { - RegisterTestingT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := config.NewAdapterRequirementsConfig() - adapterConfig.RequiredNodePoolAdapters = []string{"validation", "hypershift"} - - service := NewNodePoolService(nodePoolDao, adapterStatusDao, adapterConfig) - - ctx := context.Background() - nodePoolID := testNodePoolID - - nodePool := &api.NodePool{Generation: 1} - nodePool.ID = nodePoolID - _, svcErr := service.Create(ctx, nodePool) - Expect(svcErr).To(BeNil()) - - getSynth := func() (api.ResourceCondition, api.ResourceCondition) { - stored, getErr := nodePoolDao.Get(ctx, nodePoolID) - Expect(getErr).To(BeNil()) - - var conds []api.ResourceCondition - Expect(json.Unmarshal(stored.StatusConditions, &conds)).To(Succeed()) - Expect(len(conds)).To(BeNumerically(">=", 2)) - - var available, ready *api.ResourceCondition - for i := range conds { - switch conds[i].Type { - case conditionTypeAvailable: - available = &conds[i] - case conditionTypeReady: - ready = &conds[i] - } - } - Expect(available).ToNot(BeNil()) - Expect(ready).ToNot(BeNil()) - return *available, *ready - } - - upsert := func(adapter string, available api.AdapterConditionStatus, observedGen int32) { - conditions := []api.AdapterCondition{ - {Type: conditionTypeAvailable, Status: available, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - - adapterStatus := &api.AdapterStatus{ - ResourceType: "NodePool", - ResourceID: nodePoolID, - Adapter: adapter, - ObservedGeneration: observedGen, - Conditions: conditionsJSON, - CreatedTime: &now, - LastReportTime: &now, - } - - _, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - Expect(err).To(BeNil()) - } - - // No adapter statuses yet. - _, err := service.UpdateNodePoolStatusFromAdapters(ctx, nodePoolID) - Expect(err).To(BeNil()) - avail, ready := getSynth() - Expect(avail.Status).To(Equal(api.ConditionFalse)) - Expect(avail.ObservedGeneration).To(Equal(int32(1))) - Expect(ready.Status).To(Equal(api.ConditionFalse)) - Expect(ready.ObservedGeneration).To(Equal(int32(1))) - - // Partial adapters: still not Available/Ready. - upsert("validation", api.AdapterConditionTrue, 1) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionFalse)) - Expect(ready.Status).To(Equal(api.ConditionFalse)) - - // All required adapters available at gen=1 => Available=True, Ready=True. - upsert("hypershift", api.AdapterConditionTrue, 1) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionTrue)) - Expect(avail.ObservedGeneration).To(Equal(int32(1))) - Expect(ready.Status).To(Equal(api.ConditionTrue)) - - // Bump resource generation => Ready flips to False; Available remains True. - nodePoolDao.nodePools[nodePoolID].Generation = 2 - _, err = service.UpdateNodePoolStatusFromAdapters(ctx, nodePoolID) - Expect(err).To(BeNil()) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionTrue)) - Expect(avail.ObservedGeneration).To(Equal(int32(1))) - Expect(ready.Status).To(Equal(api.ConditionFalse)) - Expect(ready.ObservedGeneration).To(Equal(int32(2))) - - // One adapter updates to gen=2 => Ready still False; Available still True (minObservedGeneration still 1). - upsert("validation", api.AdapterConditionTrue, 2) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionTrue)) - Expect(avail.ObservedGeneration).To(Equal(int32(1))) - Expect(ready.Status).To(Equal(api.ConditionFalse)) - - // All required adapters at gen=2 => Ready becomes True, Available minObservedGeneration becomes 2. - upsert("hypershift", api.AdapterConditionTrue, 2) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionTrue)) - Expect(avail.ObservedGeneration).To(Equal(int32(2))) - Expect(ready.Status).To(Equal(api.ConditionTrue)) - - // One required adapter goes False => both Available and Ready become False. - upsert("hypershift", api.AdapterConditionFalse, 2) - avail, ready = getSynth() - Expect(avail.Status).To(Equal(api.ConditionFalse)) - Expect(avail.ObservedGeneration).To(Equal(int32(0))) - Expect(ready.Status).To(Equal(api.ConditionFalse)) - - // Adapter status with no Available condition should not overwrite synthetic conditions. - prevStatus := api.NodePool{}.StatusConditions - prevStatus = append(prevStatus, nodePoolDao.nodePools[nodePoolID].StatusConditions...) - nonAvailableConds := []api.AdapterCondition{ - {Type: "Health", Status: api.AdapterConditionTrue, LastTransitionTime: time.Now()}, - } - nonAvailableJSON, _ := json.Marshal(nonAvailableConds) - nonAvailableStatus := &api.AdapterStatus{ - ResourceType: "NodePool", - ResourceID: nodePoolID, - Adapter: "hypershift", - ObservedGeneration: 2, - Conditions: nonAvailableJSON, - } - result, svcErr := service.ProcessAdapterStatus(ctx, nodePoolID, nonAvailableStatus) - Expect(svcErr).To(BeNil()) - Expect(result).ToNot(BeNil()) - Expect(nodePoolDao.nodePools[nodePoolID].StatusConditions).To(Equal(prevStatus)) - - // Available=Unknown is a no-op (does not store, does not overwrite nodepool conditions). - prevStatus = api.NodePool{}.StatusConditions - prevStatus = append(prevStatus, nodePoolDao.nodePools[nodePoolID].StatusConditions...) - unknownConds := []api.AdapterCondition{ - {Type: conditionTypeAvailable, Status: api.AdapterConditionUnknown, LastTransitionTime: time.Now()}, - } - unknownJSON, _ := json.Marshal(unknownConds) - unknownStatus := &api.AdapterStatus{ - ResourceType: "NodePool", - ResourceID: nodePoolID, - Adapter: "hypershift", - Conditions: unknownJSON, - } - result, svcErr = service.ProcessAdapterStatus(ctx, nodePoolID, unknownStatus) - Expect(svcErr).To(BeNil()) - Expect(result).To(BeNil()) - Expect(nodePoolDao.nodePools[nodePoolID].StatusConditions).To(Equal(prevStatus)) -} - -func TestNodePoolStaleAdapterStatusUpdatePolicy(t *testing.T) { - RegisterTestingT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := config.NewAdapterRequirementsConfig() - adapterConfig.RequiredNodePoolAdapters = []string{"validation", "hypershift"} - - service := NewNodePoolService(nodePoolDao, adapterStatusDao, adapterConfig) - - ctx := context.Background() - nodePoolID := testNodePoolID - - nodePool := &api.NodePool{Generation: 2} - nodePool.ID = nodePoolID - _, svcErr := service.Create(ctx, nodePool) - Expect(svcErr).To(BeNil()) - - getAvailable := func() api.ResourceCondition { - stored, getErr := nodePoolDao.Get(ctx, nodePoolID) - Expect(getErr).To(BeNil()) - - var conds []api.ResourceCondition - Expect(json.Unmarshal(stored.StatusConditions, &conds)).To(Succeed()) - for i := range conds { - if conds[i].Type == conditionTypeAvailable { - return conds[i] - } - } - Expect(true).To(BeFalse(), "Available condition not found") - return api.ResourceCondition{} - } - - upsert := func(adapter string, available api.AdapterConditionStatus, observedGen int32) { - conditions := []api.AdapterCondition{ - {Type: conditionTypeAvailable, Status: available, LastTransitionTime: time.Now()}, - } - conditionsJSON, _ := json.Marshal(conditions) - now := time.Now() - - adapterStatus := &api.AdapterStatus{ - ResourceType: "NodePool", - ResourceID: nodePoolID, - Adapter: adapter, - ObservedGeneration: observedGen, - Conditions: conditionsJSON, - CreatedTime: &now, - LastReportTime: &now, - } - - _, err := service.ProcessAdapterStatus(ctx, nodePoolID, adapterStatus) - Expect(err).To(BeNil()) - } - - // Current generation statuses => Available=True at observed_generation=2. - upsert("validation", api.AdapterConditionTrue, 2) - upsert("hypershift", api.AdapterConditionTrue, 2) - available := getAvailable() - Expect(available.Status).To(Equal(api.ConditionTrue)) - Expect(available.ObservedGeneration).To(Equal(int32(2))) - - // Stale True should not override newer True. - upsert("validation", api.AdapterConditionTrue, 1) - available = getAvailable() - Expect(available.Status).To(Equal(api.ConditionTrue)) - Expect(available.ObservedGeneration).To(Equal(int32(2))) - - // Stale False is more restrictive and should override but we do not override newer generation responses - upsert("validation", api.AdapterConditionFalse, 1) - available = getAvailable() - Expect(available.Status).To(Equal(api.ConditionTrue)) - Expect(available.ObservedGeneration).To(Equal(int32(2))) -} - -func TestNodePoolSyntheticTimestampsStableWithoutAdapterStatus(t *testing.T) { - RegisterTestingT(t) - - nodePoolDao := newMockNodePoolDao() - adapterStatusDao := newMockAdapterStatusDao() - - adapterConfig := config.NewAdapterRequirementsConfig() - adapterConfig.RequiredNodePoolAdapters = []string{"validation"} - - service := NewNodePoolService(nodePoolDao, adapterStatusDao, adapterConfig) - - ctx := context.Background() - nodePoolID := testNodePoolID - - fixedNow := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) - initialConditions := []api.ResourceCondition{ - { - Type: conditionTypeAvailable, - Status: api.ConditionFalse, - ObservedGeneration: 1, - LastTransitionTime: fixedNow, - CreatedTime: fixedNow, - LastUpdatedTime: fixedNow, - }, - { - Type: conditionTypeReady, - Status: api.ConditionFalse, - ObservedGeneration: 1, - LastTransitionTime: fixedNow, - CreatedTime: fixedNow, - LastUpdatedTime: fixedNow, - }, - } - initialConditionsJSON, _ := json.Marshal(initialConditions) - - nodePool := &api.NodePool{ - Generation: 1, - StatusConditions: initialConditionsJSON, - } - nodePool.ID = nodePoolID - created, svcErr := service.Create(ctx, nodePool) - Expect(svcErr).To(BeNil()) - - var createdConds []api.ResourceCondition - Expect(json.Unmarshal(created.StatusConditions, &createdConds)).To(Succeed()) - Expect(len(createdConds)).To(BeNumerically(">=", 2)) - - var createdAvailable, createdReady *api.ResourceCondition - for i := range createdConds { - switch createdConds[i].Type { - case conditionTypeAvailable: - createdAvailable = &createdConds[i] - case conditionTypeReady: - createdReady = &createdConds[i] - } - } - Expect(createdAvailable).ToNot(BeNil()) - Expect(createdReady).ToNot(BeNil()) - Expect(createdAvailable.CreatedTime).To(Equal(fixedNow)) - Expect(createdAvailable.LastTransitionTime).To(Equal(fixedNow)) - Expect(createdAvailable.LastUpdatedTime).To(Equal(fixedNow)) - Expect(createdReady.CreatedTime).To(Equal(fixedNow)) - Expect(createdReady.LastTransitionTime).To(Equal(fixedNow)) - Expect(createdReady.LastUpdatedTime).To(Equal(fixedNow)) - - updated, err := service.UpdateNodePoolStatusFromAdapters(ctx, nodePoolID) - Expect(err).To(BeNil()) - - var updatedConds []api.ResourceCondition - Expect(json.Unmarshal(updated.StatusConditions, &updatedConds)).To(Succeed()) - Expect(len(updatedConds)).To(BeNumerically(">=", 2)) - - var updatedAvailable, updatedReady *api.ResourceCondition - for i := range updatedConds { - switch updatedConds[i].Type { - case conditionTypeAvailable: - updatedAvailable = &updatedConds[i] - case conditionTypeReady: - updatedReady = &updatedConds[i] - } - } - Expect(updatedAvailable).ToNot(BeNil()) - Expect(updatedReady).ToNot(BeNil()) - Expect(updatedAvailable.CreatedTime).To(Equal(fixedNow)) - Expect(updatedAvailable.LastTransitionTime).To(Equal(fixedNow)) - Expect(updatedAvailable.LastUpdatedTime).To(Equal(fixedNow)) - Expect(updatedReady.CreatedTime).To(Equal(fixedNow)) - Expect(updatedReady.LastTransitionTime).To(Equal(fixedNow)) - Expect(updatedReady.LastUpdatedTime).To(Equal(fixedNow)) -} diff --git a/plugins/clusters/plugin.go b/plugins/clusters/plugin.go deleted file mode 100644 index 944a1a4..0000000 --- a/plugins/clusters/plugin.go +++ /dev/null @@ -1,100 +0,0 @@ -package clusters - -import ( - "net/http" - - "github.com/gorilla/mux" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments/registry" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/server" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/handlers" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/adapterStatus" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/generic" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/nodePools" -) - -// ServiceLocator Service Locator -type ServiceLocator func() services.ClusterService - -func NewServiceLocator(env *environments.Env) ServiceLocator { - // Initialize adapter requirements config from environment variables - adapterConfig := config.NewAdapterRequirementsConfig() - - return func() services.ClusterService { - return services.NewClusterService( - dao.NewClusterDao(&env.Database.SessionFactory), - dao.NewAdapterStatusDao(&env.Database.SessionFactory), - adapterConfig, - ) - } -} - -// Service helper function to get the cluster service from the registry -func Service(s *environments.Services) services.ClusterService { - if s == nil { - return nil - } - if obj := s.GetService("Clusters"); obj != nil { - locator := obj.(ServiceLocator) - return locator() - } - return nil -} - -func init() { - // Service registration - registry.RegisterService("Clusters", func(env interface{}) interface{} { - return NewServiceLocator(env.(*environments.Env)) - }) - - // Routes registration - server.RegisterRoutes("clusters", func(apiV1Router *mux.Router, services server.ServicesInterface, authMiddleware auth.JWTMiddleware, authzMiddleware auth.AuthorizationMiddleware) { - envServices := services.(*environments.Services) - clusterHandler := handlers.NewClusterHandler(Service(envServices), generic.Service(envServices)) - - clustersRouter := apiV1Router.PathPrefix("/clusters").Subrouter() - clustersRouter.HandleFunc("", clusterHandler.List).Methods(http.MethodGet) - clustersRouter.HandleFunc("/{id}", clusterHandler.Get).Methods(http.MethodGet) - clustersRouter.HandleFunc("", clusterHandler.Create).Methods(http.MethodPost) - clustersRouter.HandleFunc("/{id}", clusterHandler.Patch).Methods(http.MethodPatch) - clustersRouter.HandleFunc("/{id}", clusterHandler.Delete).Methods(http.MethodDelete) - - // Nested resource: cluster statuses - clusterStatusHandler := handlers.NewClusterStatusHandler(adapterStatus.Service(envServices), Service(envServices)) - clustersRouter.HandleFunc("/{id}/statuses", clusterStatusHandler.List).Methods(http.MethodGet) - clustersRouter.HandleFunc("/{id}/statuses", clusterStatusHandler.Create).Methods(http.MethodPost) - - // Nested resource: cluster nodepools - clusterNodePoolsHandler := handlers.NewClusterNodePoolsHandler( - Service(envServices), - nodePools.Service(envServices), - generic.Service(envServices), - ) - clustersRouter.HandleFunc("/{id}/nodepools", clusterNodePoolsHandler.List).Methods(http.MethodGet) - clustersRouter.HandleFunc("/{id}/nodepools", clusterNodePoolsHandler.Create).Methods(http.MethodPost) - clustersRouter.HandleFunc("/{id}/nodepools/{nodepool_id}", clusterNodePoolsHandler.Get).Methods(http.MethodGet) - - // Nested resource: nodepool statuses - nodepoolStatusHandler := handlers.NewNodePoolStatusHandler(adapterStatus.Service(envServices), nodePools.Service(envServices)) - clustersRouter.HandleFunc("/{id}/nodepools/{nodepool_id}/statuses", nodepoolStatusHandler.List).Methods(http.MethodGet) - clustersRouter.HandleFunc("/{id}/nodepools/{nodepool_id}/statuses", nodepoolStatusHandler.Create).Methods(http.MethodPost) - - clustersRouter.Use(authMiddleware.AuthenticateAccountJWT) - clustersRouter.Use(authzMiddleware.AuthorizeApi) - }) - - // REMOVED: Controller registration - Sentinel handles orchestration - // Controllers are no longer run inside the API service - - // Presenter registration - presenters.RegisterPath(api.Cluster{}, "clusters") - presenters.RegisterPath(&api.Cluster{}, "clusters") - presenters.RegisterKind(api.Cluster{}, "Cluster") - presenters.RegisterKind(&api.Cluster{}, "Cluster") -} diff --git a/plugins/nodePools/plugin.go b/plugins/nodePools/plugin.go deleted file mode 100644 index 0b870f3..0000000 --- a/plugins/nodePools/plugin.go +++ /dev/null @@ -1,76 +0,0 @@ -package nodePools - -import ( - "net/http" - - "github.com/gorilla/mux" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments/registry" - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/server" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/presenters" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/config" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/dao" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/handlers" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/generic" -) - -// ServiceLocator Service Locator -type ServiceLocator func() services.NodePoolService - -func NewServiceLocator(env *environments.Env) ServiceLocator { - // Initialize adapter requirements config from environment variables - adapterConfig := config.NewAdapterRequirementsConfig() - - return func() services.NodePoolService { - return services.NewNodePoolService( - dao.NewNodePoolDao(&env.Database.SessionFactory), - dao.NewAdapterStatusDao(&env.Database.SessionFactory), - adapterConfig, - ) - } -} - -// Service helper function to get the nodePool service from the registry -func Service(s *environments.Services) services.NodePoolService { - if s == nil { - return nil - } - if obj := s.GetService("NodePools"); obj != nil { - locator := obj.(ServiceLocator) - return locator() - } - return nil -} - -func init() { - // Service registration - registry.RegisterService("NodePools", func(env interface{}) interface{} { - return NewServiceLocator(env.(*environments.Env)) - }) - - // Routes registration - server.RegisterRoutes("nodePools", func(apiV1Router *mux.Router, services server.ServicesInterface, authMiddleware auth.JWTMiddleware, authzMiddleware auth.AuthorizationMiddleware) { - envServices := services.(*environments.Services) - nodePoolHandler := handlers.NewNodePoolHandler(Service(envServices), generic.Service(envServices)) - - // Only register routes that are in the OpenAPI spec - // GET /api/hyperfleet/v1/nodepools - List all nodepools - nodePoolsRouter := apiV1Router.PathPrefix("/nodepools").Subrouter() - nodePoolsRouter.HandleFunc("", nodePoolHandler.List).Methods(http.MethodGet) - - nodePoolsRouter.Use(authMiddleware.AuthenticateAccountJWT) - nodePoolsRouter.Use(authzMiddleware.AuthorizeApi) - }) - - // REMOVED: Controller registration - Sentinel handles orchestration - // Controllers are no longer run inside the API service - - // Presenter registration - presenters.RegisterPath(api.NodePool{}, "node_pools") - presenters.RegisterPath(&api.NodePool{}, "node_pools") - presenters.RegisterKind(api.NodePool{}, "NodePool") - presenters.RegisterKind(&api.NodePool{}, "NodePool") -} diff --git a/plugins/resources/plugin.go b/plugins/resources/plugin.go index b562f0c..1567313 100644 --- a/plugins/resources/plugin.go +++ b/plugins/resources/plugin.go @@ -1,10 +1,10 @@ // Package resources provides a dynamic plugin that loads CRD definitions and registers routes. -// Adding a new resource type only requires adding a YAML file to the config/crds directory. +// CRD definitions are loaded from the Kubernetes API server at startup. package resources import ( + "context" "net/http" - "os" "github.com/gorilla/mux" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" @@ -20,13 +20,6 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/pkg/services" ) -const ( - // DefaultCRDPath is the default path for CRD YAML files - DefaultCRDPath = "config/crds" - // CRDPathEnvVar is the environment variable to override the CRD path - CRDPathEnvVar = "CRD_CONFIG_PATH" -) - // ServiceLocator creates a ResourceService instance type ServiceLocator func() services.ResourceService @@ -52,24 +45,16 @@ func Service(s *environments.Services) services.ResourceService { return nil } -// getCRDPath returns the path to CRD configuration files -func getCRDPath() string { - if path := os.Getenv(CRDPathEnvVar); path != "" { - return path - } - return DefaultCRDPath -} - func init() { - // Load CRDs from filesystem - crdPath := getCRDPath() - if err := crd.LoadFromDirectory(crdPath); err != nil { + // Load CRDs from Kubernetes API + ctx := context.Background() + if err := crd.LoadFromKubernetes(ctx); err != nil { // Log warning but don't fail - CRDs might not be present in all environments - logger.With(nil, "crd_path", crdPath).Info( - "CRD directory not found or failed to load, generic resource API disabled") + logger.WithError(nil, err).Warn( + "Failed to load CRDs from Kubernetes API, generic resource API disabled") } else { logger.With(nil, "crd_count", crd.Default().Count()).Info( - "Loaded CRD definitions") + "Loaded CRD definitions from Kubernetes API") } // Service registration diff --git a/test/factories/clusters.go b/test/factories/clusters.go deleted file mode 100644 index da69822..0000000 --- a/test/factories/clusters.go +++ /dev/null @@ -1,176 +0,0 @@ -package factories - -import ( - "context" - "encoding/json" - "time" - - "gorm.io/gorm" - - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/clusters" -) - -func (f *Factories) NewCluster(id string) (*api.Cluster, error) { - clusterService := clusters.Service(&environments.Environment().Services) - - cluster := &api.Cluster{ - Meta: api.Meta{ID: id}, - Name: "test-cluster-" + id, // Use unique name based on ID - Spec: []byte(`{"test": "spec"}`), - Generation: 42, - CreatedBy: "test@example.com", - UpdatedBy: "test@example.com", - } - - sub, err := clusterService.Create(context.Background(), cluster) - if err != nil { - return nil, err - } - - return sub, nil -} - -func (f *Factories) NewClusterList(name string, count int) ([]*api.Cluster, error) { - var Clusters []*api.Cluster - for i := 1; i <= count; i++ { - c, err := f.NewCluster(f.NewID()) - if err != nil { - return nil, err - } - Clusters = append(Clusters, c) - } - return Clusters, nil -} - -// Aliases for test compatibility -func (f *Factories) NewClusters(id string) (*api.Cluster, error) { - return f.NewCluster(id) -} - -func (f *Factories) NewClustersList(name string, count int) ([]*api.Cluster, error) { - return f.NewClusterList(name, count) -} - -// reloadCluster reloads a cluster from the database to ensure all fields are current -func reloadCluster(dbSession *gorm.DB, cluster *api.Cluster) error { - return dbSession.First(cluster, "id = ?", cluster.ID).Error -} - -// NewClusterWithStatus creates a cluster with specific status conditions -// dbFactory parameter is needed to update database fields -// The isAvailable and isReady parameters control which synthetic conditions are set -func NewClusterWithStatus( - f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReady bool, -) (*api.Cluster, error) { - cluster, err := f.NewCluster(id) - if err != nil { - return nil, err - } - - now := time.Now() - availableStatus := api.ConditionFalse - if isAvailable { - availableStatus = api.ConditionTrue - } - readyStatus := api.ConditionFalse - if isReady { - readyStatus = api.ConditionTrue - } - - conditions := []api.ResourceCondition{ - { - Type: "Available", - Status: availableStatus, - ObservedGeneration: cluster.Generation, - LastTransitionTime: now, - CreatedTime: now, - LastUpdatedTime: now, - }, - { - Type: "Ready", - Status: readyStatus, - ObservedGeneration: cluster.Generation, - LastTransitionTime: now, - CreatedTime: now, - LastUpdatedTime: now, - }, - } - - conditionsJSON, err := json.Marshal(conditions) - if err != nil { - return nil, err - } - - // Update database record with status conditions - dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(cluster).Update("status_conditions", conditionsJSON).Error - if err != nil { - return nil, err - } - - // Reload to get updated values - if err := reloadCluster(dbSession, cluster); err != nil { - return nil, err - } - return cluster, nil -} - -// NewClusterWithLabels creates a cluster with specific labels -func NewClusterWithLabels( - f *Factories, dbFactory db.SessionFactory, id string, labels map[string]string, -) (*api.Cluster, error) { - cluster, err := f.NewCluster(id) - if err != nil { - return nil, err - } - - // Convert labels to JSON and update - labelsJSON, err := json.Marshal(labels) - if err != nil { - return nil, err - } - - dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(cluster).Update("labels", labelsJSON).Error - if err != nil { - return nil, err - } - - // Reload to get updated values - if err := reloadCluster(dbSession, cluster); err != nil { - return nil, err - } - return cluster, nil -} - -// NewClusterWithStatusAndLabels creates a cluster with both status conditions and labels -func NewClusterWithStatusAndLabels( - f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReady bool, labels map[string]string, -) (*api.Cluster, error) { - cluster, err := NewClusterWithStatus(f, dbFactory, id, isAvailable, isReady) - if err != nil { - return nil, err - } - - if labels != nil { - labelsJSON, err := json.Marshal(labels) - if err != nil { - return nil, err - } - - dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(cluster).Update("labels", labelsJSON).Error - if err != nil { - return nil, err - } - - if err := reloadCluster(dbSession, cluster); err != nil { - return nil, err - } - } - - return cluster, nil -} diff --git a/test/factories/node_pools.go b/test/factories/node_pools.go deleted file mode 100644 index 1ad9076..0000000 --- a/test/factories/node_pools.go +++ /dev/null @@ -1,196 +0,0 @@ -package factories - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "gorm.io/gorm" - - "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" - "github.com/openshift-hyperfleet/hyperfleet-api/plugins/nodePools" -) - -func (f *Factories) NewNodePool(id string) (*api.NodePool, error) { - nodePoolService := nodePools.Service(&environments.Environment().Services) - - if nodePoolService == nil { - return nil, fmt.Errorf("nodePoolService is nil - service not initialized") - } - - // Create a parent cluster first to get a valid OwnerID - cluster, err := f.NewCluster(f.NewID()) - if err != nil { - return nil, fmt.Errorf("failed to create parent cluster: %w", err) - } - - if cluster == nil { - return nil, fmt.Errorf("cluster is nil after NewCluster call") - } - - nodePool := &api.NodePool{ - Meta: api.Meta{ID: id}, - Name: "test-nodepool-" + id, // Use unique name based on ID - Spec: []byte(`{"test": "spec"}`), - OwnerID: cluster.ID, // Use real cluster ID - CreatedBy: "test@example.com", - UpdatedBy: "test@example.com", - } - - sub, serviceErr := nodePoolService.Create(context.Background(), nodePool) - // Check for real errors (not typed nil) - if serviceErr != nil && serviceErr.RFC9457Code != "" { - return nil, fmt.Errorf("failed to create nodepool: %s (code: %s)", serviceErr.Reason, serviceErr.RFC9457Code) - } - - if sub == nil { - return nil, fmt.Errorf("nodePoolService.Create returned nil without error") - } - - return sub, nil -} - -func (f *Factories) NewNodePoolList(name string, count int) ([]*api.NodePool, error) { - var NodePools []*api.NodePool - for i := 1; i <= count; i++ { - c, err := f.NewNodePool(f.NewID()) - if err != nil { - return nil, err - } - NodePools = append(NodePools, c) - } - return NodePools, nil -} - -// Aliases for test compatibility -func (f *Factories) NewNodePools(id string) (*api.NodePool, error) { - return f.NewNodePool(id) -} - -func (f *Factories) NewNodePoolsList(name string, count int) ([]*api.NodePool, error) { - return f.NewNodePoolList(name, count) -} - -// reloadNodePool reloads a node pool from the database to ensure all fields are current -func reloadNodePool(dbSession *gorm.DB, nodePool *api.NodePool) error { - return dbSession.First(nodePool, "id = ?", nodePool.ID).Error -} - -// NewNodePoolWithStatus creates a node pool with specific status conditions -// dbFactory parameter is needed to update database fields -// The isAvailable and isReady parameters control which synthetic conditions are set -func NewNodePoolWithStatus( - f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReady bool, -) (*api.NodePool, error) { - nodePool, err := f.NewNodePool(id) - if err != nil { - return nil, err - } - - now := time.Now() - availableStatus := api.ConditionFalse - if isAvailable { - availableStatus = api.ConditionTrue - } - readyStatus := api.ConditionFalse - if isReady { - readyStatus = api.ConditionTrue - } - - conditions := []api.ResourceCondition{ - { - Type: "Available", - Status: availableStatus, - ObservedGeneration: nodePool.Generation, - LastTransitionTime: now, - CreatedTime: now, - LastUpdatedTime: now, - }, - { - Type: "Ready", - Status: readyStatus, - ObservedGeneration: nodePool.Generation, - LastTransitionTime: now, - CreatedTime: now, - LastUpdatedTime: now, - }, - } - - conditionsJSON, err := json.Marshal(conditions) - if err != nil { - return nil, err - } - - // Update database record with status conditions - dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(nodePool).Update("status_conditions", conditionsJSON).Error - if err != nil { - return nil, err - } - - // Reload to get updated values - if err := reloadNodePool(dbSession, nodePool); err != nil { - return nil, err - } - return nodePool, nil -} - -// NewNodePoolWithLabels creates a node pool with specific labels -func NewNodePoolWithLabels( - f *Factories, dbFactory db.SessionFactory, id string, labels map[string]string, -) (*api.NodePool, error) { - nodePool, err := f.NewNodePool(id) - if err != nil { - return nil, err - } - - // Convert labels to JSON and update - labelsJSON, err := json.Marshal(labels) - if err != nil { - return nil, err - } - - dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(nodePool).Update("labels", labelsJSON).Error - if err != nil { - return nil, err - } - - // Reload to get updated values - if err := reloadNodePool(dbSession, nodePool); err != nil { - return nil, err - } - return nodePool, nil -} - -// NewNodePoolWithStatusAndLabels creates a node pool with both status conditions and labels -func NewNodePoolWithStatusAndLabels( - f *Factories, dbFactory db.SessionFactory, id string, isAvailable, isReady bool, labels map[string]string, -) (*api.NodePool, error) { - nodePool, err := NewNodePoolWithStatus(f, dbFactory, id, isAvailable, isReady) - if err != nil { - return nil, err - } - - if labels != nil { - labelsJSON, err := json.Marshal(labels) - if err != nil { - return nil, err - } - - dbSession := dbFactory.New(context.Background()) - err = dbSession.Model(nodePool).Update("labels", labelsJSON).Error - if err != nil { - return nil, err - } - - if err := reloadNodePool(dbSession, nodePool); err != nil { - return nil, err - } - } - - return nodePool, nil -} diff --git a/test/factories/resources.go b/test/factories/resources.go new file mode 100644 index 0000000..41d0fa0 --- /dev/null +++ b/test/factories/resources.go @@ -0,0 +1,224 @@ +package factories + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "gorm.io/gorm" + + "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" + "github.com/openshift-hyperfleet/hyperfleet-api/plugins/resources" +) + +// NewResource creates a generic resource of the specified kind. +func (f *Factories) NewResource(id, kind string) (*api.Resource, error) { + resourceService := resources.Service(&environments.Environment().Services) + + if resourceService == nil { + return nil, fmt.Errorf("resourceService is nil - service not initialized") + } + + resource := &api.Resource{ + Meta: api.Meta{ID: id}, + Kind: kind, + Name: fmt.Sprintf("test-%s-%s", kind, id), + Spec: []byte(`{"test": "spec"}`), + Labels: []byte(`{}`), + CreatedBy: "test@example.com", + UpdatedBy: "test@example.com", + } + + // Use empty required adapters for test resources + created, err := resourceService.Create(context.Background(), resource, []string{}) + if err != nil { + return nil, fmt.Errorf("failed to create resource: %s", err.Reason) + } + + return created, nil +} + +// NewClusterResource creates a Cluster resource using the generic resources table. +func (f *Factories) NewClusterResource(id string) (*api.Resource, error) { + return f.NewResource(id, "Cluster") +} + +// NewNodePoolResource creates a NodePool resource owned by a Cluster. +func (f *Factories) NewNodePoolResource(id string, clusterID string) (*api.Resource, error) { + resourceService := resources.Service(&environments.Environment().Services) + + if resourceService == nil { + return nil, fmt.Errorf("resourceService is nil - service not initialized") + } + + ownerKind := "Cluster" + ownerHref := fmt.Sprintf("/api/hyperfleet/v1/clusters/%s", clusterID) + + resource := &api.Resource{ + Meta: api.Meta{ID: id}, + Kind: "NodePool", + Name: fmt.Sprintf("test-nodepool-%s", id), + Spec: []byte(`{"test": "spec"}`), + Labels: []byte(`{}`), + OwnerID: &clusterID, + OwnerKind: &ownerKind, + OwnerHref: &ownerHref, + CreatedBy: "test@example.com", + UpdatedBy: "test@example.com", + } + + // Use empty required adapters for test resources + created, err := resourceService.Create(context.Background(), resource, []string{}) + if err != nil { + return nil, fmt.Errorf("failed to create nodepool resource: %s", err.Reason) + } + + return created, nil +} + +// NewClusters creates a Cluster resource (alias for backwards compatibility with adapter_status_test). +func (f *Factories) NewClusters(id string) (*api.Resource, error) { + return f.NewClusterResource(id) +} + +// NewNodePools creates a NodePool resource with its parent Cluster. +func (f *Factories) NewNodePools(id string) (*api.Resource, error) { + // Create a parent cluster first + cluster, err := f.NewClusterResource(f.NewID()) + if err != nil { + return nil, fmt.Errorf("failed to create parent cluster: %w", err) + } + + // Create the nodepool owned by the cluster + nodePool, err := f.NewNodePoolResource(id, cluster.ID) + if err != nil { + return nil, fmt.Errorf("failed to create nodepool: %w", err) + } + + // Set OwnerID for test compatibility (some tests check this) + nodePool.OwnerID = &cluster.ID + + return nodePool, nil +} + +// reloadResource reloads a resource from the database to ensure all fields are current. +func reloadResource(dbSession *gorm.DB, resource *api.Resource) error { + return dbSession.First(resource, "id = ?", resource.ID).Error +} + +// NewResourceWithStatus creates a resource with specific status conditions. +func NewResourceWithStatus( + f *Factories, dbFactory db.SessionFactory, id, kind string, isAvailable, isReady bool, +) (*api.Resource, error) { + resource, err := f.NewResource(id, kind) + if err != nil { + return nil, err + } + + now := time.Now() + availableStatus := api.ConditionFalse + if isAvailable { + availableStatus = api.ConditionTrue + } + readyStatus := api.ConditionFalse + if isReady { + readyStatus = api.ConditionTrue + } + + conditions := []api.ResourceCondition{ + { + Type: "Available", + Status: availableStatus, + ObservedGeneration: resource.Generation, + LastTransitionTime: now, + CreatedTime: now, + LastUpdatedTime: now, + }, + { + Type: "Ready", + Status: readyStatus, + ObservedGeneration: resource.Generation, + LastTransitionTime: now, + CreatedTime: now, + LastUpdatedTime: now, + }, + } + + conditionsJSON, err := json.Marshal(conditions) + if err != nil { + return nil, err + } + + // Update database record with status conditions + dbSession := dbFactory.New(context.Background()) + err = dbSession.Model(resource).Update("status_conditions", conditionsJSON).Error + if err != nil { + return nil, err + } + + // Reload to get updated values + if err := reloadResource(dbSession, resource); err != nil { + return nil, err + } + return resource, nil +} + +// NewResourceWithLabels creates a resource with specific labels. +func NewResourceWithLabels( + f *Factories, dbFactory db.SessionFactory, id, kind string, labels map[string]string, +) (*api.Resource, error) { + resource, err := f.NewResource(id, kind) + if err != nil { + return nil, err + } + + // Convert labels to JSON and update + labelsJSON, err := json.Marshal(labels) + if err != nil { + return nil, err + } + + dbSession := dbFactory.New(context.Background()) + err = dbSession.Model(resource).Update("labels", labelsJSON).Error + if err != nil { + return nil, err + } + + // Reload to get updated values + if err := reloadResource(dbSession, resource); err != nil { + return nil, err + } + return resource, nil +} + +// NewResourceWithStatusAndLabels creates a resource with both status conditions and labels. +func NewResourceWithStatusAndLabels( + f *Factories, dbFactory db.SessionFactory, id, kind string, isAvailable, isReady bool, labels map[string]string, +) (*api.Resource, error) { + resource, err := NewResourceWithStatus(f, dbFactory, id, kind, isAvailable, isReady) + if err != nil { + return nil, err + } + + if labels != nil { + labelsJSON, err := json.Marshal(labels) + if err != nil { + return nil, err + } + + dbSession := dbFactory.New(context.Background()) + err = dbSession.Model(resource).Update("labels", labelsJSON).Error + if err != nil { + return nil, err + } + + if err := reloadResource(dbSession, resource); err != nil { + return nil, err + } + } + + return resource, nil +} diff --git a/test/integration/adapter_status_test.go b/test/integration/adapter_status_test.go deleted file mode 100644 index 6b2a114..0000000 --- a/test/integration/adapter_status_test.go +++ /dev/null @@ -1,570 +0,0 @@ -package integration - -import ( - "fmt" - "net/http" - "testing" - "time" - - . "github.com/onsi/gomega" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" - "github.com/openshift-hyperfleet/hyperfleet-api/test" -) - -// Helper to create AdapterStatusCreateRequest -func newAdapterStatusRequest( - adapter string, observedGen int32, conditions []openapi.ConditionRequest, data *map[string]interface{}, -) openapi.AdapterStatusCreateRequest { - return openapi.AdapterStatusCreateRequest{ - Adapter: adapter, - ObservedGeneration: observedGen, - Data: data, - Conditions: conditions, - ObservedTime: time.Now(), - } -} - -// TestClusterStatusPost tests creating adapter status for a cluster -func TestClusterStatusPost(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a cluster first - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Create an adapter status for the cluster - data := map[string]interface{}{ - "test_key": map[string]interface{}{"value": "test_value"}, - } - statusInput := newAdapterStatusRequest( - "test-adapter", - cluster.Generation, - []openapi.ConditionRequest{ - { - Type: "Ready", - Status: openapi.AdapterConditionStatusTrue, - Reason: util.PtrString("AdapterReady"), - }, - }, - &data, - ) - - resp, err := client.PostClusterStatusesWithResponse( - ctx, cluster.ID, - openapi.PostClusterStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Error posting cluster status: %v", err) - Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) - Expect(resp.JSON201).NotTo(BeNil()) - Expect(resp.JSON201.Adapter).To(Equal("test-adapter")) - Expect(resp.JSON201.ObservedGeneration).To(Equal(cluster.Generation)) - Expect(len(resp.JSON201.Conditions)).To(BeNumerically(">", 0)) -} - -// TestClusterStatusGet tests retrieving adapter statuses for a cluster -func TestClusterStatusGet(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a cluster first - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Create a few adapter statuses - for i := 0; i < 3; i++ { - statusInput := newAdapterStatusRequest( - fmt.Sprintf("adapter-%d", i), - cluster.Generation, - []openapi.ConditionRequest{ - { - Type: "Ready", - Status: openapi.AdapterConditionStatusTrue, - }, - }, - nil, - ) - _, err := client.PostClusterStatusesWithResponse( - ctx, cluster.ID, - openapi.PostClusterStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - } - - // Get all statuses for the cluster - resp, err := client.GetClusterStatusesWithResponse(ctx, cluster.ID, nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Error getting cluster statuses: %v", err) - Expect(resp.StatusCode()).To(Equal(http.StatusOK)) - Expect(resp.JSON200).NotTo(BeNil()) - Expect(len(resp.JSON200.Items)).To(BeNumerically(">=", 3)) -} - -// TestNodePoolStatusPost tests creating adapter status for a nodepool -func TestNodePoolStatusPost(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a nodepool (which also creates its parent cluster) - nodePool, err := h.Factories.NewNodePools(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - Expect(nodePool).NotTo(BeNil(), "nodePool should not be nil") - Expect(nodePool.OwnerID).NotTo(BeEmpty(), "nodePool.OwnerID should not be empty") - Expect(nodePool.ID).NotTo(BeEmpty(), "nodePool.ID should not be empty") - - // Create an adapter status for the nodepool - data := map[string]interface{}{ - "nodepool_data": map[string]interface{}{"value": "test_value"}, - } - statusInput := newAdapterStatusRequest( - "test-nodepool-adapter", - 1, - []openapi.ConditionRequest{ - { - Type: "Ready", - Status: openapi.AdapterConditionStatusFalse, - Reason: util.PtrString("Initializing"), - }, - }, - &data, - ) - - // Use nodePool.OwnerID as the cluster_id parameter - resp, err := client.PostNodePoolStatusesWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, - openapi.PostNodePoolStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Error posting nodepool status: %v", err) - Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) - Expect(resp.JSON201).NotTo(BeNil()) - Expect(resp.JSON201.Adapter).To(Equal("test-nodepool-adapter")) - Expect(len(resp.JSON201.Conditions)).To(BeNumerically(">", 0)) -} - -// TestNodePoolStatusGet tests retrieving adapter statuses for a nodepool -func TestNodePoolStatusGet(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a nodepool (which also creates its parent cluster) - nodePool, err := h.Factories.NewNodePools(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Create a few adapter statuses - for i := 0; i < 2; i++ { - statusInput := newAdapterStatusRequest( - fmt.Sprintf("nodepool-adapter-%d", i), - 1, - []openapi.ConditionRequest{ - { - Type: "Ready", - Status: openapi.AdapterConditionStatusTrue, - }, - }, - nil, - ) - // Use nodePool.OwnerID as the cluster_id parameter - _, err := client.PostNodePoolStatusesWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, - openapi.PostNodePoolStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - } - - // Get all statuses for the nodepool - resp, err := client.GetNodePoolsStatusesWithResponse(ctx, nodePool.OwnerID, nodePool.ID, nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Error getting nodepool statuses: %v", err) - Expect(resp.StatusCode()).To(Equal(http.StatusOK)) - Expect(resp.JSON200).NotTo(BeNil()) - Expect(len(resp.JSON200.Items)).To(BeNumerically(">=", 2)) -} - -// TestAdapterStatusPaging tests paging for adapter statuses -func TestAdapterStatusPaging(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a cluster - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Create multiple statuses - for i := 0; i < 10; i++ { - statusInput := newAdapterStatusRequest( - fmt.Sprintf("adapter-%d", i), - cluster.Generation, - []openapi.ConditionRequest{ - { - Type: "Ready", - Status: openapi.AdapterConditionStatusTrue, - }, - }, - nil, - ) - _, err := client.PostClusterStatusesWithResponse( - ctx, cluster.ID, - openapi.PostClusterStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - } - - // Test paging - page := openapi.QueryParamsPage(1) - pageSize := openapi.QueryParamsPageSize(5) - params := &openapi.GetClusterStatusesParams{ - Page: &page, - PageSize: &pageSize, - } - resp, err := client.GetClusterStatusesWithResponse(ctx, cluster.ID, params, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.JSON200).NotTo(BeNil()) - Expect(len(resp.JSON200.Items)).To(BeNumerically("<=", 5)) - Expect(resp.JSON200.Page).To(Equal(int32(1))) -} - -// TestAdapterStatusIdempotency tests that posting the same adapter twice updates instead of creating duplicate -func TestAdapterStatusIdempotency(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a cluster - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // First POST: Create adapter status - data1 := map[string]interface{}{ - "version": map[string]interface{}{"value": "1.0"}, - } - statusInput1 := newAdapterStatusRequest( - "idempotency-test-adapter", - cluster.Generation, - []openapi.ConditionRequest{ - { - Type: "Ready", - Status: openapi.AdapterConditionStatusFalse, - Reason: util.PtrString("Initializing"), - }, - }, - &data1, - ) - - resp1, err := client.PostClusterStatusesWithResponse( - ctx, cluster.ID, - openapi.PostClusterStatusesJSONRequestBody(statusInput1), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(resp1.StatusCode()).To(Equal(http.StatusCreated)) - Expect(resp1.JSON201).NotTo(BeNil()) - Expect(resp1.JSON201.Adapter).To(Equal("idempotency-test-adapter")) - Expect(resp1.JSON201.Conditions[0].Status).To(Equal(openapi.AdapterConditionStatusFalse)) - - // Second POST: Update the same adapter with different conditions - data2 := map[string]interface{}{ - "version": map[string]interface{}{"value": "2.0"}, - } - statusInput2 := newAdapterStatusRequest( - "idempotency-test-adapter", - cluster.Generation, - []openapi.ConditionRequest{ - { - Type: "Ready", - Status: openapi.AdapterConditionStatusTrue, - Reason: util.PtrString("AdapterReady"), - }, - }, - &data2, - ) - - resp2, err := client.PostClusterStatusesWithResponse( - ctx, cluster.ID, - openapi.PostClusterStatusesJSONRequestBody(statusInput2), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(resp2.StatusCode()).To(Equal(http.StatusCreated)) - Expect(resp2.JSON201).NotTo(BeNil()) - Expect(resp2.JSON201.Adapter).To(Equal("idempotency-test-adapter")) - Expect(resp2.JSON201.Conditions[0].Status).To(Equal(openapi.AdapterConditionStatusTrue)) - - // GET all statuses - should have only ONE status for "idempotency-test-adapter" - listResp, err := client.GetClusterStatusesWithResponse(ctx, cluster.ID, nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - Expect(listResp.JSON200).NotTo(BeNil()) - - // Count how many times this adapter appears - adapterCount := 0 - var finalStatus openapi.AdapterStatus - for _, s := range listResp.JSON200.Items { - if s.Adapter == "idempotency-test-adapter" { - adapterCount++ - finalStatus = s - } - } - - // Verify: should have exactly ONE entry for this adapter (updated, not duplicated) - Expect(adapterCount).To(Equal(1), "Adapter should be updated, not duplicated") - Expect(finalStatus.Conditions[0].Status). - To(Equal(openapi.AdapterConditionStatusTrue), "Conditions should be updated to latest") -} - -// TestClusterStatusPost_UnknownReturns204 tests that posting Unknown Available status returns 204 No Content -func TestClusterStatusPost_UnknownReturns204(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a cluster first - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Create an adapter status with Available=Unknown - statusInput := newAdapterStatusRequest( - "test-adapter-unknown", - cluster.Generation, - []openapi.ConditionRequest{ - { - Type: "Available", - Status: openapi.AdapterConditionStatusUnknown, - Reason: util.PtrString("StartupPending"), - }, - }, - nil, - ) - - resp, err := client.PostClusterStatusesWithResponse( - ctx, cluster.ID, - openapi.PostClusterStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Error posting cluster status: %v", err) - Expect(resp.StatusCode()). - To(Equal(http.StatusNoContent), "Expected 204 No Content for Unknown status") - - // Verify the status was NOT stored - listResp, err := client.GetClusterStatusesWithResponse(ctx, cluster.ID, nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - Expect(listResp.JSON200).NotTo(BeNil()) - - // Check that no adapter status with "test-adapter-unknown" exists - for _, s := range listResp.JSON200.Items { - Expect(s.Adapter).NotTo(Equal("test-adapter-unknown"), "Unknown status should not be stored") - } -} - -// TestNodePoolStatusPost_UnknownReturns204 tests that posting Unknown Available status returns 204 No Content -func TestNodePoolStatusPost_UnknownReturns204(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a nodepool (which also creates its parent cluster) - nodePool, err := h.Factories.NewNodePools(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Create an adapter status with Available=Unknown - statusInput := newAdapterStatusRequest( - "test-nodepool-adapter-unknown", - 1, - []openapi.ConditionRequest{ - { - Type: "Available", - Status: openapi.AdapterConditionStatusUnknown, - Reason: util.PtrString("StartupPending"), - }, - }, - nil, - ) - - resp, err := client.PostNodePoolStatusesWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, - openapi.PostNodePoolStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Error posting nodepool status: %v", err) - Expect(resp.StatusCode()). - To(Equal(http.StatusNoContent), "Expected 204 No Content for Unknown status") - - // Verify the status was NOT stored - listResp, err := client.GetNodePoolsStatusesWithResponse( - ctx, nodePool.OwnerID, nodePool.ID, nil, test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(listResp.JSON200).NotTo(BeNil()) - - // Check that no adapter status with "test-nodepool-adapter-unknown" exists - for _, s := range listResp.JSON200.Items { - Expect(s.Adapter).NotTo(Equal("test-nodepool-adapter-unknown"), - "Unknown status should not be stored") - } -} - -// TestClusterStatusPost_MultipleConditionsWithUnknownAvailable tests that -// Unknown Available is detected among multiple conditions -func TestClusterStatusPost_MultipleConditionsWithUnknownAvailable(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a cluster first - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Create an adapter status with multiple conditions including Available=Unknown - statusInput := newAdapterStatusRequest( - "test-adapter-multi-unknown", - cluster.Generation, - []openapi.ConditionRequest{ - { - Type: "Ready", - Status: openapi.AdapterConditionStatusTrue, - }, - { - Type: "Available", - Status: openapi.AdapterConditionStatusUnknown, - Reason: util.PtrString("StartupPending"), - }, - { - Type: "Progressing", - Status: openapi.AdapterConditionStatusTrue, - }, - }, - nil, - ) - - resp, err := client.PostClusterStatusesWithResponse( - ctx, cluster.ID, - openapi.PostClusterStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Error posting cluster status: %v", err) - Expect(resp.StatusCode()).To(Equal(http.StatusNoContent), - "Expected 204 No Content when Available=Unknown among multiple conditions") -} - -// TestAdapterStatusPagingEdgeCases tests edge cases in pagination -func TestAdapterStatusPagingEdgeCases(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a cluster - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Create exactly 10 statuses - for i := 0; i < 10; i++ { - statusInput := newAdapterStatusRequest( - fmt.Sprintf("edge-adapter-%d", i), - cluster.Generation, - []openapi.ConditionRequest{ - { - Type: "Ready", - Status: openapi.AdapterConditionStatusTrue, - }, - }, - nil, - ) - _, err := client.PostClusterStatusesWithResponse( - ctx, cluster.ID, - openapi.PostClusterStatusesJSONRequestBody(statusInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - } - - // Test 1: Empty dataset pagination (different cluster with no statuses) - emptyCluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - emptyResp, err := client.GetClusterStatusesWithResponse(ctx, emptyCluster.ID, nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - Expect(emptyResp.JSON200).NotTo(BeNil()) - Expect(emptyResp.JSON200.Total).To(Equal(int32(0))) - Expect(len(emptyResp.JSON200.Items)).To(Equal(0)) - - // Test 2: Page beyond total pages - page100 := openapi.QueryParamsPage(100) - pageSize5 := openapi.QueryParamsPageSize(5) - beyondParams := &openapi.GetClusterStatusesParams{ - Page: &page100, - PageSize: &pageSize5, - } - beyondResp, err := client.GetClusterStatusesWithResponse(ctx, cluster.ID, beyondParams, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - Expect(beyondResp.JSON200).NotTo(BeNil()) - Expect(len(beyondResp.JSON200.Items)).To(Equal(0), "Should return empty when page exceeds total pages") - Expect(beyondResp.JSON200.Total).To(Equal(int32(10)), "Total should still reflect actual count") - - // Test 3: Single item dataset - singleCluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - singleStatus := newAdapterStatusRequest( - "single-adapter", - singleCluster.Generation, - []openapi.ConditionRequest{ - { - Type: "Ready", - Status: openapi.AdapterConditionStatusTrue, - }, - }, - nil, - ) - _, err = client.PostClusterStatusesWithResponse( - ctx, singleCluster.ID, - openapi.PostClusterStatusesJSONRequestBody(singleStatus), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - - singleResp, err := client.GetClusterStatusesWithResponse(ctx, singleCluster.ID, nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - Expect(singleResp.JSON200).NotTo(BeNil()) - Expect(singleResp.JSON200.Total).To(Equal(int32(1))) - Expect(len(singleResp.JSON200.Items)).To(Equal(1)) - Expect(singleResp.JSON200.Page).To(Equal(int32(1))) - - // Test 4: Pagination consistency - verify no duplicates and no missing items - allItems := make(map[string]bool) - pageNum := openapi.QueryParamsPage(1) - pageSz := openapi.QueryParamsPageSize(3) - - for { - params := &openapi.GetClusterStatusesParams{ - Page: &pageNum, - PageSize: &pageSz, - } - listResp, err := client.GetClusterStatusesWithResponse(ctx, cluster.ID, params, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - Expect(listResp.JSON200).NotTo(BeNil()) - - if len(listResp.JSON200.Items) == 0 { - break - } - - for _, item := range listResp.JSON200.Items { - adapter := item.Adapter - Expect(allItems[adapter]).To(BeFalse(), "Duplicate adapter found in pagination: %s", adapter) - allItems[adapter] = true - } - - pageNum++ - if pageNum > 10 { - break // Safety limit - } - } - - // Verify we got all 10 unique adapters - Expect(len(allItems)).To(Equal(10), "Should retrieve all items exactly once across pages") -} diff --git a/test/integration/clusters_test.go b/test/integration/clusters_test.go deleted file mode 100644 index ec672b2..0000000 --- a/test/integration/clusters_test.go +++ /dev/null @@ -1,780 +0,0 @@ -package integration - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "os" - "strings" - "testing" - "time" - - . "github.com/onsi/gomega" - "gopkg.in/resty.v1" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/util" - "github.com/openshift-hyperfleet/hyperfleet-api/test" -) - -func TestClusterGet(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // 401 using no JWT token - resp, err := client.GetClusterByIdWithResponse(context.Background(), "foo", nil) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusUnauthorized), "Expected 401 but got %d", resp.StatusCode()) - - // GET responses per openapi spec: 200 and 404, - resp, err = client.GetClusterByIdWithResponse(ctx, "foo", nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusNotFound), "Expected 404") - - clusterModel, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - resp, err = client.GetClusterByIdWithResponse(ctx, clusterModel.ID, nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusOK)) - - clusterOutput := resp.JSON200 - Expect(clusterOutput).NotTo(BeNil()) - Expect(*clusterOutput.Id).To(Equal(clusterModel.ID), "found object does not match test object") - Expect(*clusterOutput.Kind).To(Equal("Cluster")) - Expect(*clusterOutput.Href).To(Equal(fmt.Sprintf("/api/hyperfleet/v1/clusters/%s", clusterModel.ID))) - Expect(clusterOutput.CreatedTime).To(BeTemporally("~", clusterModel.CreatedTime)) - Expect(clusterOutput.UpdatedTime).To(BeTemporally("~", clusterModel.UpdatedTime)) -} - -func TestClusterPost(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // POST responses per openapi spec: 201, 409, 500 - clusterInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: "test-name", - Spec: map[string]interface{}{"test": "spec"}, - } - - // 201 Created - resp, err := client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(clusterInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Error posting object: %v", err) - Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) - - clusterOutput := resp.JSON201 - Expect(clusterOutput).NotTo(BeNil()) - Expect(*clusterOutput.Id).NotTo(BeEmpty(), "Expected ID assigned on creation") - Expect(*clusterOutput.Kind).To(Equal("Cluster")) - Expect(*clusterOutput.Href).To(Equal(fmt.Sprintf("/api/hyperfleet/v1/clusters/%s", *clusterOutput.Id))) - - // 400 bad request. posting junk json is one way to trigger 400. - jwtToken := test.GetAccessTokenFromContext(ctx) - restyResp, err := resty.R(). - SetHeader("Content-Type", "application/json"). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). - SetBody(`{ this is invalid }`). - Post(h.RestURL("/clusters")) - Expect(err).ToNot(HaveOccurred(), "Error object: %v", err) - Expect(restyResp.StatusCode()).To(Equal(http.StatusBadRequest)) -} - -// TestClusterPatch is disabled because PATCH endpoints are not implemented -// func TestClusterPatch(t *testing.T) { -// // PATCH not implemented in current API -// } - -func TestClusterPaging(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Paging - _, err := h.Factories.NewClustersList("Bronto", 20) - Expect(err).NotTo(HaveOccurred()) - - resp, err := client.GetClustersWithResponse(ctx, nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Error getting cluster list: %v", err) - list := resp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(len(list.Items)).To(Equal(20)) - Expect(list.Size).To(Equal(int32(20))) - Expect(list.Total).To(Equal(int32(20))) - Expect(list.Page).To(Equal(int32(1))) - - page := openapi.QueryParamsPage(2) - pageSize := openapi.QueryParamsPageSize(5) - params := &openapi.GetClustersParams{ - Page: &page, - PageSize: &pageSize, - } - resp, err = client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Error getting cluster list: %v", err) - list = resp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(len(list.Items)).To(Equal(5)) - Expect(list.Size).To(Equal(int32(5))) - Expect(list.Total).To(Equal(int32(20))) - Expect(list.Page).To(Equal(int32(2))) -} - -func TestClusterListSearch(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - clusters, err := h.Factories.NewClustersList("bronto", 20) - Expect(err).NotTo(HaveOccurred(), "Error creating test clusters: %v", err) - - searchStr := fmt.Sprintf("id in ('%s')", clusters[0].ID) - search := openapi.SearchParams(searchStr) - params := &openapi.GetClustersParams{ - Search: &search, - } - resp, err := client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Error getting cluster list: %v", err) - list := resp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(len(list.Items)).To(Equal(1)) - Expect(list.Total).To(Equal(int32(1))) - Expect(*list.Items[0].Id).To(Equal(clusters[0].ID)) -} - -// TestClusterSearchSQLInjection tests SQL injection protection in search -func TestClusterSearchSQLInjection(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a few clusters - clusters, err := h.Factories.NewClustersList("injection-test", 5) - Expect(err).NotTo(HaveOccurred()) - - // Test 1: SQL injection attempt with OR - maliciousSearchStr := "id='anything' OR '1'='1'" - maliciousSearch := openapi.SearchParams(maliciousSearchStr) - params := &openapi.GetClustersParams{ - Search: &maliciousSearch, - } - _, err = client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - // Should either return 400 error or return empty/controlled results - // Not crash or return all data - if err == nil { - // If no error, the search should not return everything - t.Logf("Search with SQL injection did not error - implementation may handle it gracefully") - } - - // Test 2: SQL injection attempt with DROP - dropSearchStr := "id='; DROP TABLE clusters; --" - dropSearch := openapi.SearchParams(dropSearchStr) - params = &openapi.GetClustersParams{ - Search: &dropSearch, - } - _, err = client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - // Should not crash - if err == nil { - t.Logf("Search with DROP statement did not error - implementation may handle it gracefully") - } - - // Test 3: Verify clusters still exist after injection attempts - resp, err := client.GetClustersWithResponse(ctx, nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - list := resp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(list.Total).To(BeNumerically(">=", 5), "Clusters should still exist after injection attempts") - - // Test 4: Valid search still works - validSearchStr := fmt.Sprintf("id='%s'", clusters[0].ID) - validSearch := openapi.SearchParams(validSearchStr) - params = &openapi.GetClustersParams{ - Search: &validSearch, - } - resp, err = client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - Expect(len(resp.JSON200.Items)).To(BeNumerically(">=", 0)) -} - -// TestClusterDuplicateNames tests that duplicate cluster names are rejected -func TestClusterDuplicateNames(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create first cluster with a specific name - clusterInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: "duplicate-name-test", - Spec: map[string]interface{}{"test": "spec1"}, - } - - resp, err := client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(clusterInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) - id1 := *resp.JSON201.Id - - // Create second cluster with the SAME name - // Names are unique, so this should return 409 Conflict - resp, err = client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(clusterInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()). - To(Equal(http.StatusConflict), "Expected 409 Conflict for duplicate name") - - // Verify first cluster still exists - getResp, err := client.GetClusterByIdWithResponse( - ctx, id1, nil, test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(getResp.JSON200.Name).To(Equal("duplicate-name-test")) -} - -// TestClusterBoundaryValues tests boundary values for cluster fields -func TestClusterBoundaryValues(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Test 1: Maximum name length (database limit is 63 characters) - longName := "" - for i := 0; i < 63; i++ { - longName += "a" - } - - longNameInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: longName, - Spec: map[string]interface{}{"test": "spec"}, - } - - resp, err := client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(longNameInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Should accept name up to 63 characters") - Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) - Expect(resp.JSON201.Name).To(Equal(longName)) - - // Test exceeding max length (64 characters should fail) - tooLongName := longName + "a" - tooLongInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: tooLongName, - Spec: map[string]interface{}{"test": "spec"}, - } - resp, err = client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(tooLongInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()). - To(Equal(http.StatusBadRequest), "Should reject name exceeding 63 characters") - - // Test 2: Empty name - emptyNameInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: "", - Spec: map[string]interface{}{"test": "spec"}, - } - - resp, err = client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(emptyNameInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()). - To(Equal(http.StatusBadRequest), "Should reject empty name") - - // Test 3: Large spec JSON (test with ~10KB JSON) - largeSpec := make(map[string]interface{}) - for i := 0; i < 100; i++ { - largeSpec[fmt.Sprintf("key_%d", i)] = fmt.Sprintf("value_%d_with_some_padding_to_increase_size_xxxxxxxxxxxxxxxxxx", i) - } - - largeSpecInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: "large-spec-test", - Spec: largeSpec, - } - - resp, err = client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(largeSpecInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Should accept large spec JSON") - Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) - - // Verify the spec was stored correctly - getResp, err := client.GetClusterByIdWithResponse( - ctx, *resp.JSON201.Id, nil, test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(len(getResp.JSON200.Spec)).To(Equal(100)) - - // Test 4: Unicode in name (should be rejected - pattern only allows [a-z0-9-]) - unicodeNameInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: "テスト-δοκιμή-🚀", - Spec: map[string]interface{}{"test": "spec"}, - } - - resp, err = client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(unicodeNameInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest), - "Should reject unicode in name (pattern is ^[a-z0-9-]+$)") -} - -// TestClusterSchemaValidation tests schema validation for cluster specs -// Note: This test validates against the base openapi.yaml schema which has an empty ClusterSpec -// The base schema accepts any JSON object, so this test mainly verifies the middleware is working -func TestClusterSchemaValidation(t *testing.T) { - RegisterTestingT(t) - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Test 1: Valid cluster spec (base schema accepts any object) - validSpec := map[string]interface{}{ - "region": "us-central1", - "provider": "gcp", - } - - validInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: "schema-valid-test", - Spec: validSpec, - } - - resp, err := client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(validInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Valid spec should be accepted") - Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) - Expect(*resp.JSON201.Id).NotTo(BeEmpty()) - - // Test 2: Invalid spec type (spec must be object, not string) - // This should fail even with base schema - // Can't use the generated struct because Spec is typed as map[string]interface{} - // So we send raw JSON request - invalidTypeJSON := `{ - "kind": "Cluster", - "name": "schema-invalid-type", - "spec": "invalid-string-spec" - }` - - jwtToken := test.GetAccessTokenFromContext(ctx) - - resp2, _ := resty.R(). - SetHeader("Content-Type", "application/json"). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). - SetBody(invalidTypeJSON). - Post(h.RestURL("/clusters")) - - if resp2.StatusCode() == http.StatusBadRequest { - t.Logf("Schema validation correctly rejected invalid spec type") - // Verify error response contains details - var errorResponse openapi.Error - _ = json.Unmarshal(resp2.Body(), &errorResponse) - Expect(errorResponse.Code).ToNot(BeNil()) - Expect(errorResponse.Detail).ToNot(BeNil()) - } else { - t.Logf("Base schema may accept any spec type, status: %d", resp2.StatusCode()) - } - - // Test 3: Empty spec (should be valid as spec is optional in base schema) - emptySpecInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: "schema-empty-spec", - Spec: map[string]interface{}{}, - } - - resp3, err := client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(emptySpecInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Empty spec should be accepted by base schema") - Expect(resp3.StatusCode()).To(Equal(http.StatusCreated)) - Expect(*resp3.JSON201.Id).NotTo(BeEmpty()) -} - -// TestClusterSchemaValidationWithProviderSchema tests schema validation with a provider-specific schema -// This test will only work if OPENAPI_SCHEMA_PATH is set to a provider schema (e.g., gcp_openapi.yaml) -// When using the base schema, this test will be skipped -func TestClusterSchemaValidationWithProviderSchema(t *testing.T) { - RegisterTestingT(t) - - // Check if we're using a provider schema or base schema - // If base schema, skip detailed validation tests - schemaPath := os.Getenv("OPENAPI_SCHEMA_PATH") - if schemaPath == "" || strings.HasSuffix(schemaPath, "openapi/openapi.yaml") { - t.Skip("Skipping provider schema validation test - using base schema") - return - } - - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Test with provider-specific schema (assumes GCP schema for this example) - // If using a different provider, adjust the spec accordingly - - // Test 1: Invalid spec - missing required field - invalidSpec := map[string]interface{}{ - "gcp": map[string]interface{}{ - // Missing required "region" field - "zone": "us-central1-a", - }, - } - - invalidInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: "provider-schema-invalid", - Spec: invalidSpec, - } - - resp, err := client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(invalidInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest), - "Should reject spec with missing required field") - - // Parse error response to verify field-level details - bodyBytes, err := io.ReadAll(resp.HTTPResponse.Body) - if err != nil { - t.Fatalf("failed to read response body: %v", err) - } - - var errorResponse openapi.Error - if err := json.Unmarshal(bodyBytes, &errorResponse); err != nil { - t.Fatalf("failed to unmarshal error response body: %v", err) - } - - Expect(errorResponse.Code).ToNot(BeNil()) - Expect(*errorResponse.Code).To(Equal("HYPERFLEET-VAL-000")) // Validation error code (RFC 9457 format) - Expect(errorResponse.Errors).ToNot(BeEmpty(), "Should include field-level error details") - - // Verify errors contain field path - foundRegionError := false - if errorResponse.Errors != nil { - for _, detail := range *errorResponse.Errors { - if strings.Contains(detail.Field, "region") { - foundRegionError = true - break - } - } - } - Expect(foundRegionError).To(BeTrue(), "Error details should mention missing 'region' field") -} - -// TestClusterSchemaValidationErrorDetails tests that validation errors include detailed field information -func TestClusterSchemaValidationErrorDetails(t *testing.T) { - RegisterTestingT(t) - h, _ := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Send request with spec field as wrong type (not an object) - invalidTypeRequest := map[string]interface{}{ - "kind": "Cluster", - "name": "error-details-test", - "spec": "not-an-object", // Invalid type - } - - body, _ := json.Marshal(invalidTypeRequest) - jwtToken := test.GetAccessTokenFromContext(ctx) - - resp, err := resty.R(). - SetHeader("Content-Type", "application/json"). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). - SetBody(body). - Post(h.RestURL("/clusters")) - - Expect(err).To(BeNil()) - - // Log response for debugging - t.Logf("Response status: %d, body: %s", resp.StatusCode(), string(resp.Body())) - - Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest), "Should return 400 for invalid spec type") - - // Parse error response - var errorResponse openapi.Error - if err := json.Unmarshal(resp.Body(), &errorResponse); err != nil { - t.Fatalf("failed to unmarshal error response: %v, response body: %s", err, string(resp.Body())) - } - - // Verify error structure (RFC 9457 Problem Details format) - Expect(errorResponse.Type).ToNot(BeEmpty()) - Expect(errorResponse.Title).ToNot(BeEmpty()) - - Expect(errorResponse.Code).ToNot(BeNil()) - // Both HYPERFLEET-VAL-000 (validation error) and HYPERFLEET-VAL-006 (malformed request) are acceptable - // as they both indicate the spec field is invalid - validCodes := []string{"HYPERFLEET-VAL-000", "HYPERFLEET-VAL-006"} - Expect(validCodes).To(ContainElement(*errorResponse.Code), "Expected validation or format error code") - - Expect(errorResponse.Detail).ToNot(BeNil()) - Expect(*errorResponse.Detail).To(ContainSubstring("spec")) - - Expect(errorResponse.Instance).ToNot(BeNil()) - Expect(errorResponse.TraceId).ToNot(BeNil()) - - t.Logf("Error response: code=%s, detail=%s", *errorResponse.Code, *errorResponse.Detail) -} - -// TestClusterList_DefaultSorting tests that clusters are sorted by created_time desc by default -func TestClusterList_DefaultSorting(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create 3 clusters with delays to ensure different timestamps - var createdClusters []openapi.Cluster - for i := 1; i <= 3; i++ { - clusterInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: fmt.Sprintf("sort-test-%d-%s", i, strings.ToLower(h.NewID())), - Spec: map[string]interface{}{"test": fmt.Sprintf("value-%d", i)}, - } - - resp, err := client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(clusterInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Failed to create cluster %d", i) - createdClusters = append(createdClusters, *resp.JSON201) - - // Add 100ms delay to ensure different created_time - time.Sleep(100 * time.Millisecond) - } - - // List clusters without orderBy parameter - should default to created_time desc - listResp, err := client.GetClustersWithResponse( - ctx, nil, test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Failed to list clusters") - list := listResp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(len(list.Items)).To(BeNumerically(">=", 3), "Should have at least 3 clusters") - - // Find our test clusters in the response - var testClusters []openapi.Cluster - for _, item := range list.Items { - for _, created := range createdClusters { - if *item.Id == *created.Id { - testClusters = append(testClusters, item) - break - } - } - } - - Expect(len(testClusters)).To(Equal(3), "Should find all 3 test clusters") - - // Verify they are sorted by created_time desc (newest first) - // testClusters should be in reverse creation order - Expect(*testClusters[0].Id).To(Equal(*createdClusters[2].Id), "First cluster should be the last created") - Expect(*testClusters[1].Id).To(Equal(*createdClusters[1].Id), "Second cluster should be the middle created") - Expect(*testClusters[2].Id).To(Equal(*createdClusters[0].Id), "Third cluster should be the first created") - - t.Logf("✓ Default sorting works: clusters sorted by created_time desc") -} - -// TestClusterList_OrderByName tests custom sorting by name -func TestClusterList_OrderByName(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create clusters with names that will sort alphabetically - testPrefix := fmt.Sprintf("name-sort-%s", strings.ToLower(h.NewID())) - names := []string{ - fmt.Sprintf("%s-charlie", testPrefix), - fmt.Sprintf("%s-alpha", testPrefix), - fmt.Sprintf("%s-bravo", testPrefix), - } - - for _, name := range names { - clusterInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: name, - Spec: map[string]interface{}{"test": "value"}, - } - - _, err := client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(clusterInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Failed to create cluster %s", name) - } - - // List with orderBy=name asc - orderByStr := "name asc" - orderBy := openapi.QueryParamsOrderBy(orderByStr) - params := &openapi.GetClustersParams{ - OrderBy: &orderBy, - } - listResp, err := client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Failed to list clusters with orderBy") - list := listResp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(len(list.Items)).To(BeNumerically(">=", 3), "Should have at least 3 clusters") - - // Find our test clusters in the response - var testClusters []openapi.Cluster - for _, item := range list.Items { - if strings.HasPrefix(item.Name, testPrefix) { - testClusters = append(testClusters, item) - } - } - - Expect(len(testClusters)).To(Equal(3), "Should find all 3 test clusters") - - // Verify they are sorted by name asc (alphabetically) - Expect(testClusters[0].Name).To(ContainSubstring("alpha"), "First should be alpha") - Expect(testClusters[1].Name).To(ContainSubstring("bravo"), "Second should be bravo") - Expect(testClusters[2].Name).To(ContainSubstring("charlie"), "Third should be charlie") - - t.Logf("✓ Custom sorting works: clusters sorted by name asc") -} - -// TestClusterList_OrderByNameDesc tests sorting by name descending -func TestClusterList_OrderByNameDesc(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create clusters with names that will sort alphabetically - testPrefix := fmt.Sprintf("desc-sort-%s", strings.ToLower(h.NewID())) - names := []string{ - fmt.Sprintf("%s-alpha", testPrefix), - fmt.Sprintf("%s-charlie", testPrefix), - fmt.Sprintf("%s-bravo", testPrefix), - } - - for _, name := range names { - clusterInput := openapi.ClusterCreateRequest{ - Kind: util.PtrString("Cluster"), - Name: name, - Spec: map[string]interface{}{"test": "value"}, - } - - _, err := client.PostClusterWithResponse( - ctx, openapi.PostClusterJSONRequestBody(clusterInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Failed to create cluster %s", name) - } - - // List with orderBy=name desc - orderByStr := "name desc" - orderBy := openapi.QueryParamsOrderBy(orderByStr) - params := &openapi.GetClustersParams{ - OrderBy: &orderBy, - } - listResp, err := client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Failed to list clusters with orderBy desc") - list := listResp.JSON200 - - // Find our test clusters in the response - var testClusters []openapi.Cluster - for _, item := range list.Items { - if strings.HasPrefix(item.Name, testPrefix) { - testClusters = append(testClusters, item) - } - } - - Expect(len(testClusters)).To(Equal(3), "Should find all 3 test clusters") - - // Verify they are sorted by name desc (reverse alphabetically) - Expect(testClusters[0].Name).To(ContainSubstring("charlie"), "First should be charlie") - Expect(testClusters[1].Name).To(ContainSubstring("bravo"), "Second should be bravo") - Expect(testClusters[2].Name).To(ContainSubstring("alpha"), "Third should be alpha") - - t.Logf("✓ Descending sorting works: clusters sorted by name desc") -} - -// TestClusterPost_EmptyKind tests that empty kind field returns 400 -func TestClusterPost_EmptyKind(t *testing.T) { - h, _ := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - jwtToken := test.GetAccessTokenFromContext(ctx) - - // Send request with empty kind - invalidInput := `{ - "kind": "", - "name": "test-cluster", - "spec": {} - }` - - restyResp, err := resty.R(). - SetHeader("Content-Type", "application/json"). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). - SetBody(invalidInput). - Post(h.RestURL("/clusters")) - - Expect(err).ToNot(HaveOccurred()) - Expect(restyResp.StatusCode()).To(Equal(http.StatusBadRequest)) - - // Parse error response - var errorResponse map[string]interface{} - err = json.Unmarshal(restyResp.Body(), &errorResponse) - Expect(err).ToNot(HaveOccurred()) - - // Verify error message contains "kind is required" (RFC 9457 uses "detail" field) - detail, ok := errorResponse["detail"].(string) - Expect(ok).To(BeTrue()) - Expect(detail).To(ContainSubstring("kind is required")) -} - -// TestClusterPost_WrongKind tests that wrong kind field returns 400 -func TestClusterPost_WrongKind(t *testing.T) { - h, _ := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - jwtToken := test.GetAccessTokenFromContext(ctx) - - // Send request with wrong kind - invalidInput := `{ - "kind": "NodePool", - "name": "test-cluster", - "spec": {} - }` - - restyResp, err := resty.R(). - SetHeader("Content-Type", "application/json"). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). - SetBody(invalidInput). - Post(h.RestURL("/clusters")) - - Expect(err).ToNot(HaveOccurred()) - Expect(restyResp.StatusCode()).To(Equal(http.StatusBadRequest)) - - // Parse error response - var errorResponse map[string]interface{} - err = json.Unmarshal(restyResp.Body(), &errorResponse) - Expect(err).ToNot(HaveOccurred()) - - // Verify error message contains "kind must be 'Cluster'" (RFC 9457 uses "detail" field) - detail, ok := errorResponse["detail"].(string) - Expect(ok).To(BeTrue()) - Expect(detail).To(ContainSubstring("kind must be 'Cluster'")) -} diff --git a/test/integration/node_pools_test.go b/test/integration/node_pools_test.go deleted file mode 100644 index c6796f4..0000000 --- a/test/integration/node_pools_test.go +++ /dev/null @@ -1,321 +0,0 @@ -package integration - -import ( - "encoding/json" - "fmt" - "net/http" - "testing" - - . "github.com/onsi/gomega" - "gopkg.in/resty.v1" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/test" -) - -// TestNodePoolGet is disabled because GET /nodepools/{id} is not in the OpenAPI spec -// The API only supports: -// - GET /api/hyperfleet/v1/nodepools (list all nodepools) -// - GET /api/hyperfleet/v1/clusters/{cluster_id}/nodepools (list nodepools by cluster) -// - POST /api/hyperfleet/v1/clusters/{cluster_id}/nodepools (create nodepool) -// func TestNodePoolGet(t *testing.T) { -// h, client := test.RegisterIntegration(t) -// -// account := h.NewRandAccount() -// ctx := h.NewAuthenticatedContext(account) -// -// // 401 using no JWT token -// _, _, err := client.DefaultAPI.GetNodePoolById(context.Background(), "foo").Execute() -// Expect(err).To(HaveOccurred(), "Expected 401 but got nil error") -// -// // GET responses per openapi spec: 200 and 404, -// _, resp, err := client.DefaultAPI.GetNodePoolById(ctx, "foo").Execute() -// Expect(err).To(HaveOccurred(), "Expected 404") -// Expect(resp.StatusCode).To(Equal(http.StatusNotFound)) -// -// nodePoolModel, err := h.Factories.NewNodePools(h.NewID()) -// Expect(err).NotTo(HaveOccurred()) -// -// nodePoolOutput, resp, err := client.DefaultAPI.GetNodePoolById(ctx, nodePoolModel.ID).Execute() -// Expect(err).NotTo(HaveOccurred()) -// Expect(resp.StatusCode).To(Equal(http.StatusOK)) -// -// Expect(*nodePoolOutput.Id).To(Equal(nodePoolModel.ID), "found object does not match test object") -// Expect(*nodePoolOutput.Kind).To(Equal("NodePool")) -// Expect(*nodePoolOutput.Href).To(Equal(fmt.Sprintf("/api/hyperfleet/v1/node_pools/%s", nodePoolModel.ID))) -// Expect(nodePoolOutput.CreatedAt).To(BeTemporally("~", nodePoolModel.CreatedAt)) -// Expect(nodePoolOutput.UpdatedAt).To(BeTemporally("~", nodePoolModel.UpdatedAt)) -// } - -func TestNodePoolPost(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a parent cluster first - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // POST responses per openapi spec: 201, 409, 500 - kind := "NodePool" - nodePoolInput := openapi.NodePoolCreateRequest{ - Kind: &kind, - Name: "test-name", - Spec: map[string]interface{}{"test": "spec"}, - } - - // 201 Created - resp, err := client.CreateNodePoolWithResponse( - ctx, cluster.ID, openapi.CreateNodePoolJSONRequestBody(nodePoolInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Error posting object: %v", err) - Expect(resp.StatusCode()).To(Equal(http.StatusCreated)) - - nodePoolOutput := resp.JSON201 - Expect(nodePoolOutput).NotTo(BeNil()) - Expect(*nodePoolOutput.Id).NotTo(BeEmpty(), "Expected ID assigned on creation") - Expect(*nodePoolOutput.Kind).To(Equal("NodePool")) - Expect(*nodePoolOutput.Href). - To(Equal(fmt.Sprintf("/api/hyperfleet/v1/clusters/%s/nodepools/%s", cluster.ID, *nodePoolOutput.Id))) - - // 400 bad request. posting junk json is one way to trigger 400. - jwtToken := test.GetAccessTokenFromContext(ctx) - restyResp, err := resty.R(). - SetHeader("Content-Type", "application/json"). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). - SetBody(`{ this is invalid }`). - Post(h.RestURL(fmt.Sprintf("/clusters/%s/nodepools", cluster.ID))) - - Expect(restyResp.StatusCode()).To(Equal(http.StatusBadRequest)) - Expect(err).NotTo(HaveOccurred(), "Error posting object: %v", err) -} - -// TestNodePoolPatch is disabled because PATCH endpoints are not implemented -// func TestNodePoolPatch(t *testing.T) { -// // PATCH not implemented in current API -// } - -func TestNodePoolPaging(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Paging - _, err := h.Factories.NewNodePoolsList("Bronto", 20) - Expect(err).NotTo(HaveOccurred()) - - resp, err := client.GetNodePoolsWithResponse(ctx, nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Error getting nodePool list: %v", err) - list := resp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(len(list.Items)).To(Equal(20)) - Expect(list.Size).To(Equal(int32(20))) - Expect(list.Total).To(Equal(int32(20))) - Expect(list.Page).To(Equal(int32(1))) - - page := openapi.QueryParamsPage(2) - pageSize := openapi.QueryParamsPageSize(5) - params := &openapi.GetNodePoolsParams{ - Page: &page, - PageSize: &pageSize, - } - resp, err = client.GetNodePoolsWithResponse(ctx, params, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Error getting nodePool list: %v", err) - list = resp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(len(list.Items)).To(Equal(5)) - Expect(list.Size).To(Equal(int32(5))) - Expect(list.Total).To(Equal(int32(20))) - Expect(list.Page).To(Equal(int32(2))) -} - -func TestNodePoolListSearch(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - nodePools, err := h.Factories.NewNodePoolsList("bronto", 20) - Expect(err).NotTo(HaveOccurred(), "Error creating test nodepools: %v", err) - - searchStr := fmt.Sprintf("id in ('%s')", nodePools[0].ID) - search := openapi.SearchParams(searchStr) - params := &openapi.GetNodePoolsParams{ - Search: &search, - } - resp, err := client.GetNodePoolsWithResponse(ctx, params, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Error getting nodePool list: %v", err) - list := resp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(len(list.Items)).To(Equal(1)) - Expect(list.Total).To(Equal(int32(1))) - Expect(*list.Items[0].Id).To(Equal(nodePools[0].ID)) -} - -func TestNodePoolsByClusterId(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a cluster first - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Create nodepools for this cluster - // Note: In a real implementation, nodepools would be associated with the cluster - // For now, we're just creating nodepools and testing the endpoint exists - _, err = h.Factories.NewNodePoolsList("cluster-nodepools", 5) - Expect(err).NotTo(HaveOccurred()) - - // Get nodepools by cluster ID - resp, err := client.GetNodePoolsByClusterIdWithResponse(ctx, cluster.ID, nil, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Error getting nodepools by cluster ID: %v", err) - Expect(resp.StatusCode()).To(Equal(http.StatusOK)) - Expect(resp.JSON200).NotTo(BeNil()) - // The list might be empty if nodepools aren't properly associated with the cluster - // but the endpoint should work -} - -func TestGetNodePoolByClusterIdAndNodePoolId(t *testing.T) { - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create a cluster first - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Create a nodepool for this cluster using the API - kind := "NodePool" - nodePoolInput := openapi.NodePoolCreateRequest{ - Kind: &kind, - Name: "test-nodepool-get", - Spec: map[string]interface{}{"instance_type": "m5.large", "replicas": 2}, - } - - createResp, err := client.CreateNodePoolWithResponse( - ctx, cluster.ID, openapi.CreateNodePoolJSONRequestBody(nodePoolInput), test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred(), "Error creating nodepool: %v", err) - Expect(createResp.StatusCode()).To(Equal(http.StatusCreated)) - Expect(*createResp.JSON201.Id).NotTo(BeEmpty()) - - nodePoolID := *createResp.JSON201.Id - - // Test 1: Get the nodepool by cluster ID and nodepool ID (200 OK) - getResp, err := client.GetNodePoolByIdWithResponse(ctx, cluster.ID, nodePoolID, test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred(), "Error getting nodepool by cluster and nodepool ID: %v", err) - Expect(getResp.StatusCode()).To(Equal(http.StatusOK)) - retrieved := getResp.JSON200 - Expect(retrieved).NotTo(BeNil()) - Expect(*retrieved.Id).To(Equal(nodePoolID), "Retrieved nodepool ID should match") - Expect(*retrieved.Kind).To(Equal("NodePool")) - Expect(retrieved.Name).To(Equal("test-nodepool-get")) - - // Test 2: Try to get with non-existent nodepool ID (404) - notFoundResp, err := client.GetNodePoolByIdWithResponse(ctx, cluster.ID, "non-existent-id", test.WithAuthToken(ctx)) - Expect(err).NotTo(HaveOccurred()) - Expect(notFoundResp.StatusCode()). - To(Equal(http.StatusNotFound), "Expected 404 for non-existent nodepool") - - // Test 3: Try to get with non-existent cluster ID (404) - notFoundResp, err = client.GetNodePoolByIdWithResponse( - ctx, "non-existent-cluster", nodePoolID, test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(notFoundResp.StatusCode()). - To(Equal(http.StatusNotFound), "Expected 404 for non-existent cluster") - - // Test 4: Create another cluster and verify that nodepool is not accessible from wrong cluster - cluster2, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - wrongClusterResp, err := client.GetNodePoolByIdWithResponse( - ctx, cluster2.ID, nodePoolID, test.WithAuthToken(ctx), - ) - Expect(err).NotTo(HaveOccurred()) - Expect(wrongClusterResp.StatusCode()).To(Equal(http.StatusNotFound), - "Expected 404 when accessing nodepool from wrong cluster") -} - -// TestNodePoolPost_EmptyKind tests that empty kind field returns 400 -func TestNodePoolPost_EmptyKind(t *testing.T) { - h, _ := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - jwtToken := test.GetAccessTokenFromContext(ctx) - - // Create a cluster first - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Send request with empty kind - invalidInput := `{ - "kind": "", - "name": "test-nodepool", - "spec": {} - }` - - restyResp, err := resty.R(). - SetHeader("Content-Type", "application/json"). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). - SetBody(invalidInput). - Post(h.RestURL(fmt.Sprintf("/clusters/%s/nodepools", cluster.ID))) - - Expect(err).ToNot(HaveOccurred()) - Expect(restyResp.StatusCode()).To(Equal(http.StatusBadRequest)) - - // Parse error response - var errorResponse map[string]interface{} - err = json.Unmarshal(restyResp.Body(), &errorResponse) - Expect(err).ToNot(HaveOccurred()) - - // Verify error message contains "kind is required" (RFC 9457 uses "detail" field) - detail, ok := errorResponse["detail"].(string) - Expect(ok).To(BeTrue()) - Expect(detail).To(ContainSubstring("kind is required")) -} - -// TestNodePoolPost_WrongKind tests that wrong kind field returns 400 -func TestNodePoolPost_WrongKind(t *testing.T) { - h, _ := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - jwtToken := test.GetAccessTokenFromContext(ctx) - - // Create a cluster first - cluster, err := h.Factories.NewClusters(h.NewID()) - Expect(err).NotTo(HaveOccurred()) - - // Send request with wrong kind - invalidInput := `{ - "kind": "Cluster", - "name": "test-nodepool", - "spec": {} - }` - - restyResp, err := resty.R(). - SetHeader("Content-Type", "application/json"). - SetHeader("Authorization", fmt.Sprintf("Bearer %s", jwtToken)). - SetBody(invalidInput). - Post(h.RestURL(fmt.Sprintf("/clusters/%s/nodepools", cluster.ID))) - - Expect(err).ToNot(HaveOccurred()) - Expect(restyResp.StatusCode()).To(Equal(http.StatusBadRequest)) - - // Parse error response - var errorResponse map[string]interface{} - err = json.Unmarshal(restyResp.Body(), &errorResponse) - Expect(err).ToNot(HaveOccurred()) - - // Verify error message contains "kind must be 'NodePool'" (RFC 9457 uses "detail" field) - detail, ok := errorResponse["detail"].(string) - Expect(ok).To(BeTrue()) - Expect(detail).To(ContainSubstring("kind must be 'NodePool'")) -} diff --git a/test/integration/search_field_mapping_test.go b/test/integration/search_field_mapping_test.go deleted file mode 100644 index 88d67dd..0000000 --- a/test/integration/search_field_mapping_test.go +++ /dev/null @@ -1,369 +0,0 @@ -package integration - -import ( - "net/http" - "testing" - - . "github.com/onsi/gomega" - - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" - "github.com/openshift-hyperfleet/hyperfleet-api/test" - "github.com/openshift-hyperfleet/hyperfleet-api/test/factories" -) - -// TestSearchLabelsMapping verifies that labels.xxx user-friendly syntax -// correctly maps to JSONB query labels->>'xxx' -func TestSearchLabelsMapping(t *testing.T) { - RegisterTestingT(t) - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create cluster with production labels - prodCluster, err := factories.NewClusterWithLabels(&h.Factories, h.DBFactory, h.NewID(), map[string]string{ - "environment": "production", - "region": "us-east", - }) - Expect(err).NotTo(HaveOccurred()) - - // Create cluster with staging labels - stagingCluster, err := factories.NewClusterWithLabels(&h.Factories, h.DBFactory, h.NewID(), map[string]string{ - "environment": "staging", - }) - Expect(err).NotTo(HaveOccurred()) - - // Query production environment clusters using user-friendly syntax - searchStr := "labels.environment='production'" - search := openapi.SearchParams(searchStr) - params := &openapi.GetClustersParams{ - Search: &search, - } - resp, err := client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusOK)) - list := resp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(list.Total).To(BeNumerically(">=", 1)) - - // Verify returned clusters have correct label - foundProd := false - for _, item := range list.Items { - if *item.Id == prodCluster.ID { - foundProd = true - // Verify labels field contains environment=production - if item.Labels != nil { - Expect(*item.Labels).To(HaveKeyWithValue("environment", "production")) - } - } - // Should not contain stagingCluster - Expect(*item.Id).NotTo(Equal(stagingCluster.ID)) - } - Expect(foundProd).To(BeTrue(), "Expected to find the production cluster") -} - -// TestSearchSpecFieldRejected verifies that querying the spec field -// is correctly rejected with 400 Bad Request error -func TestSearchSpecFieldRejected(t *testing.T) { - RegisterTestingT(t) - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Attempt to query spec field (should be rejected) - searchStr := "spec = '{}'" - search := openapi.SearchParams(searchStr) - params := &openapi.GetClustersParams{ - Search: &search, - } - resp, err := client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - - // Should return error - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest)) -} - -// TestSearchCombinedQuery verifies that combined queries (AND/OR) -// work correctly with field mapping -func TestSearchCombinedQuery(t *testing.T) { - RegisterTestingT(t) - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create cluster with NotReady status (Available=False, Ready=False) and us-east region - matchCluster, err := factories.NewClusterWithStatusAndLabels( - &h.Factories, - h.DBFactory, - h.NewID(), - false, // isAvailable - false, // isReady - map[string]string{"region": "us-east"}, - ) - Expect(err).NotTo(HaveOccurred()) - - // Create cluster with NotReady status but different region - wrongRegionCluster, err := factories.NewClusterWithStatusAndLabels( - &h.Factories, - h.DBFactory, - h.NewID(), - false, // isAvailable - false, // isReady - map[string]string{"region": "us-west"}, - ) - Expect(err).NotTo(HaveOccurred()) - - // Create cluster with Ready status (Available=True, Ready=True) and us-east region - _, err = factories.NewClusterWithStatusAndLabels( - &h.Factories, - h.DBFactory, - h.NewID(), - true, // isAvailable - true, // isReady - map[string]string{"region": "us-east"}, - ) - Expect(err).NotTo(HaveOccurred()) - - // Query using combined AND condition with labels (labels search still works) - searchStr := "labels.region='us-east'" - search := openapi.SearchParams(searchStr) - params := &openapi.GetClustersParams{ - Search: &search, - } - resp, err := client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusOK)) - list := resp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(list.Total).To(BeNumerically(">=", 1)) - - // Should return matchCluster and wrongStatusCluster but not wrongRegionCluster - foundMatch := false - for _, item := range list.Items { - if *item.Id == matchCluster.ID { - foundMatch = true - } - // Should not contain wrongRegionCluster - Expect(*item.Id).NotTo(Equal(wrongRegionCluster.ID)) - } - Expect(foundMatch).To(BeTrue(), "Expected to find the matching cluster") -} - -// TestSearchNodePoolLabelsMapping verifies that NodePool also supports -// the labels field mapping -func TestSearchNodePoolLabelsMapping(t *testing.T) { - RegisterTestingT(t) - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Test labels mapping for NodePools - npWithLabels, err := factories.NewNodePoolWithLabels(&h.Factories, h.DBFactory, h.NewID(), map[string]string{ - "environment": "test", - }) - Expect(err).NotTo(HaveOccurred()) - - searchLabelsStr := "labels.environment='test'" - searchLabels := openapi.SearchParams(searchLabelsStr) - labelsParams := &openapi.GetNodePoolsParams{ - Search: &searchLabels, - } - labelsResp, labelsErr := client.GetNodePoolsWithResponse(ctx, labelsParams, test.WithAuthToken(ctx)) - - Expect(labelsErr).NotTo(HaveOccurred()) - Expect(labelsResp.StatusCode()).To(Equal(http.StatusOK)) - labelsList := labelsResp.JSON200 - Expect(labelsList).NotTo(BeNil()) - - foundLabeled := false - for _, item := range labelsList.Items { - if *item.Id == npWithLabels.ID { - foundLabeled = true - } - } - Expect(foundLabeled).To(BeTrue(), "Expected to find the labeled node pool") -} - -// TestSearchStatusConditionsMapping verifies that status.conditions.='' -// user-friendly syntax correctly maps to JSONB containment query -func TestSearchStatusConditionsMapping(t *testing.T) { - RegisterTestingT(t) - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create cluster with Ready=True, Available=True - readyCluster, err := factories.NewClusterWithStatus(&h.Factories, h.DBFactory, h.NewID(), true, true) - Expect(err).NotTo(HaveOccurred()) - - // Create cluster with Ready=False, Available=True - notReadyCluster, err := factories.NewClusterWithStatus(&h.Factories, h.DBFactory, h.NewID(), true, false) - Expect(err).NotTo(HaveOccurred()) - - // Create cluster with Ready=False, Available=False - notAvailableCluster, err := factories.NewClusterWithStatus(&h.Factories, h.DBFactory, h.NewID(), false, false) - Expect(err).NotTo(HaveOccurred()) - - // Search for Ready=True - searchStr := "status.conditions.Ready='True'" - search := openapi.SearchParams(searchStr) - params := &openapi.GetClustersParams{ - Search: &search, - } - resp, err := client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusOK)) - list := resp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(list.Total).To(BeNumerically(">=", 1)) - - // Verify only readyCluster is returned - foundReady := false - for _, item := range list.Items { - if *item.Id == readyCluster.ID { - foundReady = true - } - // Should not contain notReadyCluster or notAvailableCluster - Expect(*item.Id).NotTo(Equal(notReadyCluster.ID)) - Expect(*item.Id).NotTo(Equal(notAvailableCluster.ID)) - } - Expect(foundReady).To(BeTrue(), "Expected to find the ready cluster") - - // Search for Available=True - searchAvailableStr := "status.conditions.Available='True'" - searchAvailable := openapi.SearchParams(searchAvailableStr) - availableParams := &openapi.GetClustersParams{ - Search: &searchAvailable, - } - availableResp, err := client.GetClustersWithResponse(ctx, availableParams, test.WithAuthToken(ctx)) - - Expect(err).NotTo(HaveOccurred()) - Expect(availableResp.StatusCode()).To(Equal(http.StatusOK)) - availableList := availableResp.JSON200 - Expect(availableList).NotTo(BeNil()) - Expect(availableList.Total).To(BeNumerically(">=", 2)) - - // Should contain readyCluster and notReadyCluster (both have Available=True) - foundReadyInAvailable := false - foundNotReadyInAvailable := false - for _, item := range availableList.Items { - if *item.Id == readyCluster.ID { - foundReadyInAvailable = true - } - if *item.Id == notReadyCluster.ID { - foundNotReadyInAvailable = true - } - // Should not contain notAvailableCluster - Expect(*item.Id).NotTo(Equal(notAvailableCluster.ID)) - } - Expect(foundReadyInAvailable).To(BeTrue(), "Expected to find ready cluster in Available=True search") - Expect(foundNotReadyInAvailable).To(BeTrue(), "Expected to find notReady cluster in Available=True search") -} - -// TestSearchStatusConditionsCombinedWithLabels verifies that condition queries -// can be combined with label queries using AND -func TestSearchStatusConditionsCombinedWithLabels(t *testing.T) { - RegisterTestingT(t) - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Create cluster with Ready=True and region=us-east - matchCluster, err := factories.NewClusterWithStatusAndLabels( - &h.Factories, - h.DBFactory, - h.NewID(), - true, // isAvailable - true, // isReady - map[string]string{"region": "us-east"}, - ) - Expect(err).NotTo(HaveOccurred()) - - // Create cluster with Ready=True but wrong region - wrongRegionCluster, err := factories.NewClusterWithStatusAndLabels( - &h.Factories, - h.DBFactory, - h.NewID(), - true, // isAvailable - true, // isReady - map[string]string{"region": "us-west"}, - ) - Expect(err).NotTo(HaveOccurred()) - - // Create cluster with correct region but Ready=False - wrongStatusCluster, err := factories.NewClusterWithStatusAndLabels( - &h.Factories, - h.DBFactory, - h.NewID(), - true, // isAvailable - false, // isReady - map[string]string{"region": "us-east"}, - ) - Expect(err).NotTo(HaveOccurred()) - - // Search for Ready=True AND region=us-east - searchStr := "status.conditions.Ready='True' AND labels.region='us-east'" - search := openapi.SearchParams(searchStr) - params := &openapi.GetClustersParams{ - Search: &search, - } - resp, err := client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusOK)) - list := resp.JSON200 - Expect(list).NotTo(BeNil()) - Expect(list.Total).To(BeNumerically(">=", 1)) - - // Should only find matchCluster - foundMatch := false - for _, item := range list.Items { - if *item.Id == matchCluster.ID { - foundMatch = true - } - // Should not contain wrongRegionCluster or wrongStatusCluster - Expect(*item.Id).NotTo(Equal(wrongRegionCluster.ID)) - Expect(*item.Id).NotTo(Equal(wrongStatusCluster.ID)) - } - Expect(foundMatch).To(BeTrue(), "Expected to find the matching cluster") -} - -// TestSearchStatusConditionsInvalidValues verifies that invalid condition values -// are rejected with 400 Bad Request -func TestSearchStatusConditionsInvalidValues(t *testing.T) { - RegisterTestingT(t) - h, client := test.RegisterIntegration(t) - - account := h.NewRandAccount() - ctx := h.NewAuthenticatedContext(account) - - // Test invalid condition status - searchStr := "status.conditions.Ready='Invalid'" - search := openapi.SearchParams(searchStr) - params := &openapi.GetClustersParams{ - Search: &search, - } - resp, err := client.GetClustersWithResponse(ctx, params, test.WithAuthToken(ctx)) - - Expect(err).NotTo(HaveOccurred()) - Expect(resp.StatusCode()).To(Equal(http.StatusBadRequest)) - - // Test invalid condition type (lowercase) - searchInvalidType := "status.conditions.ready='True'" - searchInvalidTypeParam := openapi.SearchParams(searchInvalidType) - invalidTypeParams := &openapi.GetClustersParams{ - Search: &searchInvalidTypeParam, - } - invalidTypeResp, err := client.GetClustersWithResponse(ctx, invalidTypeParams, test.WithAuthToken(ctx)) - - Expect(err).NotTo(HaveOccurred()) - Expect(invalidTypeResp.StatusCode()).To(Equal(http.StatusBadRequest)) -} From ffccb71e153e83f7de8fe75a4d78781379ef88af Mon Sep 17 00:00:00 2001 From: Ciaran Roche Date: Mon, 9 Feb 2026 12:36:28 +0000 Subject: [PATCH 3/6] Generate OpenAPI spec dynamically from CRDs Replace static OpenAPI specification with dynamic generation from CRD definitions loaded from Kubernetes. This ensures the API documentation always matches the actual CRD schemas. Changes: - Add pkg/openapi package with generator, paths, schemas, and common modules - Extract openAPIV3Schema from CRDs when loading into registry - Update OpenAPI handler to use dynamic generator - Update schema validator to work with generated specs - Remove static openapi/openapi.yaml and oapi-codegen.yaml - Update Makefile to remove generate target (types are now static) - Update Dockerfile to remove static OpenAPI file copy The generated Go types in pkg/api/openapi/openapi.gen.go are preserved as they are still used for request/response handling throughout the codebase. Co-Authored-By: Claude Opus 4.5 --- Dockerfile | 8 +- Makefile | 32 +- cmd/hyperfleet-api/server/routes.go | 50 +- openapi/oapi-codegen.yaml | 17 - openapi/openapi.yaml | 1315 ----------------------- pkg/api/resource_definition.go | 11 + pkg/crd/registry.go | 123 +++ pkg/handlers/openapi.go | 20 +- pkg/openapi/common.go | 580 ++++++++++ pkg/openapi/generator.go | 91 ++ pkg/openapi/generator_test.go | 151 +++ pkg/openapi/paths.go | 598 +++++++++++ pkg/openapi/schemas.go | 368 +++++++ pkg/validators/schema_validator.go | 40 +- pkg/validators/schema_validator_test.go | 6 +- 15 files changed, 1995 insertions(+), 1415 deletions(-) delete mode 100644 openapi/oapi-codegen.yaml delete mode 100644 openapi/openapi.yaml create mode 100644 pkg/openapi/common.go create mode 100644 pkg/openapi/generator.go create mode 100644 pkg/openapi/generator_test.go create mode 100644 pkg/openapi/paths.go create mode 100644 pkg/openapi/schemas.go diff --git a/Dockerfile b/Dockerfile index d3bcdbf..e605952 100755 --- a/Dockerfile +++ b/Dockerfile @@ -30,13 +30,9 @@ WORKDIR /app # Copy binary from builder COPY --from=builder /build/bin/hyperfleet-api /app/hyperfleet-api -# Copy OpenAPI schema for validation (uses the source spec, not the generated one) -COPY --from=builder /build/openapi/openapi.yaml /app/openapi/openapi.yaml - # CRD definitions are now loaded from Kubernetes API at runtime - -# Set default schema path (can be overridden by Helm for provider-specific schemas) -ENV OPENAPI_SCHEMA_PATH=/app/openapi/openapi.yaml +# OpenAPI schema is generated dynamically from CRDs +# For provider-specific schemas, set OPENAPI_SCHEMA_PATH to override EXPOSE 8000 diff --git a/Makefile b/Makefile index f2ffe02..41bcf16 100755 --- a/Makefile +++ b/Makefile @@ -60,9 +60,7 @@ help: @echo "make run/docs run swagger and host the api spec" @echo "make test run unit tests" @echo "make test-integration run integration tests" - @echo "make generate generate openapi modules" @echo "make generate-mocks generate mock implementations for services" - @echo "make generate-all generate all code (openapi + mocks)" @echo "make clean delete temporary generated files" @echo "make image build container image" @echo "make image-push build and push container image" @@ -71,7 +69,6 @@ help: .PHONY: help # Encourage consistent tool versions -OPENAPI_GENERATOR_VERSION:=5.4.0 GO_VERSION:=go1.24. ### Constants: @@ -135,14 +132,14 @@ lint: $(GOLANGCI_LINT) # Build binaries # NOTE it may be necessary to use CGO_ENABLED=0 for backwards compatibility with centos7 if not using centos7 -build: check-gopath generate-all +build: check-gopath generate-mocks @mkdir -p bin echo "Building version: ${build_version}" CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=boringcrypto ${GO} build -ldflags="$(ldflags)" -o bin/hyperfleet-api ./cmd/hyperfleet-api .PHONY: build # Install -install: check-gopath generate-all +install: check-gopath generate-mocks CGO_ENABLED=$(CGO_ENABLED) GOEXPERIMENT=boringcrypto ${GO} install -ldflags="$(ldflags)" ./cmd/hyperfleet-api @ ${GO} version | grep -q "$(GO_VERSION)" || \ ( \ @@ -224,26 +221,17 @@ test-integration: install secrets $(GOTESTSUM) ./test/integration .PHONY: test-integration -# Regenerate openapi types using oapi-codegen -generate: $(OAPI_CODEGEN) - rm -rf pkg/api/openapi - mkdir -p pkg/api/openapi - $(OAPI_CODEGEN) --config openapi/oapi-codegen.yaml openapi/openapi.yaml -.PHONY: generate - # Generate mock implementations for service interfaces generate-mocks: $(MOCKGEN) ${GO} generate ./pkg/services/... .PHONY: generate-mocks -# Generate all code (openapi + mocks) -generate-all: generate generate-mocks +# Generate all code (mocks only - OpenAPI types are now static) +# Note: pkg/api/openapi/openapi.gen.go contains pre-generated types +# OpenAPI spec is now dynamically generated from CRDs at runtime +generate-all: generate-mocks .PHONY: generate-all -# generate-vendor is now equivalent to generate (oapi-codegen handles dependencies) -generate-vendor: generate -.PHONY: generate-vendor - run: build ./bin/hyperfleet-api migrate ./bin/hyperfleet-api serve @@ -253,17 +241,17 @@ run-no-auth: build ./bin/hyperfleet-api migrate ./bin/hyperfleet-api serve --enable-authz=false --enable-jwt=false -# Run Swagger nd host the api docs +# Run Swagger and host the api docs +# Note: With dynamic OpenAPI generation, use the /api/hyperfleet/v1/openapi.html endpoint instead run/docs: - @echo "Please open http://localhost/" - docker run -d -p 80:8080 -e SWAGGER_JSON=/hyperfleet.yaml -v $(PWD)/openapi/hyperfleet.yaml:/hyperfleet.yaml swaggerapi/swagger-ui + @echo "OpenAPI spec is now dynamically generated from CRDs." + @echo "Start the server and visit: http://localhost:8000/api/hyperfleet/v1/openapi.html" .PHONY: run/docs # Delete temporary files clean: rm -rf \ bin \ - pkg/api/openapi \ data/generated/openapi/*.json \ secrets \ .PHONY: clean diff --git a/cmd/hyperfleet-api/server/routes.go b/cmd/hyperfleet-api/server/routes.go index c693476..7f75dc0 100755 --- a/cmd/hyperfleet-api/server/routes.go +++ b/cmd/hyperfleet-api/server/routes.go @@ -12,10 +12,12 @@ import ( "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/server/logging" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/auth" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/crd" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/db" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/handlers" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/middleware" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/openapi" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/validators" ) @@ -114,31 +116,39 @@ func (s *apiServer) routes() *mux.Router { func registerApiMiddleware(router *mux.Router) { router.Use(MetricsMiddleware) - // Schema validation middleware (validates cluster/nodepool spec fields) - // Load schema from environment variable, default to repo base schema - schemaPath := os.Getenv("OPENAPI_SCHEMA_PATH") - if schemaPath == "" { - // Default: use base schema in repo (provider-agnostic) - // Production: Helm sets OPENAPI_SCHEMA_PATH=/etc/hyperfleet/schemas/openapi.yaml - schemaPath = "openapi/openapi.yaml" - } - - // Initialize schema validator (non-blocking - will warn if schema not found) + // Schema validation middleware (validates spec fields for all resources) // Use background context for initialization logging ctx := context.Background() - schemaValidator, err := validators.NewSchemaValidator(schemaPath) - if err != nil { - // Log warning but don't fail - schema validation is optional - logger.With(ctx, logger.FieldSchemaPath, schemaPath).WithError(err).Warn("Failed to load schema validator") - logger.Warn(ctx, "Schema validation is disabled. Spec fields will not be validated.") - logger.Info(ctx, "To enable schema validation:") - logger.Info(ctx, " - Local: Run from repo root, or set OPENAPI_SCHEMA_PATH=openapi/openapi.yaml") - logger.Info(ctx, " - Production: Helm sets OPENAPI_SCHEMA_PATH=/etc/hyperfleet/schemas/openapi.yaml") + // Check if an external schema file is specified (for production with provider-specific schemas) + schemaPath := os.Getenv("OPENAPI_SCHEMA_PATH") + + var schemaValidator *validators.SchemaValidator + var err error + + if schemaPath != "" { + // Production: Load schema from file (Helm sets OPENAPI_SCHEMA_PATH=/etc/hyperfleet/schemas/openapi.yaml) + schemaValidator, err = validators.NewSchemaValidator(schemaPath) + if err != nil { + logger.With(ctx, logger.FieldSchemaPath, schemaPath).WithError(err).Warn("Failed to load schema validator from file") + } else { + logger.With(ctx, logger.FieldSchemaPath, schemaPath).Info("Schema validation enabled from file") + } } else { - // Apply schema validation middleware - logger.With(ctx, logger.FieldSchemaPath, schemaPath).Info("Schema validation enabled") + // Default: Generate schema dynamically from CRD registry + spec := openapi.GenerateSpec(crd.Default()) + schemaValidator, err = validators.NewSchemaValidatorFromSpec(spec) + if err != nil { + logger.WithError(ctx, err).Warn("Failed to create schema validator from generated spec") + } else { + logger.Info(ctx, "Schema validation enabled from dynamically generated spec") + } + } + + if schemaValidator != nil { router.Use(middleware.SchemaValidationMiddleware(schemaValidator)) + } else { + logger.Warn(ctx, "Schema validation is disabled. Spec fields will not be validated.") } router.Use( diff --git a/openapi/oapi-codegen.yaml b/openapi/oapi-codegen.yaml deleted file mode 100644 index 3a71e77..0000000 --- a/openapi/oapi-codegen.yaml +++ /dev/null @@ -1,17 +0,0 @@ -# oapi-codegen configuration -# See: https://github.com/oapi-codegen/oapi-codegen - -package: openapi -output: pkg/api/openapi/openapi.gen.go -generate: - models: true - chi-server: false - client: true - embedded-spec: true -output-options: - skip-prune: false -compatibility: - # Use old allOf merge behavior where schemas are inlined - old-merge-schemas: true - # Use old behavior generating type definitions instead of aliases - old-aliasing: true diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml deleted file mode 100644 index 46f47e7..0000000 --- a/openapi/openapi.yaml +++ /dev/null @@ -1,1315 +0,0 @@ -openapi: 3.0.0 -info: - title: HyperFleet API - version: 1.0.4 - contact: - name: HyperFleet Team - license: - name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0 - description: |- - HyperFleet API provides simple CRUD operations for managing cluster resources and their status history. - - **Architecture**: Simple CRUD only, no business logic, no event creation. - Sentinel operator handles all orchestration logic. - Adapters handle the specifics of managing spec -tags: [] -paths: - /api/hyperfleet/v1/clusters: - get: - operationId: getClusters - summary: List clusters - parameters: - - $ref: '#/components/parameters/SearchParams' - - $ref: '#/components/parameters/QueryParams.page' - - $ref: '#/components/parameters/QueryParams.pageSize' - - $ref: '#/components/parameters/QueryParams.orderBy' - - $ref: '#/components/parameters/QueryParams.order' - responses: - '200': - description: The request has succeeded. - content: - application/json: - schema: - $ref: '#/components/schemas/ClusterList' - '400': - description: The server could not understand the request due to invalid syntax. - default: - description: An unexpected error response. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Error' - security: - - BearerAuth: [] - post: - operationId: postCluster - summary: Create cluster - description: |- - Create a new cluster resource. - - **Note**: The `status` object in the response is read-only and computed by the service. - It is NOT part of the request body. Initially, - status.conditions will include mandatory "Available" and "Ready" conditions. - parameters: [] - responses: - '201': - description: The request has succeeded and a new resource has been created as a result. - content: - application/json: - schema: - $ref: '#/components/schemas/Cluster' - '400': - description: The server could not understand the request due to invalid syntax. - default: - description: An unexpected error response. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Error' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ClusterCreateRequest' - security: - - BearerAuth: [] - /api/hyperfleet/v1/clusters/{cluster_id}: - get: - operationId: getClusterById - summary: Get cluster by ID - parameters: - - $ref: '#/components/parameters/SearchParams' - - name: cluster_id - in: path - required: true - schema: - type: string - responses: - '200': - description: The request has succeeded. - content: - application/json: - schema: - $ref: '#/components/schemas/Cluster' - '400': - description: The server could not understand the request due to invalid syntax. - default: - description: An unexpected error response. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Error' - security: - - BearerAuth: [] - /api/hyperfleet/v1/clusters/{cluster_id}/nodepools: - get: - operationId: getNodePoolsByClusterId - summary: List all nodepools for cluster - description: Returns the list of all nodepools for a cluster - parameters: - - name: cluster_id - in: path - required: true - description: Cluster ID - schema: - type: string - - $ref: '#/components/parameters/SearchParams' - - $ref: '#/components/parameters/QueryParams.page' - - $ref: '#/components/parameters/QueryParams.pageSize' - - $ref: '#/components/parameters/QueryParams.orderBy' - - $ref: '#/components/parameters/QueryParams.order' - responses: - '200': - description: The request has succeeded. - content: - application/json: - schema: - $ref: '#/components/schemas/NodePoolList' - '400': - description: The server could not understand the request due to invalid syntax. - default: - description: An unexpected error response. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Error' - security: - - BearerAuth: [] - post: - operationId: createNodePool - summary: Create nodepool - description: Create a NodePool for a cluster - parameters: - - name: cluster_id - in: path - required: true - description: Cluster ID - schema: - type: string - responses: - '201': - description: The request has succeeded and a new resource has been created as a result. - content: - application/json: - schema: - $ref: '#/components/schemas/NodePoolCreateResponse' - '400': - description: The server could not understand the request due to invalid syntax. - default: - description: An unexpected error response. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Error' - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NodePoolCreateRequest' - security: - - BearerAuth: [] - /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{nodepool_id}: - get: - operationId: getNodePoolById - summary: Get nodepool by ID - description: Returns specific nodepool - parameters: - - name: cluster_id - in: path - required: true - description: Cluster ID - schema: - type: string - - name: nodepool_id - in: path - required: true - description: NodePool ID - schema: - type: string - responses: - '200': - description: The request has succeeded. - content: - application/json: - schema: - $ref: '#/components/schemas/NodePool' - '400': - description: The server could not understand the request due to invalid syntax. - default: - description: An unexpected error response. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Error' - security: - - BearerAuth: [] - /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{nodepool_id}/statuses: - post: - operationId: postNodePoolStatuses - summary: Create or update adapter status - description: |- - Adapter creates or updates its status report for this nodepool. - If adapter already has a status, it will be updated (upsert by adapter name). - - Response includes the full adapter status with all conditions. - Adapter should call this endpoint every time it evaluates the nodepool. - parameters: - - name: cluster_id - in: path - required: true - description: Cluster ID - schema: - type: string - - name: nodepool_id - in: path - required: true - schema: - type: string - responses: - '201': - description: The request has succeeded and a new resource has been created as a result. - content: - application/json: - schema: - $ref: '#/components/schemas/AdapterStatus' - '400': - description: The server could not understand the request due to invalid syntax. - '404': - description: The server cannot find the requested resource. - '409': - description: The request conflicts with the current state of the server. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AdapterStatusCreateRequest' - get: - operationId: getNodePoolsStatuses - summary: List all adapter statuses for nodepools - description: Returns adapter status reports for this nodepool - parameters: - - name: cluster_id - in: path - required: true - description: Cluster ID - schema: - type: string - - name: nodepool_id - in: path - required: true - schema: - type: string - - $ref: '#/components/parameters/SearchParams' - - $ref: '#/components/parameters/QueryParams.page' - - $ref: '#/components/parameters/QueryParams.pageSize' - - $ref: '#/components/parameters/QueryParams.orderBy' - - $ref: '#/components/parameters/QueryParams.order' - responses: - '200': - description: The request has succeeded. - content: - application/json: - schema: - $ref: '#/components/schemas/AdapterStatusList' - '400': - description: The server could not understand the request due to invalid syntax. - default: - description: An unexpected error response. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Error' - /api/hyperfleet/v1/clusters/{cluster_id}/statuses: - post: - operationId: postClusterStatuses - summary: Create or update adapter status - description: |- - Adapter creates or updates its status report for this cluster. - If adapter already has a status, it will be updated (upsert by adapter name). - - Response includes the full adapter status with all conditions. - Adapter should call this endpoint every time it evaluates the cluster. - parameters: - - name: cluster_id - in: path - required: true - description: Cluster ID - schema: - type: string - responses: - '201': - description: The request has succeeded and a new resource has been created as a result. - content: - application/json: - schema: - $ref: '#/components/schemas/AdapterStatus' - '400': - description: The server could not understand the request due to invalid syntax. - '404': - description: The server cannot find the requested resource. - '409': - description: The request conflicts with the current state of the server. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AdapterStatusCreateRequest' - security: - - BearerAuth: [] - get: - operationId: getClusterStatuses - summary: List all adapter statuses for cluster - description: Returns adapter status reports for this cluster - parameters: - - name: cluster_id - in: path - required: true - description: Cluster ID - schema: - type: string - - $ref: '#/components/parameters/SearchParams' - - $ref: '#/components/parameters/QueryParams.page' - - $ref: '#/components/parameters/QueryParams.pageSize' - - $ref: '#/components/parameters/QueryParams.orderBy' - - $ref: '#/components/parameters/QueryParams.order' - responses: - '200': - description: The request has succeeded. - content: - application/json: - schema: - $ref: '#/components/schemas/AdapterStatusList' - '400': - description: The server could not understand the request due to invalid syntax. - '404': - description: The server cannot find the requested resource. - security: - - BearerAuth: [] - /api/hyperfleet/v1/nodepools: - get: - operationId: getNodePools - summary: List all nodepools for cluster - description: Returns the list of all nodepools - parameters: - - $ref: '#/components/parameters/SearchParams' - - $ref: '#/components/parameters/QueryParams.page' - - $ref: '#/components/parameters/QueryParams.pageSize' - - $ref: '#/components/parameters/QueryParams.orderBy' - - $ref: '#/components/parameters/QueryParams.order' - responses: - '200': - description: The request has succeeded. - content: - application/json: - schema: - $ref: '#/components/schemas/NodePoolList' - '400': - description: The server could not understand the request due to invalid syntax. - default: - description: An unexpected error response. - content: - application/problem+json: - schema: - $ref: '#/components/schemas/Error' - security: - - BearerAuth: [] -components: - parameters: - QueryParams.order: - name: order - in: query - required: false - schema: - $ref: '#/components/schemas/OrderDirection' - explode: false - QueryParams.orderBy: - name: orderBy - in: query - required: false - schema: - type: string - default: created_time - explode: false - QueryParams.page: - name: page - in: query - required: false - schema: - type: integer - format: int32 - default: 1 - explode: false - QueryParams.pageSize: - name: pageSize - in: query - required: false - schema: - type: integer - format: int32 - default: 20 - explode: false - SearchParams: - name: search - in: query - required: false - description: |- - Filter results using TSL (Tree Search Language) query syntax. - Examples: `status.conditions.Ready='True'`, `name in ('c1','c2')`, `labels.region='us-east'` - schema: - type: string - explode: false - schemas: - AdapterCondition: - type: object - required: - - type - - last_transition_time - - status - properties: - type: - type: string - description: Condition type - reason: - type: string - description: Machine-readable reason code - message: - type: string - description: Human-readable message - last_transition_time: - type: string - format: date-time - description: |- - When this condition last transitioned status (API-managed) - Only updated when status changes (True/False), not when reason/message changes - status: - $ref: '#/components/schemas/AdapterConditionStatus' - description: |- - Condition in AdapterStatus - Used for standard Kubernetes condition types: "Available", "Applied", "Health" - Note: observed_generation is at AdapterStatus level, not per-condition, - since all conditions in one AdapterStatus share the same observed generation - AdapterConditionStatus: - type: string - enum: - - 'True' - - 'False' - - Unknown - description: Status value for adapter conditions - AdapterStatus: - type: object - required: - - adapter - - observed_generation - - conditions - - created_time - - last_report_time - properties: - adapter: - type: string - description: Adapter name (e.g., "validator", "dns", "provisioner") - observed_generation: - type: integer - format: int32 - description: Which generation of the resource this status reflects - metadata: - type: object - properties: - job_name: - type: string - job_namespace: - type: string - attempt: - type: integer - format: int32 - started_time: - type: string - format: date-time - completed_time: - type: string - format: date-time - duration: - type: string - description: Job execution metadata - data: - type: object - additionalProperties: {} - description: Adapter-specific data (structure varies by adapter type) - conditions: - type: array - items: - $ref: '#/components/schemas/AdapterCondition' - description: |- - Kubernetes-style conditions tracking adapter state - Typically includes: Available, Applied, Health - created_time: - type: string - format: date-time - description: When this adapter status was first created (API-managed) - last_report_time: - type: string - format: date-time - description: |- - When this adapter last reported its status (API-managed) - Updated every time the adapter POSTs, even if conditions haven't changed - Used by Sentinel to detect adapter liveness - description: |- - AdapterStatus represents the complete status report from an adapter - Contains multiple conditions, job metadata, and adapter-specific data - example: - adapter: adapter1 - observed_generation: 1 - conditions: - - type: Available - status: 'True' - reason: This adapter1 is available - message: This adapter1 is available - last_transition_time: '2021-01-01T10:00:00Z' - - type: Applied - status: 'True' - reason: Validation job applied - message: Adapter1 validation job applied successfully - last_transition_time: '2021-01-01T10:00:00Z' - - type: Health - status: 'True' - reason: All adapter1 operations completed successfully - message: All adapter1 runtime operations completed successfully - last_transition_time: '2021-01-01T10:00:00Z' - metadata: - job_name: validator-job-abc123 - job_namespace: hyperfleet-system - attempt: 1 - started_time: '2021-01-01T10:00:00Z' - completed_time: '2021-01-01T10:02:00Z' - duration: 2m - data: - validation_results: - total_tests: 30 - passed: 30 - failed: 0 - created_time: '2021-01-01T10:00:00Z' - last_report_time: '2021-01-01T10:02:00Z' - AdapterStatusCreateRequest: - type: object - required: - - adapter - - observed_generation - - observed_time - - conditions - properties: - adapter: - type: string - description: Adapter name (e.g., "validator", "dns", "provisioner") - observed_generation: - type: integer - format: int32 - description: Which generation of the resource this status reflects - metadata: - type: object - properties: - job_name: - type: string - job_namespace: - type: string - attempt: - type: integer - format: int32 - started_time: - type: string - format: date-time - completed_time: - type: string - format: date-time - duration: - type: string - description: Job execution metadata - data: - type: object - additionalProperties: {} - description: Adapter-specific data (structure varies by adapter type) - observed_time: - type: string - format: date-time - description: |- - When the adapter observed this resource state - API will use this to set AdapterStatus.last_report_time - conditions: - type: array - items: - $ref: '#/components/schemas/ConditionRequest' - description: Request payload for creating/updating adapter status - example: - adapter: validator - observed_generation: 1 - observed_time: '2021-01-01T10:00:00Z' - conditions: - - type: Available - status: 'True' - reason: This adapter1 is available - message: This adapter1 is available - - type: Applied - status: 'True' - reason: Validation job applied - message: Adapter1 validation job applied successfully - - type: Health - status: 'True' - reason: All adapter1 operations completed successfully - message: All adapter1 runtime operations completed successfully - metadata: - job_name: validator-job-abc123 - job_namespace: hyperfleet-system - attempt: 1 - started_time: '2021-01-01T10:00:00Z' - completed_time: '2021-01-01T10:02:00Z' - duration: 2m - data: - validation_results: - total_tests: 30 - passed: 30 - failed: 0 - AdapterStatusList: - type: object - required: - - kind - - page - - size - - total - - items - properties: - kind: - type: string - page: - type: integer - format: int32 - size: - type: integer - format: int32 - total: - type: integer - format: int32 - items: - type: array - items: - $ref: '#/components/schemas/AdapterStatus' - description: List of adapter statuses with pagination metadata - example: - kind: AdapterStatusList - page: 1 - size: 2 - total: 2 - items: - - adapter: adapter1 - observed_generation: 1 - conditions: - - type: Available - status: 'True' - reason: This adapter1 is available - message: This adapter1 is available - last_transition_time: '2021-01-01T10:00:00Z' - metadata: - job_name: validator-job-abc123 - duration: 2m - created_time: '2021-01-01T10:00:00Z' - last_report_time: '2021-01-01T10:02:00Z' - - adapter: adapter2 - observed_generation: 1 - conditions: - - type: Available - status: 'True' - reason: This adapter2 is available - message: This adapter2 is available - last_transition_time: '2021-01-01T10:01:00Z' - created_time: '2021-01-01T10:01:00Z' - last_report_time: '2021-01-01T10:01:30Z' - BearerAuth: - type: object - required: - - type - - scheme - properties: - type: - type: string - enum: - - http - scheme: - type: string - enum: - - bearer - Cluster: - type: object - required: - - name - - spec - - created_time - - updated_time - - created_by - - updated_by - - generation - - status - properties: - id: - type: string - description: Resource identifier - kind: - type: string - description: Resource kind - href: - type: string - description: Resource URI - labels: - type: object - additionalProperties: - type: string - description: labels for the API resource as pairs of name:value strings - name: - type: string - minLength: 3 - maxLength: 63 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - description: Cluster name (unique) - spec: - $ref: '#/components/schemas/ClusterSpec' - created_time: - type: string - format: date-time - updated_time: - type: string - format: date-time - created_by: - type: string - format: email - updated_by: - type: string - format: email - generation: - type: integer - format: int32 - minimum: 1 - description: Generation field is updated on customer updates, reflecting the version of the "intent" of the customer - status: - $ref: '#/components/schemas/ClusterStatus' - example: - kind: Cluster - id: cluster-123 - href: https://api.hyperfleet.com/v1/clusters/cluster-123 - name: cluster-123 - labels: - environment: production - team: platform - spec: {} - created_time: '2021-01-01T00:00:00Z' - updated_time: '2021-01-01T00:00:00Z' - generation: 1 - status: - conditions: - - type: Ready - status: 'True' - reason: All adapters reported Ready True for the current generation - message: All adapters reported Ready True for the current generation - observed_generation: 1 - created_time: '2021-01-01T10:00:00Z' - last_updated_time: '2021-01-01T10:00:00Z' - last_transition_time: '2021-01-01T10:00:00Z' - - type: Available - status: 'True' - reason: All adapters reported Available True for the same generation - message: All adapters reported Available True for the same generation - observed_generation: 1 - created_time: '2021-01-01T10:00:00Z' - last_updated_time: '2021-01-01T10:00:00Z' - last_transition_time: '2021-01-01T10:00:00Z' - - type: Adapter1Successful - status: 'True' - reason: This adapter1 is available - message: This adapter1 is available - observed_generation: 1 - created_time: '2021-01-01T10:00:00Z' - last_updated_time: '2021-01-01T10:00:00Z' - last_transition_time: '2021-01-01T10:00:00Z' - - type: Adapter2Successful - status: 'True' - reason: This adapter2 is available - message: This adapter2 is available - observed_generation: 1 - created_time: '2021-01-01T10:01:00Z' - last_updated_time: '2021-01-01T10:01:00Z' - last_transition_time: '2021-01-01T10:01:00Z' - created_by: user-123@example.com - updated_by: user-123@example.com - ClusterCreateRequest: - type: object - required: - - name - - spec - properties: - id: - type: string - description: Resource identifier - kind: - type: string - description: Resource kind - href: - type: string - description: Resource URI - labels: - type: object - additionalProperties: - type: string - description: labels for the API resource as pairs of name:value strings - name: - type: string - minLength: 3 - maxLength: 63 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - description: Cluster name (unique) - spec: - $ref: '#/components/schemas/ClusterSpec' - example: - kind: Cluster - name: cluster-123 - labels: - environment: production - team: platform - spec: {} - ClusterList: - type: object - required: - - kind - - page - - size - - total - - items - properties: - kind: - type: string - page: - type: integer - format: int32 - size: - type: integer - format: int32 - total: - type: integer - format: int32 - items: - type: array - items: - $ref: '#/components/schemas/Cluster' - ClusterSpec: - type: object - description: |- - Core cluster specification. - Accepts any properties as the spec is provider-agnostic. - This is represented as a simple object to allow flexibility. - ClusterStatus: - type: object - required: - - conditions - properties: - conditions: - type: array - items: - $ref: '#/components/schemas/ResourceCondition' - minItems: 2 - description: |- - List of status conditions for the cluster. - - **Mandatory conditions**: - - `type: "Ready"`: Whether all adapters report successfully at the current generation. - - `type: "Available"`: Aggregated adapter result for a common observed_generation. - - These conditions are present immediately upon resource creation. - description: |- - Cluster status computed from all status conditions. - - This object is computed by the service and CANNOT be modified directly. - It is aggregated from condition updates posted to `/clusters/{id}/statuses`. - - Provides quick overview of all reported conditions. - ConditionRequest: - type: object - required: - - type - - status - properties: - type: - type: string - status: - $ref: '#/components/schemas/AdapterConditionStatus' - reason: - type: string - message: - type: string - description: |- - Condition data for create/update requests (from adapters) - observed_generation and observed_time are now at AdapterStatusCreateRequest level - Error: - type: object - required: - - type - - title - - status - properties: - type: - type: string - format: uri - description: URI reference identifying the problem type - example: https://api.hyperfleet.io/errors/validation-error - title: - type: string - description: Short human-readable summary of the problem - example: Validation Failed - status: - type: integer - description: HTTP status code - example: 400 - detail: - type: string - description: Human-readable explanation specific to this occurrence - example: The cluster name field is required - instance: - type: string - format: uri - description: URI reference for this specific occurrence - example: /api/hyperfleet/v1/clusters - code: - type: string - description: Machine-readable error code in HYPERFLEET-CAT-NUM format - example: HYPERFLEET-VAL-001 - timestamp: - type: string - format: date-time - description: RFC3339 timestamp of when the error occurred - example: '2024-01-15T10:30:00Z' - trace_id: - type: string - description: Distributed trace ID for correlation - example: abc123def456 - errors: - type: array - items: - $ref: '#/components/schemas/ValidationError' - description: Field-level validation errors (for validation failures) - description: RFC 9457 Problem Details error format with HyperFleet extensions - NodePool: - type: object - required: - - name - - spec - - created_time - - updated_time - - created_by - - updated_by - - generation - - owner_references - - status - properties: - id: - type: string - description: Resource identifier - kind: - type: string - description: Resource kind - href: - type: string - description: Resource URI - labels: - type: object - additionalProperties: - type: string - description: labels for the API resource as pairs of name:value strings - name: - type: string - minLength: 3 - maxLength: 63 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - description: NodePool name (unique in a cluster) - spec: - $ref: '#/components/schemas/NodePoolSpec' - created_time: - type: string - format: date-time - updated_time: - type: string - format: date-time - created_by: - type: string - format: email - updated_by: - type: string - format: email - generation: - type: integer - format: int32 - minimum: 1 - description: Generation field is updated on customer updates, reflecting the version of the "intent" of the customer - owner_references: - $ref: '#/components/schemas/ObjectReference' - status: - $ref: '#/components/schemas/NodePoolStatus' - example: - kind: NodePool - id: nodepool-123 - href: https://api.hyperfleet.com/v1/nodepools/nodepool-123 - name: worker-pool-1 - labels: - environment: production - pooltype: worker - spec: {} - generation: 1 - created_time: '2021-01-01T00:00:00Z' - updated_time: '2021-01-01T00:00:00Z' - created_by: user-123@example.com - updated_by: user-123@example.com - owner_references: - id: cluster-123 - kind: Cluster - href: https://api.hyperfleet.com/v1/clusters/cluster-123 - status: - conditions: - - type: Ready - status: 'True' - reason: All adapters reported Ready True for the current generation - message: All adapters reported Ready True for the current generation - observed_generation: 1 - created_time: '2021-01-01T10:00:00Z' - last_updated_time: '2021-01-01T10:00:00Z' - last_transition_time: '2021-01-01T10:00:00Z' - - type: Available - status: 'True' - reason: All adapters reported Available True for the same generation - message: All adapters reported Available True for the same generation - observed_generation: 1 - created_time: '2021-01-01T10:00:00Z' - last_updated_time: '2021-01-01T10:00:00Z' - last_transition_time: '2021-01-01T10:00:00Z' - - type: Adapter1Successful - status: 'True' - reason: This adapter1 is available - message: This adapter1 is available - observed_generation: 1 - created_time: '2021-01-01T10:00:00Z' - last_updated_time: '2021-01-01T10:00:00Z' - last_transition_time: '2021-01-01T10:00:00Z' - - type: Adapter2Successful - status: 'True' - reason: This adapter2 is available - message: This adapter2 is available - observed_generation: 1 - created_time: '2021-01-01T10:01:00Z' - last_updated_time: '2021-01-01T10:01:00Z' - last_transition_time: '2021-01-01T10:01:00Z' - NodePoolCreateRequest: - type: object - required: - - name - - spec - properties: - id: - type: string - description: Resource identifier - kind: - type: string - description: Resource kind - href: - type: string - description: Resource URI - labels: - type: object - additionalProperties: - type: string - description: labels for the API resource as pairs of name:value strings - name: - type: string - minLength: 3 - maxLength: 63 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - description: NodePool name (unique in a cluster) - spec: - $ref: '#/components/schemas/NodePoolSpec' - example: - name: worker-pool-1 - labels: - environment: production - pooltype: worker - spec: {} - NodePoolCreateResponse: - type: object - required: - - name - - spec - - created_time - - updated_time - - created_by - - updated_by - - generation - - owner_references - - status - properties: - id: - type: string - description: Resource identifier - kind: - type: string - description: Resource kind - href: - type: string - description: Resource URI - labels: - type: object - additionalProperties: - type: string - description: labels for the API resource as pairs of name:value strings - name: - type: string - minLength: 3 - maxLength: 63 - pattern: ^[a-z0-9]([-a-z0-9]*[a-z0-9])?$ - description: NodePool name (unique in a cluster) - spec: - $ref: '#/components/schemas/NodePoolSpec' - created_time: - type: string - format: date-time - updated_time: - type: string - format: date-time - created_by: - type: string - format: email - updated_by: - type: string - format: email - generation: - type: integer - format: int32 - minimum: 1 - description: Generation field is updated on customer updates, reflecting the version of the "intent" of the customer - owner_references: - $ref: '#/components/schemas/ObjectReference' - status: - $ref: '#/components/schemas/NodePoolStatus' - NodePoolList: - type: object - required: - - kind - - page - - size - - total - - items - properties: - kind: - type: string - page: - type: integer - format: int32 - size: - type: integer - format: int32 - total: - type: integer - format: int32 - items: - type: array - items: - $ref: '#/components/schemas/NodePool' - NodePoolSpec: - type: object - description: |- - Core nodepool specification. - Accepts any properties as the spec is provider-agnostic. - This is represented as a simple object to allow flexibility. - NodePoolStatus: - type: object - required: - - conditions - properties: - conditions: - type: array - items: - $ref: '#/components/schemas/ResourceCondition' - minItems: 2 - description: |- - List of status conditions for the nodepool. - - **Mandatory conditions**: - - `type: "Ready"`: Whether all adapters report successfully at the current generation. - - `type: "Available"`: Aggregated adapter result for a common observed_generation. - - These conditions are present immediately upon resource creation. - description: |- - NodePool status computed from all status conditions. - - This object is computed by the service and CANNOT be modified directly. - ObjectReference: - type: object - properties: - id: - type: string - description: Resource identifier - kind: - type: string - description: Resource kind - href: - type: string - description: Resource URI - OrderDirection: - type: string - enum: - - asc - - desc - ResourceCondition: - type: object - required: - - type - - last_transition_time - - status - - observed_generation - - created_time - - last_updated_time - properties: - type: - type: string - description: Condition type - reason: - type: string - description: Machine-readable reason code - message: - type: string - description: Human-readable message - last_transition_time: - type: string - format: date-time - description: |- - When this condition last transitioned status (API-managed) - Only updated when status changes (True/False), not when reason/message changes - status: - $ref: '#/components/schemas/ResourceConditionStatus' - observed_generation: - type: integer - format: int32 - description: Generation of the spec that this condition reflects - created_time: - type: string - format: date-time - description: When this condition was first created (API-managed) - last_updated_time: - type: string - format: date-time - description: |- - When the corresponding adapter last reported (API-managed) - Updated every time the adapter POSTs, even if condition status hasn't changed - Copied from AdapterStatus.last_report_time - description: |- - Condition in Cluster/NodePool status - Used for semantic condition types: "ValidationSuccessful", "DNSSuccessful", "NodePoolSuccessful", etc. - Includes observed_generation and last_updated_time to track adapter-specific state - ResourceConditionStatus: - type: string - enum: - - 'True' - - 'False' - description: Status value for resource conditions - ValidationError: - type: object - required: - - field - - message - properties: - field: - type: string - description: JSON path to the field that failed validation - example: spec.name - value: - description: The invalid value that was provided (if safe to include) - constraint: - type: string - enum: - - required - - min - - max - - min_length - - max_length - - pattern - - enum - - format - - unique - description: The validation constraint that was violated - example: required - message: - type: string - description: Human-readable error message for this field - example: Cluster name is required - description: Field-level validation error detail - securitySchemes: - BearerAuth: - type: http - scheme: bearer -servers: - - url: https://hyperfleet.redhat.com - description: Production - variables: {} diff --git a/pkg/api/resource_definition.go b/pkg/api/resource_definition.go index 288405c..86c14cc 100644 --- a/pkg/api/resource_definition.go +++ b/pkg/api/resource_definition.go @@ -42,6 +42,15 @@ type StatusConfig struct { RequiredAdapters []string `yaml:"requiredAdapters" json:"requiredAdapters"` } +// ResourceSchema holds the OpenAPI schema extracted from a CRD. +// It contains the spec and status property schemas for use in API documentation. +type ResourceSchema struct { + // Spec contains the OpenAPI schema for the .spec field. + Spec map[string]interface{} `json:"spec,omitempty"` + // Status contains the OpenAPI schema for the .status field. + Status map[string]interface{} `json:"status,omitempty"` +} + // ResourceDefinition defines a custom resource type (CRD). // It specifies the resource's identity, scope, ownership, and status configuration. type ResourceDefinition struct { @@ -61,6 +70,8 @@ type ResourceDefinition struct { StatusConfig StatusConfig `yaml:"statusConfig" json:"statusConfig"` // Enabled indicates whether this resource type is active. Enabled bool `yaml:"enabled" json:"enabled"` + // Schema contains the OpenAPI schema extracted from the CRD. + Schema *ResourceSchema `yaml:"schema,omitempty" json:"schema,omitempty"` } // IsRoot returns true if this is a root-level resource. diff --git a/pkg/crd/registry.go b/pkg/crd/registry.go index a12b98f..ba180ae 100644 --- a/pkg/crd/registry.go +++ b/pkg/crd/registry.go @@ -163,6 +163,9 @@ func (r *Registry) parseCRD(crd *apiextensionsv1.CustomResourceDefinition) (*api enabledStr := annotations[AnnotationEnabled] enabled := enabledStr == "" || enabledStr == "true" // Default to enabled + // Extract OpenAPI schema from CRD + schema := extractOpenAPISchema(crd) + def := &api.ResourceDefinition{ APIVersion: HyperfleetGroup + "/v1", Kind: crd.Spec.Names.Kind, @@ -174,11 +177,131 @@ func (r *Registry) parseCRD(crd *apiextensionsv1.CustomResourceDefinition) (*api RequiredAdapters: requiredAdapters, }, Enabled: enabled, + Schema: schema, } return def, nil } +// extractOpenAPISchema extracts the spec and status schemas from a CRD's openAPIV3Schema. +// It looks for the first served version and extracts the properties.spec and properties.status fields. +func extractOpenAPISchema(crd *apiextensionsv1.CustomResourceDefinition) *api.ResourceSchema { + // Find the storage version or first served version + var version *apiextensionsv1.CustomResourceDefinitionVersion + for i := range crd.Spec.Versions { + v := &crd.Spec.Versions[i] + if v.Storage { + version = v + break + } + if version == nil && v.Served { + version = v + } + } + + if version == nil || version.Schema == nil || version.Schema.OpenAPIV3Schema == nil { + return nil + } + + schema := &api.ResourceSchema{} + openAPISchema := version.Schema.OpenAPIV3Schema + + // Extract properties from the schema + if openAPISchema.Properties != nil { + // Extract spec schema + if specSchema, ok := openAPISchema.Properties["spec"]; ok { + schema.Spec = jsonSchemaToMap(&specSchema) + } + + // Extract status schema + if statusSchema, ok := openAPISchema.Properties["status"]; ok { + schema.Status = jsonSchemaToMap(&statusSchema) + } + } + + return schema +} + +// jsonSchemaToMap converts a JSONSchemaProps to a map[string]interface{} for OpenAPI generation. +func jsonSchemaToMap(schema *apiextensionsv1.JSONSchemaProps) map[string]interface{} { + if schema == nil { + return nil + } + + result := make(map[string]interface{}) + + if schema.Type != "" { + result["type"] = schema.Type + } + if schema.Description != "" { + result["description"] = schema.Description + } + if schema.Format != "" { + result["format"] = schema.Format + } + if len(schema.Enum) > 0 { + enumValues := make([]interface{}, len(schema.Enum)) + for i, e := range schema.Enum { + enumValues[i] = string(e.Raw) + } + result["enum"] = enumValues + } + if schema.Minimum != nil { + result["minimum"] = *schema.Minimum + } + if schema.Maximum != nil { + result["maximum"] = *schema.Maximum + } + if schema.MinLength != nil { + result["minLength"] = *schema.MinLength + } + if schema.MaxLength != nil { + result["maxLength"] = *schema.MaxLength + } + if schema.Pattern != "" { + result["pattern"] = schema.Pattern + } + if schema.MinItems != nil { + result["minItems"] = *schema.MinItems + } + if schema.MaxItems != nil { + result["maxItems"] = *schema.MaxItems + } + if len(schema.Required) > 0 { + result["required"] = schema.Required + } + + // Handle properties (for object types) + if len(schema.Properties) > 0 { + props := make(map[string]interface{}) + for name, prop := range schema.Properties { + props[name] = jsonSchemaToMap(&prop) + } + result["properties"] = props + } + + // Handle items (for array types) + if schema.Items != nil && schema.Items.Schema != nil { + result["items"] = jsonSchemaToMap(schema.Items.Schema) + } + + // Handle additionalProperties + if schema.AdditionalProperties != nil { + if schema.AdditionalProperties.Allows { + result["additionalProperties"] = true + } else if schema.AdditionalProperties.Schema != nil { + result["additionalProperties"] = jsonSchemaToMap(schema.AdditionalProperties.Schema) + } + } + + // Handle x-kubernetes-preserve-unknown-fields (translates to additionalProperties: true) + if schema.XPreserveUnknownFields != nil && *schema.XPreserveUnknownFields { + result["additionalProperties"] = true + } + + return result +} + // getKubeConfig returns a Kubernetes client config. // It tries in-cluster config first, then falls back to kubeconfig file. func getKubeConfig() (*rest.Config, error) { diff --git a/pkg/handlers/openapi.go b/pkg/handlers/openapi.go index 26f4e49..cf475eb 100755 --- a/pkg/handlers/openapi.go +++ b/pkg/handlers/openapi.go @@ -6,9 +6,10 @@ import ( "io/fs" "net/http" - "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api/openapi" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/crd" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/errors" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/logger" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/openapi" ) //go:embed openapi-ui.html @@ -21,24 +22,19 @@ type openAPIHandler struct { func NewOpenAPIHandler() (*openAPIHandler, error) { ctx := context.Background() - // Load the OpenAPI spec from the generated code's embedded swagger - swagger, err := openapi.GetSwagger() - if err != nil { - return nil, errors.GeneralError( - "can't load OpenAPI specification from generated code: %v", - err, - ) - } - // Marshal the swagger spec to JSON - data, err := swagger.MarshalJSON() + // Generate the OpenAPI spec dynamically from CRD registry + spec := openapi.GenerateSpec(crd.Default()) + + // Marshal the spec to JSON + data, err := spec.MarshalJSON() if err != nil { return nil, errors.GeneralError( "can't marshal OpenAPI specification to JSON: %v", err, ) } - logger.Info(ctx, "Loaded fully resolved OpenAPI specification from embedded pkg/api/openapi/api/openapi.yaml") + logger.Info(ctx, "Generated OpenAPI specification from CRD registry") // Load the OpenAPI UI HTML content uiContent, err := fs.ReadFile(openapiui, "openapi-ui.html") diff --git a/pkg/openapi/common.go b/pkg/openapi/common.go new file mode 100644 index 0000000..71a357f --- /dev/null +++ b/pkg/openapi/common.go @@ -0,0 +1,580 @@ +/* +Copyright (c) 2018 Red Hat, 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. +*/ + +// Package openapi provides dynamic OpenAPI specification generation from CRD definitions. + +package openapi + +import ( + "github.com/getkin/kin-openapi/openapi3" +) + +// addCommonSchemas adds reusable schemas used across all resources. +func addCommonSchemas(doc *openapi3.T) { + // Error schema (RFC 9457) + doc.Components.Schemas["Error"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"type", "title", "status"}, + Properties: openapi3.Schemas{ + "type": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "uri", + Description: "URI reference identifying the problem type", + Example: "https://api.hyperfleet.io/errors/validation-error", + }, + }, + "title": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Short human-readable summary of the problem", + Example: "Validation Failed", + }, + }, + "status": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Description: "HTTP status code", + Example: 400, + }, + }, + "detail": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Human-readable explanation specific to this occurrence", + Example: "The cluster name field is required", + }, + }, + "instance": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "uri", + Description: "URI reference for this specific occurrence", + Example: "/api/hyperfleet/v1/clusters", + }, + }, + "code": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Machine-readable error code in HYPERFLEET-CAT-NUM format", + Example: "HYPERFLEET-VAL-001", + }, + }, + "timestamp": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + Description: "RFC3339 timestamp of when the error occurred", + Example: "2024-01-15T10:30:00Z", + }, + }, + "trace_id": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Distributed trace ID for correlation", + Example: "abc123def456", + }, + }, + "errors": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{ + Ref: "#/components/schemas/ValidationError", + }, + Description: "Field-level validation errors (for validation failures)", + }, + }, + }, + Description: "RFC 9457 Problem Details error format with HyperFleet extensions", + }, + } + + // ValidationError schema + doc.Components.Schemas["ValidationError"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"field", "message"}, + Properties: openapi3.Schemas{ + "field": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "JSON path to the field that failed validation", + Example: "spec.name", + }, + }, + "value": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Description: "The invalid value that was provided (if safe to include)", + }, + }, + "constraint": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []interface{}{ + "required", "min", "max", "min_length", "max_length", + "pattern", "enum", "format", "unique", + }, + Description: "The validation constraint that was violated", + Example: "required", + }, + }, + "message": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Human-readable error message for this field", + Example: "Cluster name is required", + }, + }, + }, + Description: "Field-level validation error detail", + }, + } + + // ResourceCondition schema + doc.Components.Schemas["ResourceCondition"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"type", "last_transition_time", "status", "observed_generation", "created_time", "last_updated_time"}, + Properties: openapi3.Schemas{ + "type": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Condition type", + }, + }, + "reason": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Machine-readable reason code", + }, + }, + "message": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Human-readable message", + }, + }, + "last_transition_time": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + Description: "When this condition last transitioned status (API-managed)", + }, + }, + "status": &openapi3.SchemaRef{ + Ref: "#/components/schemas/ResourceConditionStatus", + }, + "observed_generation": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Format: "int32", + Description: "Generation of the spec that this condition reflects", + }, + }, + "created_time": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + Description: "When this condition was first created (API-managed)", + }, + }, + "last_updated_time": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + Description: "When the corresponding adapter last reported (API-managed)", + }, + }, + }, + Description: "Condition in resource status", + }, + } + + // ResourceConditionStatus enum + doc.Components.Schemas["ResourceConditionStatus"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []interface{}{"True", "False"}, + Description: "Status value for resource conditions", + }, + } + + // ObjectReference schema + doc.Components.Schemas["ObjectReference"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: openapi3.Schemas{ + "id": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Resource identifier", + }, + }, + "kind": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Resource kind", + }, + }, + "href": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Resource URI", + }, + }, + }, + }, + } + + // AdapterStatus schema + doc.Components.Schemas["AdapterStatus"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"adapter", "observed_generation", "conditions", "created_time", "last_report_time"}, + Properties: openapi3.Schemas{ + "adapter": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Adapter name (e.g., \"validator\", \"dns\", \"provisioner\")", + }, + }, + "observed_generation": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Format: "int32", + Description: "Which generation of the resource this status reflects", + }, + }, + "metadata": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Description: "Job execution metadata", + Properties: openapi3.Schemas{ + "job_name": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + "job_namespace": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + "attempt": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"integer"}, Format: "int32"}}, + "started_time": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}, Format: "date-time"}}, + "completed_time": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}, Format: "date-time"}}, + "duration": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + }, + }, + }, + "data": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + AdditionalProperties: openapi3.AdditionalProperties{Has: boolPtr(true)}, + Description: "Adapter-specific data (structure varies by adapter type)", + }, + }, + "conditions": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{ + Ref: "#/components/schemas/AdapterCondition", + }, + Description: "Kubernetes-style conditions tracking adapter state", + }, + }, + "created_time": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + Description: "When this adapter status was first created (API-managed)", + }, + }, + "last_report_time": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + Description: "When this adapter last reported its status (API-managed)", + }, + }, + }, + Description: "AdapterStatus represents the complete status report from an adapter", + }, + } + + // AdapterCondition schema + doc.Components.Schemas["AdapterCondition"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"type", "last_transition_time", "status"}, + Properties: openapi3.Schemas{ + "type": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Condition type", + }, + }, + "reason": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Machine-readable reason code", + }, + }, + "message": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Human-readable message", + }, + }, + "last_transition_time": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + Description: "When this condition last transitioned status (API-managed)", + }, + }, + "status": &openapi3.SchemaRef{ + Ref: "#/components/schemas/AdapterConditionStatus", + }, + }, + Description: "Condition in AdapterStatus", + }, + } + + // AdapterConditionStatus enum + doc.Components.Schemas["AdapterConditionStatus"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []interface{}{"True", "False", "Unknown"}, + Description: "Status value for adapter conditions", + }, + } + + // AdapterStatusCreateRequest schema + doc.Components.Schemas["AdapterStatusCreateRequest"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"adapter", "observed_generation", "observed_time", "conditions"}, + Properties: openapi3.Schemas{ + "adapter": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Adapter name (e.g., \"validator\", \"dns\", \"provisioner\")", + }, + }, + "observed_generation": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Format: "int32", + Description: "Which generation of the resource this status reflects", + }, + }, + "observed_time": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + Description: "When the adapter observed this resource state", + }, + }, + "metadata": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Description: "Job execution metadata", + Properties: openapi3.Schemas{ + "job_name": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + "job_namespace": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + "attempt": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"integer"}, Format: "int32"}}, + "started_time": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}, Format: "date-time"}}, + "completed_time": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}, Format: "date-time"}}, + "duration": &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + }, + }, + }, + "data": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + AdditionalProperties: openapi3.AdditionalProperties{Has: boolPtr(true)}, + Description: "Adapter-specific data (structure varies by adapter type)", + }, + }, + "conditions": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{ + Ref: "#/components/schemas/ConditionRequest", + }, + }, + }, + }, + Description: "Request payload for creating/updating adapter status", + }, + } + + // ConditionRequest schema + doc.Components.Schemas["ConditionRequest"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"type", "status"}, + Properties: openapi3.Schemas{ + "type": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + "status": &openapi3.SchemaRef{ + Ref: "#/components/schemas/AdapterConditionStatus", + }, + "reason": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + "message": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + Description: "Condition data for create/update requests (from adapters)", + }, + } + + // AdapterStatusList schema + doc.Components.Schemas["AdapterStatusList"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"kind", "page", "size", "total", "items"}, + Properties: openapi3.Schemas{ + "kind": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + "page": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"integer"}, Format: "int32"}, + }, + "size": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"integer"}, Format: "int32"}, + }, + "total": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"integer"}, Format: "int32"}, + }, + "items": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{ + Ref: "#/components/schemas/AdapterStatus", + }, + }, + }, + }, + Description: "List of adapter statuses with pagination metadata", + }, + } + + // OrderDirection enum + doc.Components.Schemas["OrderDirection"] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Enum: []interface{}{"asc", "desc"}, + }, + } +} + +// addCommonParameters adds reusable query parameters for pagination and search. +func addCommonParameters(doc *openapi3.T) { + // Page parameter + doc.Components.Parameters["page"] = &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "page", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Format: "int32", + Default: 1, + }, + }, + }, + } + + // PageSize parameter + doc.Components.Parameters["pageSize"] = &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "pageSize", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Format: "int32", + Default: 20, + }, + }, + }, + } + + // OrderBy parameter + doc.Components.Parameters["orderBy"] = &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "orderBy", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Default: "created_time", + }, + }, + }, + } + + // Order parameter + doc.Components.Parameters["order"] = &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "order", + In: "query", + Required: false, + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/OrderDirection", + }, + }, + } + + // Search parameter + doc.Components.Parameters["search"] = &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "search", + In: "query", + Required: false, + Description: "Filter results using TSL (Tree Search Language) query syntax. Examples: `status.conditions.Ready='True'`, `name in ('c1','c2')`, `labels.region='us-east'`", + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + } +} + +// addSecuritySchemes adds the security schemes to the OpenAPI spec. +func addSecuritySchemes(doc *openapi3.T) { + doc.Components.SecuritySchemes = openapi3.SecuritySchemes{ + "BearerAuth": &openapi3.SecuritySchemeRef{ + Value: &openapi3.SecurityScheme{ + Type: "http", + Scheme: "bearer", + }, + }, + } +} + +// boolPtr returns a pointer to a boolean value. +func boolPtr(b bool) *bool { + return &b +} diff --git a/pkg/openapi/generator.go b/pkg/openapi/generator.go new file mode 100644 index 0000000..79eaa97 --- /dev/null +++ b/pkg/openapi/generator.go @@ -0,0 +1,91 @@ +/* +Copyright (c) 2018 Red Hat, 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. +*/ + +// Package openapi provides dynamic OpenAPI specification generation from CRD definitions. + +package openapi + +import ( + "sort" + + "github.com/getkin/kin-openapi/openapi3" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/crd" +) + +// GenerateSpec builds an OpenAPI 3.0 spec from the CRD registry. +// It dynamically creates paths and schemas based on loaded CRD definitions. +func GenerateSpec(registry *crd.Registry) *openapi3.T { + doc := &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Title: "HyperFleet API", + Version: "1.0.0", + Contact: &openapi3.Contact{ + Name: "HyperFleet Team", + }, + License: &openapi3.License{ + Name: "Apache 2.0", + URL: "https://www.apache.org/licenses/LICENSE-2.0", + }, + Description: "HyperFleet API provides simple CRUD operations for managing cluster resources and their status history.\n\n**Architecture**: Simple CRUD only, no business logic, no event creation.\nSentinel operator handles all orchestration logic.\nAdapters handle the specifics of managing spec", + }, + Paths: openapi3.NewPaths(), + Components: &openapi3.Components{ + Schemas: make(openapi3.Schemas), + Parameters: make(openapi3.ParametersMap), + SecuritySchemes: make(openapi3.SecuritySchemes), + }, + Servers: openapi3.Servers{ + &openapi3.Server{ + URL: "https://hyperfleet.redhat.com", + Description: "Production", + }, + }, + } + + // Add common schemas (Error, pagination, conditions, etc.) + addCommonSchemas(doc) + + // Add common parameters (page, pageSize, orderBy, order, search) + addCommonParameters(doc) + + // Add security schemes + addSecuritySchemes(doc) + + // Get all resource definitions and sort them for deterministic output + defs := registry.All() + sort.Slice(defs, func(i, j int) bool { + // Root resources first, then by kind name + if defs[i].IsRoot() != defs[j].IsRoot() { + return defs[i].IsRoot() + } + return defs[i].Kind < defs[j].Kind + }) + + // Generate paths and schemas for each CRD + // First pass: generate schemas (needed for path references) + for _, def := range defs { + addResourceSchemas(doc, def) + } + + // Second pass: generate paths (may reference schemas) + for _, def := range defs { + addResourcePaths(doc, def, registry) + } + + return doc +} diff --git a/pkg/openapi/generator_test.go b/pkg/openapi/generator_test.go new file mode 100644 index 0000000..332d4a0 --- /dev/null +++ b/pkg/openapi/generator_test.go @@ -0,0 +1,151 @@ +/* +Copyright (c) 2018 Red Hat, 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. +*/ + +package openapi + +import ( + "testing" + + . "github.com/onsi/gomega" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/crd" +) + +func TestGenerateSpec_EmptyRegistry(t *testing.T) { + RegisterTestingT(t) + + registry := crd.NewRegistry() + spec := GenerateSpec(registry) + + Expect(spec).ToNot(BeNil()) + Expect(spec.OpenAPI).To(Equal("3.0.0")) + Expect(spec.Info.Title).To(Equal("HyperFleet API")) + Expect(spec.Components.Schemas).ToNot(BeNil()) + Expect(spec.Components.Parameters).ToNot(BeNil()) + + // Should have common schemas even with empty registry + Expect(spec.Components.Schemas["Error"]).ToNot(BeNil()) + Expect(spec.Components.Schemas["ValidationError"]).ToNot(BeNil()) + Expect(spec.Components.Schemas["ResourceCondition"]).ToNot(BeNil()) + Expect(spec.Components.Schemas["AdapterStatus"]).ToNot(BeNil()) + + // Should have common parameters + Expect(spec.Components.Parameters["page"]).ToNot(BeNil()) + Expect(spec.Components.Parameters["pageSize"]).ToNot(BeNil()) + Expect(spec.Components.Parameters["search"]).ToNot(BeNil()) +} + +func TestGenerateSpec_WithRootResource(t *testing.T) { + RegisterTestingT(t) + + registry := crd.NewRegistry() + err := registry.Register(&api.ResourceDefinition{ + APIVersion: "hyperfleet.io/v1", + Kind: "Cluster", + Plural: "clusters", + Singular: "cluster", + Scope: api.ResourceScopeRoot, + Enabled: true, + }) + Expect(err).To(BeNil()) + + spec := GenerateSpec(registry) + + // Should have Cluster schemas + Expect(spec.Components.Schemas["Cluster"]).ToNot(BeNil()) + Expect(spec.Components.Schemas["ClusterSpec"]).ToNot(BeNil()) + Expect(spec.Components.Schemas["ClusterStatus"]).ToNot(BeNil()) + Expect(spec.Components.Schemas["ClusterList"]).ToNot(BeNil()) + Expect(spec.Components.Schemas["ClusterCreateRequest"]).ToNot(BeNil()) + + // Should have paths for root resource + Expect(spec.Paths.Find("/api/hyperfleet/v1/clusters")).ToNot(BeNil()) + Expect(spec.Paths.Find("/api/hyperfleet/v1/clusters/{cluster_id}")).ToNot(BeNil()) + Expect(spec.Paths.Find("/api/hyperfleet/v1/clusters/{cluster_id}/statuses")).ToNot(BeNil()) +} + +func TestGenerateSpec_WithOwnedResource(t *testing.T) { + RegisterTestingT(t) + + registry := crd.NewRegistry() + + // First register the owner resource + err := registry.Register(&api.ResourceDefinition{ + APIVersion: "hyperfleet.io/v1", + Kind: "Cluster", + Plural: "clusters", + Singular: "cluster", + Scope: api.ResourceScopeRoot, + Enabled: true, + }) + Expect(err).To(BeNil()) + + // Then register the owned resource + err = registry.Register(&api.ResourceDefinition{ + APIVersion: "hyperfleet.io/v1", + Kind: "NodePool", + Plural: "nodepools", + Singular: "nodepool", + Scope: api.ResourceScopeOwned, + Owner: &api.OwnerRef{ + Kind: "Cluster", + PathParam: "cluster_id", + }, + Enabled: true, + }) + Expect(err).To(BeNil()) + + spec := GenerateSpec(registry) + + // Should have NodePool schemas + Expect(spec.Components.Schemas["NodePool"]).ToNot(BeNil()) + Expect(spec.Components.Schemas["NodePoolSpec"]).ToNot(BeNil()) + Expect(spec.Components.Schemas["NodePoolStatus"]).ToNot(BeNil()) + Expect(spec.Components.Schemas["NodePoolList"]).ToNot(BeNil()) + Expect(spec.Components.Schemas["NodePoolCreateRequest"]).ToNot(BeNil()) + + // Should have paths for owned resource under owner + Expect(spec.Paths.Find("/api/hyperfleet/v1/clusters/{cluster_id}/nodepools")).ToNot(BeNil()) + Expect(spec.Paths.Find("/api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{nodepool_id}")).ToNot(BeNil()) + + // Should have global list path for owned resource + Expect(spec.Paths.Find("/api/hyperfleet/v1/nodepools")).ToNot(BeNil()) +} + +func TestGenerateSpec_JSON(t *testing.T) { + RegisterTestingT(t) + + registry := crd.NewRegistry() + err := registry.Register(&api.ResourceDefinition{ + APIVersion: "hyperfleet.io/v1", + Kind: "Cluster", + Plural: "clusters", + Singular: "cluster", + Scope: api.ResourceScopeRoot, + Enabled: true, + }) + Expect(err).To(BeNil()) + + spec := GenerateSpec(registry) + + // Should be able to marshal to JSON + data, err := spec.MarshalJSON() + Expect(err).To(BeNil()) + Expect(data).ToNot(BeEmpty()) + Expect(string(data)).To(ContainSubstring(`"openapi":"3.0.0"`)) + Expect(string(data)).To(ContainSubstring(`"Cluster"`)) +} diff --git a/pkg/openapi/paths.go b/pkg/openapi/paths.go new file mode 100644 index 0000000..faf92e7 --- /dev/null +++ b/pkg/openapi/paths.go @@ -0,0 +1,598 @@ +/* +Copyright (c) 2018 Red Hat, 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. +*/ + +package openapi + +import ( + "fmt" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/jinzhu/inflection" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/crd" +) + +const basePath = "/api/hyperfleet/v1" + +// addResourcePaths generates CRUD paths for a resource based on its scope. +func addResourcePaths(doc *openapi3.T, def *api.ResourceDefinition, registry *crd.Registry) { + if def.IsOwned() { + addOwnedResourcePaths(doc, def, registry) + } else { + addRootResourcePaths(doc, def) + } +} + +// addRootResourcePaths generates paths for root-level resources. +// - GET /api/hyperfleet/v1/{plural} - List +// - POST /api/hyperfleet/v1/{plural} - Create +// - GET /api/hyperfleet/v1/{plural}/{id} - Get +// - PATCH /api/hyperfleet/v1/{plural}/{id} - Patch +// - DELETE /api/hyperfleet/v1/{plural}/{id} - Delete +func addRootResourcePaths(doc *openapi3.T, def *api.ResourceDefinition) { + collectionPath := fmt.Sprintf("%s/%s", basePath, def.Plural) + itemPath := fmt.Sprintf("%s/{%s_id}", collectionPath, def.Singular) + statusesPath := fmt.Sprintf("%s/statuses", itemPath) + + // Collection path: GET (list), POST (create) + doc.Paths.Set(collectionPath, &openapi3.PathItem{ + Get: buildListOperation(def, nil), + Post: buildCreateOperation(def, nil), + }) + + // Item path: GET (get), PATCH (patch), DELETE (delete) + doc.Paths.Set(itemPath, &openapi3.PathItem{ + Get: buildGetOperation(def, nil), + Patch: buildPatchOperation(def, nil), + Delete: buildDeleteOperation(def, nil), + }) + + // Statuses path: GET (list statuses), POST (create/update status) + doc.Paths.Set(statusesPath, &openapi3.PathItem{ + Get: buildListStatusesOperation(def, nil), + Post: buildCreateStatusOperation(def, nil), + }) +} + +// addOwnedResourcePaths generates paths for owned resources. +// - GET /api/hyperfleet/v1/{owner_plural}/{owner_id}/{plural} - List +// - POST /api/hyperfleet/v1/{owner_plural}/{owner_id}/{plural} - Create +// - GET /api/hyperfleet/v1/{owner_plural}/{owner_id}/{plural}/{id} - Get +// - PATCH /api/hyperfleet/v1/{owner_plural}/{owner_id}/{plural}/{id} - Patch +// - DELETE /api/hyperfleet/v1/{owner_plural}/{owner_id}/{plural}/{id} - Delete +func addOwnedResourcePaths(doc *openapi3.T, def *api.ResourceDefinition, registry *crd.Registry) { + ownerDef := getOwnerDefinitionFromRegistry(def, registry) + if ownerDef == nil { + return // Cannot generate paths without owner definition + } + + ownerPathParam := def.GetOwnerPathParam() + collectionPath := fmt.Sprintf("%s/%s/{%s}/%s", basePath, ownerDef.Plural, ownerPathParam, def.Plural) + itemPath := fmt.Sprintf("%s/{%s_id}", collectionPath, def.Singular) + statusesPath := fmt.Sprintf("%s/statuses", itemPath) + + // Collection path: GET (list), POST (create) + doc.Paths.Set(collectionPath, &openapi3.PathItem{ + Get: buildListOperation(def, ownerDef), + Post: buildCreateOperation(def, ownerDef), + }) + + // Item path: GET (get), PATCH (patch), DELETE (delete) + doc.Paths.Set(itemPath, &openapi3.PathItem{ + Get: buildGetOperation(def, ownerDef), + Patch: buildPatchOperation(def, ownerDef), + Delete: buildDeleteOperation(def, ownerDef), + }) + + // Statuses path: GET (list statuses), POST (create/update status) + doc.Paths.Set(statusesPath, &openapi3.PathItem{ + Get: buildListStatusesOperation(def, ownerDef), + Post: buildCreateStatusOperation(def, ownerDef), + }) + + // Also add a global list endpoint for owned resources (without owner filter) + globalListPath := fmt.Sprintf("%s/%s", basePath, def.Plural) + doc.Paths.Set(globalListPath, &openapi3.PathItem{ + Get: buildGlobalListOperation(def), + }) +} + +// getOwnerDefinitionFromRegistry returns the ResourceDefinition for the owner of an owned resource. +func getOwnerDefinitionFromRegistry(def *api.ResourceDefinition, registry *crd.Registry) *api.ResourceDefinition { + if def.Owner == nil { + return nil + } + ownerDef, ok := registry.GetByKind(def.Owner.Kind) + if !ok { + return nil + } + return ownerDef +} + +// buildListOperation creates a GET operation for listing resources. +func buildListOperation(def *api.ResourceDefinition, ownerDef *api.ResourceDefinition) *openapi3.Operation { + operationID := fmt.Sprintf("get%s", inflection.Plural(def.Kind)) + summary := fmt.Sprintf("List %s", def.Plural) + + params := []*openapi3.ParameterRef{ + {Ref: "#/components/parameters/search"}, + {Ref: "#/components/parameters/page"}, + {Ref: "#/components/parameters/pageSize"}, + {Ref: "#/components/parameters/orderBy"}, + {Ref: "#/components/parameters/order"}, + } + + // Add owner path parameter for owned resources + if ownerDef != nil { + operationID = fmt.Sprintf("get%sBy%sId", inflection.Plural(def.Kind), ownerDef.Kind) + summary = fmt.Sprintf("List all %s for %s", def.Plural, ownerDef.Singular) + params = append([]*openapi3.ParameterRef{ + buildPathParameter(def.GetOwnerPathParam(), ownerDef.Kind+" ID"), + }, params...) + } + + return &openapi3.Operation{ + OperationID: operationID, + Summary: summary, + Parameters: params, + Responses: buildListResponses(def), + Security: &openapi3.SecurityRequirements{{"BearerAuth": {}}}, + } +} + +// buildGlobalListOperation creates a GET operation for listing all resources globally. +func buildGlobalListOperation(def *api.ResourceDefinition) *openapi3.Operation { + return &openapi3.Operation{ + OperationID: fmt.Sprintf("get%s", inflection.Plural(def.Kind)), + Summary: fmt.Sprintf("List all %s", def.Plural), + Description: fmt.Sprintf("Returns the list of all %s", def.Plural), + Parameters: []*openapi3.ParameterRef{ + {Ref: "#/components/parameters/search"}, + {Ref: "#/components/parameters/page"}, + {Ref: "#/components/parameters/pageSize"}, + {Ref: "#/components/parameters/orderBy"}, + {Ref: "#/components/parameters/order"}, + }, + Responses: buildListResponses(def), + Security: &openapi3.SecurityRequirements{{"BearerAuth": {}}}, + } +} + +// buildCreateOperation creates a POST operation for creating a resource. +func buildCreateOperation(def *api.ResourceDefinition, ownerDef *api.ResourceDefinition) *openapi3.Operation { + operationID := fmt.Sprintf("post%s", def.Kind) + summary := fmt.Sprintf("Create %s", def.Singular) + + var params []*openapi3.ParameterRef + + // Add owner path parameter for owned resources + if ownerDef != nil { + operationID = fmt.Sprintf("create%s", def.Kind) + summary = fmt.Sprintf("Create %s for %s", def.Singular, ownerDef.Singular) + params = append(params, buildPathParameter(def.GetOwnerPathParam(), ownerDef.Kind+" ID")) + } + + return &openapi3.Operation{ + OperationID: operationID, + Summary: summary, + Description: fmt.Sprintf("Create a new %s resource.\n\n**Note**: The `status` object in the response is read-only and computed by the service. It is NOT part of the request body.", def.Singular), + Parameters: params, + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Required: true, + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + def.Kind + "CreateRequest", + }, + }, + }, + }, + }, + Responses: buildCreateResponses(def), + Security: &openapi3.SecurityRequirements{{"BearerAuth": {}}}, + } +} + +// buildGetOperation creates a GET operation for getting a single resource. +func buildGetOperation(def *api.ResourceDefinition, ownerDef *api.ResourceDefinition) *openapi3.Operation { + operationID := fmt.Sprintf("get%sById", def.Kind) + summary := fmt.Sprintf("Get %s by ID", def.Singular) + + params := []*openapi3.ParameterRef{ + {Ref: "#/components/parameters/search"}, + buildPathParameter(def.Singular+"_id", def.Kind+" ID"), + } + + // Add owner path parameter for owned resources + if ownerDef != nil { + params = append([]*openapi3.ParameterRef{ + buildPathParameter(def.GetOwnerPathParam(), ownerDef.Kind+" ID"), + }, params...) + } + + return &openapi3.Operation{ + OperationID: operationID, + Summary: summary, + Parameters: params, + Responses: buildGetResponses(def), + Security: &openapi3.SecurityRequirements{{"BearerAuth": {}}}, + } +} + +// buildPatchOperation creates a PATCH operation for updating a resource. +func buildPatchOperation(def *api.ResourceDefinition, ownerDef *api.ResourceDefinition) *openapi3.Operation { + operationID := fmt.Sprintf("patch%s", def.Kind) + summary := fmt.Sprintf("Update %s", def.Singular) + + params := []*openapi3.ParameterRef{ + buildPathParameter(def.Singular+"_id", def.Kind+" ID"), + } + + // Add owner path parameter for owned resources + if ownerDef != nil { + params = append([]*openapi3.ParameterRef{ + buildPathParameter(def.GetOwnerPathParam(), ownerDef.Kind+" ID"), + }, params...) + } + + return &openapi3.Operation{ + OperationID: operationID, + Summary: summary, + Parameters: params, + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Required: true, + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + def.Kind + "CreateRequest", + }, + }, + }, + }, + }, + Responses: buildGetResponses(def), + Security: &openapi3.SecurityRequirements{{"BearerAuth": {}}}, + } +} + +// buildDeleteOperation creates a DELETE operation for deleting a resource. +func buildDeleteOperation(def *api.ResourceDefinition, ownerDef *api.ResourceDefinition) *openapi3.Operation { + operationID := fmt.Sprintf("delete%s", def.Kind) + summary := fmt.Sprintf("Delete %s", def.Singular) + + params := []*openapi3.ParameterRef{ + buildPathParameter(def.Singular+"_id", def.Kind+" ID"), + } + + // Add owner path parameter for owned resources + if ownerDef != nil { + params = append([]*openapi3.ParameterRef{ + buildPathParameter(def.GetOwnerPathParam(), ownerDef.Kind+" ID"), + }, params...) + } + + return &openapi3.Operation{ + OperationID: operationID, + Summary: summary, + Parameters: params, + Responses: buildDeleteResponses(), + Security: &openapi3.SecurityRequirements{{"BearerAuth": {}}}, + } +} + +// buildListStatusesOperation creates a GET operation for listing adapter statuses. +func buildListStatusesOperation(def *api.ResourceDefinition, ownerDef *api.ResourceDefinition) *openapi3.Operation { + operationID := fmt.Sprintf("get%sStatuses", def.Kind) + summary := fmt.Sprintf("List all adapter statuses for %s", def.Singular) + + params := []*openapi3.ParameterRef{ + buildPathParameter(def.Singular+"_id", def.Kind+" ID"), + {Ref: "#/components/parameters/search"}, + {Ref: "#/components/parameters/page"}, + {Ref: "#/components/parameters/pageSize"}, + {Ref: "#/components/parameters/orderBy"}, + {Ref: "#/components/parameters/order"}, + } + + // Add owner path parameter for owned resources + if ownerDef != nil { + params = append([]*openapi3.ParameterRef{ + buildPathParameter(def.GetOwnerPathParam(), ownerDef.Kind+" ID"), + }, params...) + } + + return &openapi3.Operation{ + OperationID: operationID, + Summary: summary, + Description: fmt.Sprintf("Returns adapter status reports for this %s", def.Singular), + Parameters: params, + Responses: buildStatusListResponses(), + Security: &openapi3.SecurityRequirements{{"BearerAuth": {}}}, + } +} + +// buildCreateStatusOperation creates a POST operation for creating/updating adapter status. +func buildCreateStatusOperation(def *api.ResourceDefinition, ownerDef *api.ResourceDefinition) *openapi3.Operation { + operationID := fmt.Sprintf("post%sStatuses", def.Kind) + summary := "Create or update adapter status" + + params := []*openapi3.ParameterRef{ + buildPathParameter(def.Singular+"_id", def.Kind+" ID"), + } + + // Add owner path parameter for owned resources + if ownerDef != nil { + params = append([]*openapi3.ParameterRef{ + buildPathParameter(def.GetOwnerPathParam(), ownerDef.Kind+" ID"), + }, params...) + } + + return &openapi3.Operation{ + OperationID: operationID, + Summary: summary, + Description: fmt.Sprintf("Adapter creates or updates its status report for this %s.\nIf adapter already has a status, it will be updated (upsert by adapter name).\n\nResponse includes the full adapter status with all conditions.\nAdapter should call this endpoint every time it evaluates the %s.", def.Singular, def.Singular), + Parameters: params, + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Required: true, + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/AdapterStatusCreateRequest", + }, + }, + }, + }, + }, + Responses: buildStatusCreateResponses(), + } +} + +// buildPathParameter creates a path parameter reference. +func buildPathParameter(name, description string) *openapi3.ParameterRef { + return &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: name, + In: "path", + Required: true, + Description: description, + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + } +} + +// buildListResponses creates standard responses for list operations. +func buildListResponses(def *api.ResourceDefinition) *openapi3.Responses { + responses := openapi3.NewResponses() + responses.Set("200", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The request has succeeded."), + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + def.Kind + "List", + }, + }, + }, + }, + }) + responses.Set("400", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The server could not understand the request due to invalid syntax."), + }, + }) + responses.Set("default", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("An unexpected error response."), + Content: openapi3.Content{ + "application/problem+json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/Error", + }, + }, + }, + }, + }) + return responses +} + +// buildGetResponses creates standard responses for get operations. +func buildGetResponses(def *api.ResourceDefinition) *openapi3.Responses { + responses := openapi3.NewResponses() + responses.Set("200", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The request has succeeded."), + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + def.Kind, + }, + }, + }, + }, + }) + responses.Set("400", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The server could not understand the request due to invalid syntax."), + }, + }) + responses.Set("default", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("An unexpected error response."), + Content: openapi3.Content{ + "application/problem+json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/Error", + }, + }, + }, + }, + }) + return responses +} + +// buildCreateResponses creates standard responses for create operations. +func buildCreateResponses(def *api.ResourceDefinition) *openapi3.Responses { + responses := openapi3.NewResponses() + responses.Set("201", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The request has succeeded and a new resource has been created as a result."), + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + def.Kind, + }, + }, + }, + }, + }) + responses.Set("400", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The server could not understand the request due to invalid syntax."), + }, + }) + responses.Set("default", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("An unexpected error response."), + Content: openapi3.Content{ + "application/problem+json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/Error", + }, + }, + }, + }, + }) + return responses +} + +// buildDeleteResponses creates standard responses for delete operations. +func buildDeleteResponses() *openapi3.Responses { + responses := openapi3.NewResponses() + responses.Set("204", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The resource has been successfully deleted."), + }, + }) + responses.Set("400", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The server could not understand the request due to invalid syntax."), + }, + }) + responses.Set("404", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The server cannot find the requested resource."), + }, + }) + responses.Set("default", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("An unexpected error response."), + Content: openapi3.Content{ + "application/problem+json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/Error", + }, + }, + }, + }, + }) + return responses +} + +// buildStatusListResponses creates standard responses for status list operations. +func buildStatusListResponses() *openapi3.Responses { + responses := openapi3.NewResponses() + responses.Set("200", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The request has succeeded."), + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/AdapterStatusList", + }, + }, + }, + }, + }) + responses.Set("400", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The server could not understand the request due to invalid syntax."), + }, + }) + responses.Set("404", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The server cannot find the requested resource."), + }, + }) + responses.Set("default", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("An unexpected error response."), + Content: openapi3.Content{ + "application/problem+json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/Error", + }, + }, + }, + }, + }) + return responses +} + +// buildStatusCreateResponses creates standard responses for status create operations. +func buildStatusCreateResponses() *openapi3.Responses { + responses := openapi3.NewResponses() + responses.Set("201", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The request has succeeded and a new resource has been created as a result."), + Content: openapi3.Content{ + "application/json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/AdapterStatus", + }, + }, + }, + }, + }) + responses.Set("400", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The server could not understand the request due to invalid syntax."), + }, + }) + responses.Set("404", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The server cannot find the requested resource."), + }, + }) + responses.Set("409", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: stringPtr("The request conflicts with the current state of the server."), + }, + }) + return responses +} + +// stringPtr returns a pointer to a string value. +func stringPtr(s string) *string { + return &s +} diff --git a/pkg/openapi/schemas.go b/pkg/openapi/schemas.go new file mode 100644 index 0000000..a71074c --- /dev/null +++ b/pkg/openapi/schemas.go @@ -0,0 +1,368 @@ +/* +Copyright (c) 2018 Red Hat, 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. +*/ + +package openapi + +import ( + "github.com/getkin/kin-openapi/openapi3" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" +) + +// addResourceSchemas generates OpenAPI schemas for a resource definition. +// It creates: {Kind}, {Kind}Spec, {Kind}Status, {Kind}List, {Kind}CreateRequest +func addResourceSchemas(doc *openapi3.T, def *api.ResourceDefinition) { + // Generate spec schema + doc.Components.Schemas[def.Kind+"Spec"] = buildSpecSchema(def) + + // Generate status schema + doc.Components.Schemas[def.Kind+"Status"] = buildStatusSchema(def) + + // Generate main resource schema + doc.Components.Schemas[def.Kind] = buildResourceSchema(def) + + // Generate list schema + doc.Components.Schemas[def.Kind+"List"] = buildListSchema(def) + + // Generate create request schema + doc.Components.Schemas[def.Kind+"CreateRequest"] = buildCreateRequestSchema(def) +} + +// buildSpecSchema creates the OpenAPI schema for a resource's spec field. +func buildSpecSchema(def *api.ResourceDefinition) *openapi3.SchemaRef { + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Description: def.Kind + " specification. Accepts any properties as the spec is provider-agnostic.", + } + + // If we have schema information from the CRD, use it + if def.Schema != nil && def.Schema.Spec != nil { + applySchemaProperties(schema, def.Schema.Spec) + } else { + // Default to allowing additional properties for flexibility + schema.AdditionalProperties = openapi3.AdditionalProperties{Has: boolPtr(true)} + } + + return &openapi3.SchemaRef{Value: schema} +} + +// buildStatusSchema creates the OpenAPI schema for a resource's status field. +func buildStatusSchema(def *api.ResourceDefinition) *openapi3.SchemaRef { + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"conditions"}, + Properties: openapi3.Schemas{ + "conditions": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{ + Ref: "#/components/schemas/ResourceCondition", + }, + MinItems: 2, + Description: "List of status conditions for the " + def.Singular + ".\n\n**Mandatory conditions**: \n- `type: \"Ready\"`: Whether all adapters report successfully at the current generation.\n- `type: \"Available\"`: Aggregated adapter result for a common observed_generation.\n\nThese conditions are present immediately upon resource creation.", + }, + }, + }, + Description: def.Kind + " status computed from all status conditions.\n\nThis object is computed by the service and CANNOT be modified directly.", + } + + return &openapi3.SchemaRef{Value: schema} +} + +// buildResourceSchema creates the main OpenAPI schema for a resource. +func buildResourceSchema(def *api.ResourceDefinition) *openapi3.SchemaRef { + required := []string{"name", "spec", "created_time", "updated_time", "created_by", "updated_by", "generation", "status"} + + properties := openapi3.Schemas{ + "id": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Resource identifier", + }, + }, + "kind": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Resource kind", + }, + }, + "href": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Resource URI", + }, + }, + "labels": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + AdditionalProperties: openapi3.AdditionalProperties{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + }, + Description: "Labels for the API resource as pairs of name:value strings", + }, + }, + "name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + MinLength: 3, + MaxLength: uint64Ptr(63), + Pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + Description: def.Kind + " name (unique)", + }, + }, + "spec": &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + def.Kind + "Spec", + }, + "created_time": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + }, + }, + "updated_time": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "date-time", + }, + }, + "created_by": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "email", + }, + }, + "updated_by": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "email", + }, + }, + "generation": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + Format: "int32", + Min: float64Ptr(1), + Description: "Generation field is updated on customer updates, reflecting the version of the \"intent\" of the customer", + }, + }, + "status": &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + def.Kind + "Status", + }, + } + + // Add owner_references for owned resources + if def.IsOwned() { + required = append(required, "owner_references") + properties["owner_references"] = &openapi3.SchemaRef{ + Ref: "#/components/schemas/ObjectReference", + } + } + + return &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: required, + Properties: properties, + }, + } +} + +// buildListSchema creates the OpenAPI schema for a list of resources. +func buildListSchema(def *api.ResourceDefinition) *openapi3.SchemaRef { + return &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"kind", "page", "size", "total", "items"}, + Properties: openapi3.Schemas{ + "kind": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + "page": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"integer"}, Format: "int32"}, + }, + "size": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"integer"}, Format: "int32"}, + }, + "total": &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"integer"}, Format: "int32"}, + }, + "items": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + def.Kind, + }, + }, + }, + }, + }, + } +} + +// buildCreateRequestSchema creates the OpenAPI schema for a create request. +func buildCreateRequestSchema(def *api.ResourceDefinition) *openapi3.SchemaRef { + return &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Required: []string{"name", "spec"}, + Properties: openapi3.Schemas{ + "id": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Resource identifier", + }, + }, + "kind": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Resource kind", + }, + }, + "href": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Resource URI", + }, + }, + "labels": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + AdditionalProperties: openapi3.AdditionalProperties{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}, + }, + }, + Description: "Labels for the API resource as pairs of name:value strings", + }, + }, + "name": &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + MinLength: 3, + MaxLength: uint64Ptr(63), + Pattern: "^[a-z0-9]([-a-z0-9]*[a-z0-9])?$", + Description: def.Kind + " name (unique)", + }, + }, + "spec": &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + def.Kind + "Spec", + }, + }, + }, + } +} + +// applySchemaProperties applies properties from a CRD schema map to an OpenAPI schema. +func applySchemaProperties(schema *openapi3.Schema, crdSchema map[string]interface{}) { + if props, ok := crdSchema["properties"].(map[string]interface{}); ok { + schema.Properties = make(openapi3.Schemas) + for name, propSchema := range props { + if propMap, ok := propSchema.(map[string]interface{}); ok { + schema.Properties[name] = convertCRDSchemaToOpenAPI(propMap) + } + } + } + + if required, ok := crdSchema["required"].([]string); ok { + schema.Required = required + } + + if desc, ok := crdSchema["description"].(string); ok { + schema.Description = desc + } + + // Handle additionalProperties + if addProps, ok := crdSchema["additionalProperties"].(bool); ok && addProps { + schema.AdditionalProperties = openapi3.AdditionalProperties{Has: boolPtr(true)} + } +} + +// convertCRDSchemaToOpenAPI converts a CRD schema map to an OpenAPI SchemaRef. +func convertCRDSchemaToOpenAPI(crdSchema map[string]interface{}) *openapi3.SchemaRef { + schema := &openapi3.Schema{} + + if t, ok := crdSchema["type"].(string); ok { + schema.Type = &openapi3.Types{t} + } + + if desc, ok := crdSchema["description"].(string); ok { + schema.Description = desc + } + + if format, ok := crdSchema["format"].(string); ok { + schema.Format = format + } + + if enum, ok := crdSchema["enum"].([]interface{}); ok { + schema.Enum = enum + } + + if min, ok := crdSchema["minimum"].(float64); ok { + schema.Min = &min + } + + if max, ok := crdSchema["maximum"].(float64); ok { + schema.Max = &max + } + + if minLen, ok := crdSchema["minLength"].(int64); ok { + schema.MinLength = uint64(minLen) + } + + if maxLen, ok := crdSchema["maxLength"].(int64); ok { + uval := uint64(maxLen) + schema.MaxLength = &uval + } + + if pattern, ok := crdSchema["pattern"].(string); ok { + schema.Pattern = pattern + } + + if props, ok := crdSchema["properties"].(map[string]interface{}); ok { + schema.Properties = make(openapi3.Schemas) + for name, propSchema := range props { + if propMap, ok := propSchema.(map[string]interface{}); ok { + schema.Properties[name] = convertCRDSchemaToOpenAPI(propMap) + } + } + } + + if items, ok := crdSchema["items"].(map[string]interface{}); ok { + schema.Items = convertCRDSchemaToOpenAPI(items) + } + + if required, ok := crdSchema["required"].([]string); ok { + schema.Required = required + } + + if addProps, ok := crdSchema["additionalProperties"].(bool); ok && addProps { + schema.AdditionalProperties = openapi3.AdditionalProperties{Has: boolPtr(true)} + } + + return &openapi3.SchemaRef{Value: schema} +} + +// uint64Ptr returns a pointer to a uint64 value. +func uint64Ptr(v uint64) *uint64 { + return &v +} + +// float64Ptr returns a pointer to a float64 value. +func float64Ptr(v float64) *float64 { + return &v +} diff --git a/pkg/validators/schema_validator.go b/pkg/validators/schema_validator.go index 3226e5d..91d5169 100644 --- a/pkg/validators/schema_validator.go +++ b/pkg/validators/schema_validator.go @@ -30,33 +30,33 @@ func NewSchemaValidator(schemaPath string) (*SchemaValidator, error) { return nil, fmt.Errorf("failed to load OpenAPI schema from %s: %w", schemaPath, err) } - // Validate the loaded document + return NewSchemaValidatorFromSpec(doc) +} + +// NewSchemaValidatorFromSpec creates a new schema validator from an existing OpenAPI spec. +// This is useful when the spec is generated dynamically rather than loaded from a file. +func NewSchemaValidatorFromSpec(doc *openapi3.T) (*SchemaValidator, error) { + // Validate the document if err := doc.Validate(context.Background()); err != nil { return nil, fmt.Errorf("invalid OpenAPI schema: %w", err) } - // Extract ClusterSpec schema - clusterSpecSchema := doc.Components.Schemas["ClusterSpec"] - if clusterSpecSchema == nil { - return nil, fmt.Errorf("ClusterSpec schema not found in OpenAPI spec") - } + // Build schemas map dynamically from all *Spec schemas in the document + schemas := make(map[string]*ResourceSchema) - // Extract NodePoolSpec schema - nodePoolSpecSchema := doc.Components.Schemas["NodePoolSpec"] - if nodePoolSpecSchema == nil { - return nil, fmt.Errorf("NodePoolSpec schema not found in OpenAPI spec") + for name, schema := range doc.Components.Schemas { + if strings.HasSuffix(name, "Spec") { + // Extract resource type from schema name (e.g., "ClusterSpec" -> "cluster") + resourceType := strings.ToLower(strings.TrimSuffix(name, "Spec")) + schemas[resourceType] = &ResourceSchema{ + TypeName: name, + Schema: schema, + } + } } - // Build schemas map - schemas := map[string]*ResourceSchema{ - "cluster": { - TypeName: "ClusterSpec", - Schema: clusterSpecSchema, - }, - "nodepool": { - TypeName: "NodePoolSpec", - Schema: nodePoolSpecSchema, - }, + if len(schemas) == 0 { + return nil, fmt.Errorf("no *Spec schemas found in OpenAPI spec") } return &SchemaValidator{ diff --git a/pkg/validators/schema_validator_test.go b/pkg/validators/schema_validator_test.go index 07ca30c..c5a3a52 100644 --- a/pkg/validators/schema_validator_test.go +++ b/pkg/validators/schema_validator_test.go @@ -88,7 +88,7 @@ func TestNewSchemaValidator_InvalidPath(t *testing.T) { func TestNewSchemaValidator_MissingSchemas(t *testing.T) { RegisterTestingT(t) - // Schema without required components + // Schema without any *Spec schemas invalidSchema := ` openapi: 3.0.0 info: @@ -106,10 +106,10 @@ components: err := os.WriteFile(schemaPath, []byte(invalidSchema), 0600) Expect(err).To(BeNil()) - // Should fail because ClusterSpec is missing + // Should fail because no *Spec schemas are found _, err = NewSchemaValidator(schemaPath) Expect(err).ToNot(BeNil()) - Expect(err.Error()).To(ContainSubstring("ClusterSpec schema not found")) + Expect(err.Error()).To(ContainSubstring("no *Spec schemas found")) } func TestValidateClusterSpec_Valid(t *testing.T) { From 8158d2c9a275eacad1a92dcbca333b1c5b61c540 Mon Sep 17 00:00:00 2001 From: Ciaran Roche Date: Mon, 9 Feb 2026 12:54:32 +0000 Subject: [PATCH 4/6] Enable local development without Kubernetes Add ability to load CRDs from local YAML files via CRD_PATH environment variable, allowing local development without requiring a Kubernetes cluster. Changes: - Add LoadFromDirectory() to pkg/crd/registry.go for loading CRDs from files - Update plugins/resources/plugin.go to check CRD_PATH env var first - Set CRD_PATH in Makefile run/run-no-auth targets - Remove erroneous global list endpoints for owned resources - Update docs/development.md for new CRD-driven workflow - Rename Default() to DefaultRegistry() to avoid gomega conflict Co-Authored-By: Claude Opus 4.5 --- Makefile | 5 +- cmd/hyperfleet-api/server/routes.go | 2 +- docs/development.md | 107 +++++++++------------------ pkg/crd/registry.go | 65 ++++++++++++++++- pkg/crd/registry_test.go | 108 ++++++++++++++++++++++++++++ pkg/handlers/openapi.go | 2 +- pkg/openapi/generator_test.go | 4 +- pkg/openapi/paths.go | 24 ------- plugins/resources/plugin.go | 33 ++++++--- 9 files changed, 235 insertions(+), 115 deletions(-) create mode 100644 pkg/crd/registry_test.go diff --git a/Makefile b/Makefile index 41bcf16..cde7c0b 100755 --- a/Makefile +++ b/Makefile @@ -234,12 +234,13 @@ generate-all: generate-mocks run: build ./bin/hyperfleet-api migrate - ./bin/hyperfleet-api serve + CRD_PATH=$(PWD)/charts/crds ./bin/hyperfleet-api serve .PHONY: run run-no-auth: build ./bin/hyperfleet-api migrate - ./bin/hyperfleet-api serve --enable-authz=false --enable-jwt=false + CRD_PATH=$(PWD)/charts/crds ./bin/hyperfleet-api serve --enable-authz=false --enable-jwt=false +.PHONY: run-no-auth # Run Swagger and host the api docs # Note: With dynamic OpenAPI generation, use the /api/hyperfleet/v1/openapi.html endpoint instead diff --git a/cmd/hyperfleet-api/server/routes.go b/cmd/hyperfleet-api/server/routes.go index 7f75dc0..ab3f70f 100755 --- a/cmd/hyperfleet-api/server/routes.go +++ b/cmd/hyperfleet-api/server/routes.go @@ -136,7 +136,7 @@ func registerApiMiddleware(router *mux.Router) { } } else { // Default: Generate schema dynamically from CRD registry - spec := openapi.GenerateSpec(crd.Default()) + spec := openapi.GenerateSpec(crd.DefaultRegistry()) schemaValidator, err = validators.NewSchemaValidatorFromSpec(spec) if err != nil { logger.WithError(ctx, err).Warn("Failed to create schema validator from generated spec") diff --git a/docs/development.md b/docs/development.md index 664b0dd..574ac32 100644 --- a/docs/development.md +++ b/docs/development.md @@ -23,8 +23,8 @@ make --version Set up your local development environment: ```bash -# 1. Generate OpenAPI code and mocks -make generate-all +# 1. Generate mocks for testing +make generate-mocks # 2. Install dependencies go mod download @@ -32,18 +32,20 @@ go mod download # 3. Build the binary make build -# 4. Setup PostgreSQL database +# 4. Initialize secrets +make secrets + +# 5. Setup PostgreSQL database make db/setup -# 5. Run database migrations +# 6. Run database migrations ./bin/hyperfleet-api migrate -# 6. Verify database schema -make db/login -\dt +# 7. Start the service (development mode) +make run-no-auth ``` -**Important**: Generated code is not tracked in git. You must run `make generate-all` after cloning to generate both OpenAPI models and mocks. +**Important**: Mocks are generated from source interfaces. Run `make generate-mocks` after cloning. ## Pre-commit Hooks (Optional) @@ -143,22 +145,16 @@ All API endpoints have integration test coverage. ### Common Commands ```bash -# Generate OpenAPI client code -make generate - # Generate mocks for testing make generate-mocks -# Generate both OpenAPI and mocks -make generate-all - # Build binary make build # Run database migrations ./bin/hyperfleet-api migrate -# Start server (no auth) +# Start server (no auth, local CRDs) make run-no-auth # Run tests @@ -175,64 +171,39 @@ make db/login # Connect to database shell | Command | Description | |---------|-------------| -| `make generate` | Generate Go models from OpenAPI spec | | `make generate-mocks` | Generate mock implementations for testing | -| `make generate-all` | Generate both OpenAPI models and mocks | | `make build` | Build hyperfleet-api executable to bin/ | | `make test` | Run unit tests | | `make test-integration` | Run integration tests | -| `make run-no-auth` | Start server without authentication | -| `make run` | Start server with OCM authentication | +| `make run-no-auth` | Start server without authentication (loads CRDs from local files) | +| `make run` | Start server with OCM authentication (loads CRDs from local files) | | `make db/setup` | Create PostgreSQL container | | `make db/teardown` | Remove PostgreSQL container | | `make db/login` | Connect to database shell | ## Development Workflow -### Code Generation - -HyperFleet API generates Go models from OpenAPI specifications using `openapi-generator-cli`. +### CRD-Driven API -**Workflow**: -```text -openapi/openapi.yaml - ↓ -make generate (podman + openapi-generator-cli) - ↓ -pkg/api/openapi/model_*.go (Go structs) -pkg/api/openapi/api/openapi.yaml (embedded spec) -``` - -**Generated artifacts**: -- Go model structs with JSON tags (`model_*.go`) -- Fully resolved OpenAPI specification (embedded in binary) - -**Important**: -- Generated files are NOT tracked in git -- Must run `make generate` after cloning -- Must run after OpenAPI spec updates +HyperFleet API dynamically generates its OpenAPI specification and routes from Kubernetes Custom Resource Definitions (CRDs). The CRD files are located in `charts/crds/`. -**OpenAPI spec source**: -The `openapi/openapi.yaml` is maintained in the [hyperfleet-api-spec](https://github.com/openshift-hyperfleet/hyperfleet-api-spec) repository using TypeSpec. When the spec changes, the compiled YAML is copied here. Developers working on hyperfleet-api only need to run `make generate` - no TypeSpec knowledge required. - -**Commands**: -```bash -# Generate Go models from OpenAPI spec -make generate +**How it works**: +- At startup, the API loads CRD definitions and generates routes dynamically +- OpenAPI spec is generated at runtime from the loaded CRDs +- No code generation required for API types -# Generate both OpenAPI models and mocks -make generate-all -``` +**CRD Loading Priority**: +1. If `CRD_PATH` environment variable is set, load from that directory +2. Otherwise, try to load from Kubernetes API +3. If both fail, dynamic routes are disabled (warning logged) -**Troubleshooting**: +**Environment Variable**: ```bash -# If "pkg/api/openapi not found" -make generate -go mod download +# Set CRD_PATH to load CRDs from local files (used by make run/run-no-auth) +CRD_PATH=/path/to/crds ./bin/hyperfleet-api serve -# If generator container fails -podman info # Check podman is running -make generate +# The Makefile targets set this automatically: +make run-no-auth # Sets CRD_PATH=$(PWD)/charts/crds ``` ### Mock Generation @@ -252,11 +223,8 @@ Service files contain `//go:generate` directives that specify how to generate mo **Commands**: ```bash -# Generate mocks only +# Generate mocks make generate-mocks - -# Generate OpenAPI models and mocks together -make generate-all ``` ### Tool Dependency Management (Bingo) @@ -294,10 +262,9 @@ Tool versions are tracked in `.bingo/*.mod` files and loaded automatically via ` 2. **Make your changes** to the code -3. **Update OpenAPI spec if needed**: - - Make changes in the [hyperfleet-api-spec](https://github.com/openshift-hyperfleet/hyperfleet-api-spec) repository - - Copy updated `openapi.yaml` to this repository - - Run `make generate` to regenerate Go models +3. **Update CRDs if needed**: + - Modify CRD files in `charts/crds/` + - The API will pick up changes on restart 4. **Regenerate mocks if service interfaces changed**: ```bash @@ -324,16 +291,6 @@ Tool versions are tracked in `.bingo/*.mod` files and loaded automatically via ` ## Troubleshooting -### "pkg/api/openapi not found" - -**Problem**: Missing generated OpenAPI code - -**Solution**: -```bash -make generate -go mod download -``` - ### "undefined: Mock*" or missing mock files **Problem**: Missing generated mock implementations diff --git a/pkg/crd/registry.go b/pkg/crd/registry.go index ba180ae..ca806c4 100644 --- a/pkg/crd/registry.go +++ b/pkg/crd/registry.go @@ -21,6 +21,8 @@ package crd import ( "context" "fmt" + "os" + "path/filepath" "strings" "sync" @@ -29,6 +31,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/yaml" "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" ) @@ -108,6 +111,59 @@ func (r *Registry) LoadFromKubernetes(ctx context.Context) error { return nil } +// LoadFromDirectory loads CRDs from YAML files in the specified directory. +// Files must have .yaml or .yml extension and contain valid CRD definitions. +func (r *Registry) LoadFromDirectory(dir string) error { + r.mu.Lock() + defer r.mu.Unlock() + + // Find all YAML files + files, err := os.ReadDir(dir) + if err != nil { + return fmt.Errorf("failed to read directory %s: %w", dir, err) + } + + for _, file := range files { + if file.IsDir() { + continue + } + if !strings.HasSuffix(file.Name(), ".yaml") && !strings.HasSuffix(file.Name(), ".yml") { + continue + } + + path := filepath.Join(dir, file.Name()) + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", path, err) + } + + // Parse YAML into CRD + var crd apiextensionsv1.CustomResourceDefinition + if err := yaml.Unmarshal(data, &crd); err != nil { + return fmt.Errorf("failed to parse CRD from %s: %w", path, err) + } + + // Skip if not a HyperFleet CRD + if crd.Spec.Group != HyperfleetGroup { + continue + } + + def, err := r.parseCRD(&crd) + if err != nil { + return fmt.Errorf("failed to parse CRD %s: %w", crd.Name, err) + } + + // Register the CRD + r.byKind[def.Kind] = def + r.byPlural[def.Plural] = def + if def.Enabled { + r.all = append(r.all, def) + } + } + + return nil +} + // parseCRD converts a Kubernetes CRD to a ResourceDefinition using annotations. func (r *Registry) parseCRD(crd *apiextensionsv1.CustomResourceDefinition) (*api.ResourceDefinition, error) { annotations := crd.Annotations @@ -379,8 +435,8 @@ func (r *Registry) Count() int { // Global default registry var defaultRegistry = NewRegistry() -// Default returns the global default registry. -func Default() *Registry { +// DefaultRegistry returns the global default registry. +func DefaultRegistry() *Registry { return defaultRegistry } @@ -389,6 +445,11 @@ func LoadFromKubernetes(ctx context.Context) error { return defaultRegistry.LoadFromKubernetes(ctx) } +// LoadFromDirectory loads CRDs into the default registry from local YAML files. +func LoadFromDirectory(dir string) error { + return defaultRegistry.LoadFromDirectory(dir) +} + // GetByKind looks up a CRD by kind in the default registry. func GetByKind(kind string) (*api.ResourceDefinition, bool) { return defaultRegistry.GetByKind(kind) diff --git a/pkg/crd/registry_test.go b/pkg/crd/registry_test.go new file mode 100644 index 0000000..81a40bc --- /dev/null +++ b/pkg/crd/registry_test.go @@ -0,0 +1,108 @@ +/* +Copyright (c) 2018 Red Hat, 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. +*/ + +package crd + +import ( + "testing" + + . "github.com/onsi/gomega" +) + +func TestLoadFromDirectory(t *testing.T) { + RegisterTestingT(t) + + registry := NewRegistry() + err := registry.LoadFromDirectory("../../charts/crds") + + Expect(err).To(BeNil()) + Expect(registry.Count()).To(BeNumerically(">=", 3)) // Cluster, NodePool, IDP + + // Verify Cluster CRD loaded + cluster, found := registry.GetByKind("Cluster") + Expect(found).To(BeTrue()) + Expect(cluster.Plural).To(Equal("clusters")) + Expect(cluster.IsRoot()).To(BeTrue()) + + // Verify NodePool CRD loaded with owner + nodepool, found := registry.GetByKind("NodePool") + Expect(found).To(BeTrue()) + Expect(nodepool.IsOwned()).To(BeTrue()) + Expect(nodepool.GetOwnerKind()).To(Equal("Cluster")) + + // Verify IDP CRD loaded + idp, found := registry.GetByKind("IDP") + Expect(found).To(BeTrue()) + Expect(idp.Plural).To(Equal("idps")) + Expect(idp.IsOwned()).To(BeTrue()) + Expect(idp.GetOwnerKind()).To(Equal("Cluster")) +} + +func TestLoadFromDirectory_NonExistentDirectory(t *testing.T) { + RegisterTestingT(t) + + registry := NewRegistry() + err := registry.LoadFromDirectory("/nonexistent/path") + + Expect(err).ToNot(BeNil()) + Expect(err.Error()).To(ContainSubstring("failed to read directory")) +} + +func TestLoadFromDirectory_EmptyDirectory(t *testing.T) { + RegisterTestingT(t) + + registry := NewRegistry() + // Use a directory that exists but has no YAML files + err := registry.LoadFromDirectory("../../bin") + + // Should succeed but load nothing (bin may not exist, so we just check no panic) + if err == nil { + Expect(registry.Count()).To(Equal(0)) + } +} + +func TestLoadFromDirectory_GetByPlural(t *testing.T) { + RegisterTestingT(t) + + registry := NewRegistry() + err := registry.LoadFromDirectory("../../charts/crds") + Expect(err).To(BeNil()) + + // Test GetByPlural + cluster, found := registry.GetByPlural("clusters") + Expect(found).To(BeTrue()) + Expect(cluster.Kind).To(Equal("Cluster")) + + nodepool, found := registry.GetByPlural("nodepools") + Expect(found).To(BeTrue()) + Expect(nodepool.Kind).To(Equal("NodePool")) +} + +func TestLoadFromDirectory_All(t *testing.T) { + RegisterTestingT(t) + + registry := NewRegistry() + err := registry.LoadFromDirectory("../../charts/crds") + Expect(err).To(BeNil()) + + all := registry.All() + Expect(len(all)).To(BeNumerically(">=", 3)) + + // Verify all returned definitions are enabled + for _, def := range all { + Expect(def.Enabled).To(BeTrue()) + } +} diff --git a/pkg/handlers/openapi.go b/pkg/handlers/openapi.go index cf475eb..da6ebb0 100755 --- a/pkg/handlers/openapi.go +++ b/pkg/handlers/openapi.go @@ -24,7 +24,7 @@ func NewOpenAPIHandler() (*openAPIHandler, error) { ctx := context.Background() // Generate the OpenAPI spec dynamically from CRD registry - spec := openapi.GenerateSpec(crd.Default()) + spec := openapi.GenerateSpec(crd.DefaultRegistry()) // Marshal the spec to JSON data, err := spec.MarshalJSON() diff --git a/pkg/openapi/generator_test.go b/pkg/openapi/generator_test.go index 332d4a0..ce768fe 100644 --- a/pkg/openapi/generator_test.go +++ b/pkg/openapi/generator_test.go @@ -122,8 +122,8 @@ func TestGenerateSpec_WithOwnedResource(t *testing.T) { Expect(spec.Paths.Find("/api/hyperfleet/v1/clusters/{cluster_id}/nodepools")).ToNot(BeNil()) Expect(spec.Paths.Find("/api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{nodepool_id}")).ToNot(BeNil()) - // Should have global list path for owned resource - Expect(spec.Paths.Find("/api/hyperfleet/v1/nodepools")).ToNot(BeNil()) + // Should NOT have global list path for owned resource (owned resources only accessible via parent) + Expect(spec.Paths.Find("/api/hyperfleet/v1/nodepools")).To(BeNil()) } func TestGenerateSpec_JSON(t *testing.T) { diff --git a/pkg/openapi/paths.go b/pkg/openapi/paths.go index faf92e7..39a9f9f 100644 --- a/pkg/openapi/paths.go +++ b/pkg/openapi/paths.go @@ -103,12 +103,6 @@ func addOwnedResourcePaths(doc *openapi3.T, def *api.ResourceDefinition, registr Get: buildListStatusesOperation(def, ownerDef), Post: buildCreateStatusOperation(def, ownerDef), }) - - // Also add a global list endpoint for owned resources (without owner filter) - globalListPath := fmt.Sprintf("%s/%s", basePath, def.Plural) - doc.Paths.Set(globalListPath, &openapi3.PathItem{ - Get: buildGlobalListOperation(def), - }) } // getOwnerDefinitionFromRegistry returns the ResourceDefinition for the owner of an owned resource. @@ -154,24 +148,6 @@ func buildListOperation(def *api.ResourceDefinition, ownerDef *api.ResourceDefin } } -// buildGlobalListOperation creates a GET operation for listing all resources globally. -func buildGlobalListOperation(def *api.ResourceDefinition) *openapi3.Operation { - return &openapi3.Operation{ - OperationID: fmt.Sprintf("get%s", inflection.Plural(def.Kind)), - Summary: fmt.Sprintf("List all %s", def.Plural), - Description: fmt.Sprintf("Returns the list of all %s", def.Plural), - Parameters: []*openapi3.ParameterRef{ - {Ref: "#/components/parameters/search"}, - {Ref: "#/components/parameters/page"}, - {Ref: "#/components/parameters/pageSize"}, - {Ref: "#/components/parameters/orderBy"}, - {Ref: "#/components/parameters/order"}, - }, - Responses: buildListResponses(def), - Security: &openapi3.SecurityRequirements{{"BearerAuth": {}}}, - } -} - // buildCreateOperation creates a POST operation for creating a resource. func buildCreateOperation(def *api.ResourceDefinition, ownerDef *api.ResourceDefinition) *openapi3.Operation { operationID := fmt.Sprintf("post%s", def.Kind) diff --git a/plugins/resources/plugin.go b/plugins/resources/plugin.go index 1567313..d5c87b2 100644 --- a/plugins/resources/plugin.go +++ b/plugins/resources/plugin.go @@ -5,6 +5,7 @@ package resources import ( "context" "net/http" + "os" "github.com/gorilla/mux" "github.com/openshift-hyperfleet/hyperfleet-api/cmd/hyperfleet-api/environments" @@ -46,15 +47,31 @@ func Service(s *environments.Services) services.ResourceService { } func init() { - // Load CRDs from Kubernetes API ctx := context.Background() - if err := crd.LoadFromKubernetes(ctx); err != nil { - // Log warning but don't fail - CRDs might not be present in all environments - logger.WithError(nil, err).Warn( - "Failed to load CRDs from Kubernetes API, generic resource API disabled") - } else { - logger.With(nil, "crd_count", crd.Default().Count()).Info( - "Loaded CRD definitions from Kubernetes API") + crdLoaded := false + + // Try loading from local files first (for local development) + if crdPath := os.Getenv("CRD_PATH"); crdPath != "" { + if err := crd.LoadFromDirectory(crdPath); err != nil { + logger.WithError(nil, err).Warn( + "Failed to load CRDs from directory, trying Kubernetes API") + } else { + logger.With(nil, "crd_count", crd.DefaultRegistry().Count(), "path", crdPath).Info( + "Loaded CRD definitions from local files") + crdLoaded = true + } + } + + // Fall back to Kubernetes API + if !crdLoaded { + if err := crd.LoadFromKubernetes(ctx); err != nil { + // Log warning but don't fail - CRDs might not be present in all environments + logger.WithError(nil, err).Warn( + "Failed to load CRDs from Kubernetes API, generic resource API disabled") + } else { + logger.With(nil, "crd_count", crd.DefaultRegistry().Count()).Info( + "Loaded CRD definitions from Kubernetes API") + } } // Service registration From 5e4f53aca848382c6fb114fa183738f000d5c2cb Mon Sep 17 00:00:00 2001 From: Ciaran Roche Date: Mon, 9 Feb 2026 12:59:13 +0000 Subject: [PATCH 5/6] Update documentation for CRD-driven API workflow - Remove references to obsolete make generate and make generate-all - Update README.md installation steps and common commands - Update AGENTS.md development workflow and troubleshooting - Remove global list endpoint from NodePool documentation Co-Authored-By: Claude Opus 4.5 --- AGENTS.md | 70 +++++++++++++++++++++++++++---------------------------- README.md | 20 ++++++++-------- 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 00674de..c8c4fd2 100755 --- a/AGENTS.md +++ b/AGENTS.md @@ -75,8 +75,7 @@ make db/teardown # Stop and remove PostgreSQL container ### Code Generation ```bash -make generate # Regenerate Go models from openapi/openapi.yaml -make generate-vendor # Generate using vendor dependencies (offline mode) +make generate-mocks # Generate mock implementations for testing ``` ## Project Structure @@ -126,25 +125,24 @@ hyperfleet-api/ ## Core Components -### 1. API Specification Workflow +### 1. CRD-Driven API -The API is specified using TypeSpec, which compiles to OpenAPI, which then generates Go models: +The API is dynamically generated from Kubernetes Custom Resource Definitions (CRDs): ``` -TypeSpec (.tsp files in hyperfleet-api-spec repo) - ↓ tsp compile -openapi/openapi.yaml (32KB, uses $ref for DRY) - ↓ make generate (openapi-generator-cli in Podman) -pkg/api/openapi/model_*.go (Go structs) -pkg/api/openapi/api/openapi.yaml (44KB, fully resolved, embedded in binary) +charts/crds/*.yaml (CRD definitions) + ↓ loaded at startup +pkg/crd/registry.go (CRD registry) + ↓ generates +Dynamic routes + OpenAPI spec at runtime ``` **Key Points**: -- TypeSpec definitions are maintained in a separate `hyperfleet-api-spec` repository -- `openapi/openapi.yaml` is the source of truth for this repository (generated from TypeSpec) -- `make generate` uses Podman to run openapi-generator-cli, ensuring consistent versions -- Generated code includes JSON tags, validation, and type definitions -- The fully resolved spec is embedded at compile time via `//go:embed` +- CRD definitions in `charts/crds/` define resource types (Cluster, NodePool, IDP, etc.) +- Routes and OpenAPI spec are generated dynamically at startup +- No code generation required - just modify CRD YAML files +- Local development uses `CRD_PATH` env var to load CRDs from files +- Production loads CRDs from Kubernetes API ### 2. Database Layer @@ -401,8 +399,8 @@ All subcommands support these logging flags: ```bash # Prerequisites: Go 1.24, Podman, PostgreSQL client tools -# Generate OpenAPI code (required before go mod download) -make generate +# Generate mocks for testing +make generate-mocks # Download Go module dependencies go mod download @@ -419,25 +417,24 @@ make build # Run migrations ./bin/hyperfleet-api migrate -# Start server (no authentication) +# Start server (no authentication, loads CRDs from local files) make run-no-auth ``` -### Code Generation +### CRD Configuration -When the TypeSpec specification changes: +Resource types are defined by CRDs in `charts/crds/`. To add or modify resource types: ```bash -# Regenerate Go models from openapi/openapi.yaml -make generate - -# This will: -# 1. Remove pkg/api/openapi/* -# 2. Build Docker image with openapi-generator-cli -# 3. Generate model_*.go files -# 4. Copy fully resolved openapi.yaml to pkg/api/openapi/api/ +# Edit CRD files in charts/crds/ +# Restart the server to pick up changes +make run-no-auth ``` +The `CRD_PATH` environment variable controls where CRDs are loaded from: +- `make run-no-auth` sets `CRD_PATH=$(PWD)/charts/crds` automatically +- In production, CRDs are loaded from the Kubernetes API + ### Testing **Unit Tests**: @@ -712,13 +709,13 @@ The server is configured in cmd/hyperfleet/server/: **Solution**: Always run `./bin/hyperfleet-api migrate` after pulling code or changing schemas -### 2. Using Wrong OpenAPI File +### 2. CRD Changes Not Reflected -**Problem**: There are two openapi.yaml files: -- `openapi/openapi.yaml` (32KB, source, has $ref) -- `pkg/api/openapi/api/openapi.yaml` (44KB, generated, fully resolved) +**Problem**: Changes to CRD files in `charts/crds/` aren't showing up. -**Rule**: Only edit the source file. The generated file is overwritten by `make generate`. +**Solution**: Restart the server. CRDs are loaded at startup. +- For local dev: `make run-no-auth` loads from `charts/crds/` +- For production: CRDs are loaded from Kubernetes API ### 3. Context Session Access @@ -795,6 +792,7 @@ The API is designed to be stateless and horizontally scalable: Common issues and solutions: 1. **Database connection errors**: Check `make db/setup` was run and container is running -2. **Generated code issues**: Run `make generate` to regenerate from OpenAPI spec -3. **Test failures**: Ensure PostgreSQL container is running and `OCM_ENV` is set -4. **Build errors**: Verify Go version is 1.24+ with `go version` +2. **Missing mocks**: Run `make generate-mocks` to regenerate test mocks +3. **CRDs not loading**: Ensure `CRD_PATH` is set or Kubernetes cluster is accessible +4. **Test failures**: Ensure PostgreSQL container is running and `OCM_ENV` is set +5. **Build errors**: Verify Go version is 1.24+ with `go version` diff --git a/README.md b/README.md index c5533ed..2759f16 100755 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ See [PREREQUISITES.md](PREREQUISITES.md) for installation instructions. ### Installation ```bash -# 1. Generate OpenAPI code and mocks -make generate-all +# 1. Generate mocks for testing +make generate-mocks # 2. Install dependencies go mod download @@ -61,17 +61,20 @@ go mod download # 3. Build binary make build -# 4. Setup database +# 4. Initialize secrets +make secrets + +# 5. Setup database make db/setup -# 5. Run migrations +# 6. Run migrations ./bin/hyperfleet-api migrate -# 6. Start service (no auth) +# 7. Start service (no auth) make run-no-auth ``` -**Note**: Generated code is not tracked in git. You must run `make generate-all` after cloning. +**Note**: Mocks are generated from source interfaces. Run `make generate-mocks` after cloning. ### Accessing the API @@ -105,7 +108,6 @@ Kubernetes clusters with provider-specific configurations, labels, and adapter-b Groups of compute nodes within clusters. **Main endpoints:** -- `GET /api/hyperfleet/v1/nodepools` - `GET/POST /api/hyperfleet/v1/clusters/{cluster_id}/nodepools` - `GET /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{nodepool_id}` - `GET/POST /api/hyperfleet/v1/clusters/{cluster_id}/nodepools/{nodepool_id}/statuses` @@ -131,12 +133,10 @@ curl -G http://localhost:8000/api/hyperfleet/v1/clusters \ ```bash make build # Build binary to bin/ -make run-no-auth # Run without authentication +make run-no-auth # Run without authentication (loads CRDs from local files) make test # Run unit tests make test-integration # Run integration tests -make generate # Generate OpenAPI models make generate-mocks # Generate test mocks -make generate-all # Generate OpenAPI models and mocks make db/setup # Create PostgreSQL container make image # Build container image ``` From c11a80eba8f8def15620416fed33fa66dab59d2a Mon Sep 17 00:00:00 2001 From: Ciaran Roche Date: Mon, 9 Feb 2026 13:02:32 +0000 Subject: [PATCH 6/6] Fix image-dev target to use linux/amd64 platform Revert to standard amd64 build instead of ARM64 cross-compilation. Co-Authored-By: Claude Opus 4.5 --- Makefile | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index cde7c0b..174ed9d 100755 --- a/Makefile +++ b/Makefile @@ -314,11 +314,11 @@ ifndef QUAY_USER @echo "This will build and push to: quay.io/$$QUAY_USER/$(IMAGE_NAME):$(DEV_TAG)" @exit 1 endif - @echo "Building dev image quay.io/$(QUAY_USER)/$(IMAGE_NAME):$(DEV_TAG) for ARM64..." - # Cross-compile for ARM64: builder uses amd64 golang, Go cross-compiles, final stage uses arm64 base + @echo "Building dev image quay.io/$(QUAY_USER)/$(IMAGE_NAME):$(DEV_TAG)..." + # --platform flag requires Docker >= 20.10 or Podman >= 3.4 + # For older engines: use 'docker buildx build' or omit --platform $(container_tool) build \ - --build-arg TARGETARCH=arm64 \ - --build-arg BASE_IMAGE=alpine:3.21 \ + --platform linux/amd64 \ --build-arg GIT_SHA=$(GIT_SHA) \ --build-arg GIT_DIRTY=$(GIT_DIRTY) \ -t quay.io/$(QUAY_USER)/$(IMAGE_NAME):$(DEV_TAG) .