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/Dockerfile b/Dockerfile index fdabcaf..e605952 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,22 +17,22 @@ 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 # 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 - -# Set default schema path (can be overridden by Helm for provider-specific schemas) -ENV OPENAPI_SCHEMA_PATH=/app/openapi/openapi.yaml +# CRD definitions are now loaded from Kubernetes API at runtime +# 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 6c8304b..174ed9d 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,46 +221,38 @@ 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 + 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 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 @@ -330,7 +319,6 @@ endif # For older engines: use 'docker buildx build' or omit --platform $(container_tool) build \ --platform linux/amd64 \ - --build-arg BASE_IMAGE=alpine:3.21 \ --build-arg GIT_SHA=$(GIT_SHA) \ --build-arg GIT_DIRTY=$(GIT_DIRTY) \ -t quay.io/$(QUAY_USER)/$(IMAGE_NAME):$(DEV_TAG) . 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 ``` 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..a78e349 --- /dev/null +++ b/charts/crds/cluster-crd.yaml @@ -0,0 +1,102 @@ +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 + annotations: + hyperfleet.io/scope: "Root" + hyperfleet.io/required-adapters: "validation,dns,pullsecret,hypershift" + hyperfleet.io/enabled: "true" +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..346888d --- /dev/null +++ b/charts/crds/idp-crd.yaml @@ -0,0 +1,118 @@ +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 + 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: + 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..1302cd9 --- /dev/null +++ b/charts/crds/nodepool-crd.yaml @@ -0,0 +1,112 @@ +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 + 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: + 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..b8691bd --- /dev/null +++ b/charts/templates/rbac/clusterrole.yaml @@ -0,0 +1,29 @@ +{{- 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 (list requires cluster-wide access, filtering done in code) + - apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + verbs: + - get + - list + - watch + # 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..feb7f9b 100755 --- a/cmd/hyperfleet-api/main.go +++ b/cmd/hyperfleet-api/main.go @@ -13,11 +13,9 @@ 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 ) // nolint diff --git a/cmd/hyperfleet-api/server/routes.go b/cmd/hyperfleet-api/server/routes.go index c693476..ab3f70f 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.DefaultRegistry()) + 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/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/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/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/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/api/resource_definition.go b/pkg/api/resource_definition.go new file mode 100644 index 0000000..86c14cc --- /dev/null +++ b/pkg/api/resource_definition.go @@ -0,0 +1,101 @@ +/* +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"` +} + +// 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 { + // 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"` + // 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. +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..ca806c4 --- /dev/null +++ b/pkg/crd/registry.go @@ -0,0 +1,466 @@ +/* +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 ( + "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" + "sigs.k8s.io/yaml" + + "github.com/openshift-hyperfleet/hyperfleet-api/pkg/api" +) + +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. +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), + } +} + +// 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() + + // Create Kubernetes client + config, err := getKubeConfig() + if err != nil { + return fmt.Errorf("failed to get kubernetes config: %w", err) + } + + clientset, err := apiextensionsclient.NewForConfig(config) + if err != nil { + return fmt.Errorf("failed to create apiextensions client: %w", err) + } + + // List all CRDs + crdList, err := clientset.ApiextensionsV1().CustomResourceDefinitions().List(ctx, metav1.ListOptions{}) + if err != nil { + return fmt.Errorf("failed to list CRDs: %w", err) + } + + // Filter and process HyperFleet CRDs + for i := range crdList.Items { + crd := &crdList.Items[i] + 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 +} + +// 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 + if annotations == nil { + annotations = make(map[string]string) + } + + // Parse scope + scopeStr := annotations[AnnotationScope] + if scopeStr == "" { + scopeStr = "Root" // Default to Root + } + 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) + } + + // 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, + } + } + + // 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) + } + } + } + + // Parse enabled flag + 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, + Plural: crd.Spec.Names.Plural, + Singular: crd.Spec.Names.Singular, + Scope: scope, + Owner: owner, + StatusConfig: api.StatusConfig{ + 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) { + // Try in-cluster config first + config, err := rest.InClusterConfig() + if err == nil { + return config, 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. +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() + +// DefaultRegistry returns the global default registry. +func DefaultRegistry() *Registry { + return defaultRegistry +} + +// LoadFromKubernetes loads CRDs into the default registry from Kubernetes API. +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) +} + +// 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/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/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/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/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/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/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 00fe82e..3473c58 100755 --- a/pkg/db/migrations/migration_structs.go +++ b/pkg/db/migrations/migration_structs.go @@ -27,11 +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 + 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/openapi.go b/pkg/handlers/openapi.go index 26f4e49..da6ebb0 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.DefaultRegistry()) + + // 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/handlers/resource.go b/pkg/handlers/resource.go new file mode 100644 index 0000000..6e94132 --- /dev/null +++ b/pkg/handlers/resource.go @@ -0,0 +1,360 @@ +package handlers + +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" +) + +// 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. +// It looks up the plural from the CRD registry. +func getOwnerPlural(kind string) string { + if def, found := crd.GetByKind(kind); found { + return def.Plural + } + // Default: lowercase + "s" + return strings.ToLower(kind) + "s" +} 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..ce768fe --- /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 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) { + 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..39a9f9f --- /dev/null +++ b/pkg/openapi/paths.go @@ -0,0 +1,574 @@ +/* +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), + }) +} + +// 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": {}}}, + } +} + +// 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/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 6a2f4c0..8c6ddd9 100755 --- a/pkg/services/generic.go +++ b/pkg/services/generic.go @@ -41,11 +41,8 @@ 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 }, } allFieldsAllowed = map[string]string{} 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/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/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) { 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 new file mode 100644 index 0000000..d5c87b2 --- /dev/null +++ b/plugins/resources/plugin.go @@ -0,0 +1,165 @@ +// Package resources provides a dynamic plugin that loads CRD definitions and registers routes. +// 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" + "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" +) + +// 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 +} + +func init() { + ctx := context.Background() + 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 + 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" 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)) -}