diff --git a/Makefile b/Makefile index 48f0464..706081e 100644 --- a/Makefile +++ b/Makefile @@ -54,7 +54,7 @@ helm-template-dummy: ## Render helm templates with dummy validation mode @echo "$(GREEN)Rendering helm templates (dummy validation mode)...$(NC)" helm template $(RELEASE_NAME) $(CHART_DIR) \ --namespace $(NAMESPACE) \ - --set deploymentMode=dummy \ + --set validation.useDummy=true \ --set broker.type=googlepubsub \ --set broker.googlepubsub.projectId=my-project \ --set broker.googlepubsub.topic=my-topic \ @@ -65,20 +65,19 @@ helm-template-dummy: ## Render helm templates with dummy validation mode --set validation.dummy.simulateResult=success \ --set rbac.create=true -# Real mode (not yet available; keep commented until implemented — see HYPERFLEET-267) -#helm-template-real: ## Render helm templates with real validation mode (future) -# @echo "$(GREEN)Rendering helm templates (real validation mode)...$(NC)" -# helm template $(RELEASE_NAME) $(CHART_DIR) \ -# --namespace $(NAMESPACE) \ -# --set deploymentMode=real \ -# --set broker.type=googlepubsub \ -# --set broker.googlepubsub.projectId=my-project \ -# --set broker.googlepubsub.topic=my-topic \ -# --set broker.googlepubsub.subscription=my-subscription \ -# --set broker.googlepubsub.deadLetterTopic=my-dlq \ -# --set broker.subscriber.parallelism=20 \ -# --set hyperfleetApi.baseUrl=https://api.hyperfleet.example.com \ -# --set rbac.create=true +helm-template-real: ## Render helm templates with real validation mode + @echo "$(GREEN)Rendering helm templates (real validation mode)...$(NC)" + helm template $(RELEASE_NAME) $(CHART_DIR) \ + --namespace $(NAMESPACE) \ + --set validation.useDummy=false \ + --set broker.type=googlepubsub \ + --set broker.googlepubsub.projectId=my-project \ + --set broker.googlepubsub.topic=my-topic \ + --set broker.googlepubsub.subscription=my-subscription \ + --set broker.googlepubsub.deadLetterTopic=my-dlq \ + --set broker.subscriber.parallelism=20 \ + --set hyperfleetApi.baseUrl=https://api.hyperfleet.example.com \ + --set rbac.create=true helm-template-full: helm-template-dummy ## Alias for helm-template-dummy (full dummy configuration) @@ -92,7 +91,7 @@ helm-dry-run: ## Simulate helm install (requires cluster connection) --create-namespace \ --dry-run \ --debug \ - --set deploymentMode=dummy \ + --set validation.useDummy=true \ --set broker.type=googlepubsub \ --set broker.googlepubsub.projectId=test-project \ --set broker.googlepubsub.topic=test-topic \ diff --git a/README.md b/README.md index 12e2f9e..d9d9a55 100644 --- a/README.md +++ b/README.md @@ -20,10 +20,19 @@ Event-driven adapter for HyperFleet GCP cluster validation. Validates GCP cluste ## Deployment Modes -This adapter supports two deployment modes via the `deploymentMode` parameter: +This adapter supports two deployment modes via the `validation.useDummy` parameter: -### Dummy Mode (Current) -- **Value**: `deploymentMode: "dummy"` +### Real Mode (Default, Production) +- **Value**: `validation.useDummy: false` (default) +- **Description**: Performs actual GCP validation checks +- **Config File**: Uses `charts/configs/validation-adapter.yaml` +- **Features**: + - Real GCP API validation + - Production-ready validation checks + - Comprehensive error reporting + +### Dummy Mode (Testing/Development) +- **Value**: `validation.useDummy: true` - **Description**: Simulates GCP validation for testing and development - **Config File**: Uses `charts/configs/validation-dummy-adapter.yaml` - **Features**: @@ -31,15 +40,6 @@ This adapter supports two deployment modes via the `deploymentMode` parameter: - No actual GCP API calls - Fast validation cycles for testing -### Real Mode (Future) -- **Value**: `deploymentMode: "real"` -- **Description**: Performs actual GCP validation checks -- **Config File**: Will use `charts/configs/validation-gcp-adapter.yaml` (to be created) -- **Features**: - - Real GCP API validation - - Production-ready validation checks - - Comprehensive error reporting - ## Local Development Run the adapter locally for development and testing. @@ -115,7 +115,7 @@ BROKER_TYPE=rabbitmq ./run-local.sh ### Installing the Chart -**Dummy Validation Mode (Default):** +**Real Validation Mode (Default, Production):** ```bash helm install validation-gcp ./charts/ \ @@ -129,20 +129,22 @@ helm install validation-gcp ./charts/ \ **With Specific Deployment Mode:** ```bash -# Dummy mode (simulated validation) +# Dummy mode (simulated validation for testing) helm install validation-gcp ./charts/ \ - --set deploymentMode=dummy \ + --set validation.useDummy=true \ --set validation.dummy.simulateResult=success \ --set broker.type=googlepubsub \ --set broker.googlepubsub.projectId=my-gcp-project \ --set broker.googlepubsub.topic=my-topic \ --set broker.googlepubsub.subscription=my-subscription -# Real mode (not yet available; keep commented until implemented — see HYPERFLEET-267) -# helm install validation-gcp ./charts/ \ -# --set deploymentMode=real \ -# --set broker.type=googlepubsub \ -# ... +# Real mode (production GCP validation - this is the default) +helm install validation-gcp ./charts/ \ + --set validation.useDummy=false \ + --set broker.type=googlepubsub \ + --set broker.googlepubsub.projectId=my-gcp-project \ + --set broker.googlepubsub.topic=my-topic \ + --set broker.googlepubsub.subscription=my-subscription ``` ### Install to a Specific Namespace @@ -170,11 +172,11 @@ helm delete validation-gcp --namespace hyperfleet-system All configurable parameters are in `values.yaml`. For advanced customization, modify the templates directly. -### Deployment Mode +### Validation Mode | Parameter | Description | Default | |-----------|-------------|---------| -| `deploymentMode` | Deployment mode: "dummy" or "real" | `"dummy"` | +| `validation.useDummy` | Use dummy mode for testing (true) or real validation (false) | `false` | ### Image & Replica @@ -259,17 +261,29 @@ When `rbac.create=true`, the adapter gets **minimal permissions** needed for val ### Validation Configuration +#### Common Settings (Both Modes) + | Parameter | Description | Default | |-----------|-------------|---------| -| `validation.statusReporterImage` | Status reporter sidecar image | `` | +| `validation.statusReporterImage` | Status reporter sidecar image | `registry.ci.openshift.org/ci/status-reporter:latest` | +| `validation.resultsPath` | Path where validation results are written | `"/results/adapter-result.json"` | +| `validation.maxWaitTimeSeconds` | Maximum time to wait for validation completion | `"300"` | -#### Dummy Validation Mode Settings +#### Dummy Mode Settings (when `validation.useDummy=true`) | Parameter | Description | Default | |-----------|-------------|---------| | `validation.dummy.simulateResult` | Simulated result (success, failure, hang, crash, invalid-json, missing-status) | `"success"` | -| `validation.dummy.resultsPath` | Path where validation results are written | `"/results/adapter-result.json"` | -| `validation.dummy.maxWaitTimeSeconds` | Maximum time to wait for validation completion | `"300"` | + +#### Real Mode Settings (when `validation.useDummy=false`) + +| Parameter | Description | Default | +|-----------|-------------|---------| +| `validation.real.gcpValidatorImage` | GCP validator container image | `registry.ci.openshift.org/ci/gcp-validator:latest` | +| `validation.real.disabledValidators` | Comma-separated list of validators to disable | `"quota-check"` | +| `validation.real.requiredApis` | Comma-separated list of required GCP APIs to validate | `"compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com"` | +| `validation.real.logLevel` | Log level for validation containers (debug, info, warn, error) | `"info"` | +| `validation.real.stopOnFirstFailure` | Stop execution on first failure. `true` = fail-fast, `false` = collect all results | `false` | ### Environment Variables @@ -291,11 +305,10 @@ env: ## Examples -### Basic Dummy Validation with Google Pub/Sub +### Basic Real Validation with Google Pub/Sub (Default) ```bash helm install validation-gcp ./charts/ \ - --set deploymentMode=dummy \ --set broker.type=googlepubsub \ --set broker.googlepubsub.projectId=my-gcp-project \ --set broker.googlepubsub.topic=my-topic \ @@ -308,6 +321,7 @@ helm install validation-gcp ./charts/ \ ```bash # Simulate failure helm install validation-gcp ./charts/ \ + --set validation.useDummy=true \ --set validation.dummy.simulateResult=failure \ --set broker.type=googlepubsub \ --set broker.googlepubsub.projectId=my-gcp-project \ @@ -316,8 +330,9 @@ helm install validation-gcp ./charts/ \ # Simulate hang (for timeout testing) helm install validation-gcp ./charts/ \ + --set validation.useDummy=true \ --set validation.dummy.simulateResult=hang \ - --set validation.dummy.maxWaitTimeSeconds=60 \ + --set validation.maxWaitTimeSeconds=60 \ --set broker.type=googlepubsub \ ... ``` @@ -326,7 +341,7 @@ helm install validation-gcp ./charts/ \ ```bash helm install validation-gcp ./charts/ \ - --set deploymentMode=dummy \ + --set validation.useDummy=true \ --set broker.type=rabbitmq \ --set broker.rabbitmq.url="amqp://user:password@rabbitmq.svc:5672/" ``` @@ -354,10 +369,10 @@ gcloud projects add-iam-policy-binding my-gcp-project \ Then deploy: ```bash +# Real validation mode (default) helm install validation-gcp ./charts/ \ --namespace hyperfleet-system \ --create-namespace \ - --set deploymentMode=dummy \ --set image.registry=us-central1-docker.pkg.dev/my-project/my-repo \ --set image.repository=hyperfleet-adapter \ --set image.tag=v0.1.0 \ @@ -375,8 +390,6 @@ helm install validation-gcp ./charts/ \ Example my-values.yaml ```yaml -deploymentMode: dummy - replicaCount: 1 image: @@ -409,11 +422,25 @@ broker: parallelism: 1 validation: - statusReporterImage: + # Use dummy mode for testing, false for production (default) + useDummy: false + + # Common settings + statusReporterImage: registry.ci.openshift.org/ci/status-reporter:latest + resultsPath: /results/adapter-result.json + maxWaitTimeSeconds: "300" + + # Dummy mode settings (only when useDummy=true) dummy: simulateResult: success - resultsPath: /results/adapter-result.json - maxWaitTimeSeconds: "300" + + # Real mode settings (only when useDummy=false) + real: + gcpValidatorImage: registry.ci.openshift.org/ci/gcp-validator:latest + disabledValidators: "quota-check" + requiredApis: "compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com" + logLevel: "info" + stopOnFirstFailure: false # true = fail-fast, false = collect all results ``` @@ -437,10 +464,15 @@ The deployment sets these environment variables automatically: | `BROKER_SUBSCRIPTION_ID` | From `broker.googlepubsub.subscription` | When `broker.type=googlepubsub` | | `BROKER_TOPIC` | From `broker.googlepubsub.topic` | When `broker.type=googlepubsub` | | `GCP_PROJECT_ID` | From `broker.googlepubsub.projectId` | When `broker.type=googlepubsub` | -| `STATUS_REPORTER_IMAGE` | From `validation.statusReporterImage` | When `deploymentMode=dummy` | -| `SIMULATE_RESULT` | From `validation.dummy.simulateResult` | When `deploymentMode=dummy` | -| `RESULTS_PATH` | From `validation.dummy.resultsPath` | When `deploymentMode=dummy` | -| `MAX_WAIT_TIME_SECONDS` | From `validation.dummy.maxWaitTimeSeconds` | When `deploymentMode=dummy` | +| `STATUS_REPORTER_IMAGE` | From `validation.statusReporterImage` | Always | +| `RESULTS_PATH` | From `validation.resultsPath` | Always | +| `MAX_WAIT_TIME_SECONDS` | From `validation.maxWaitTimeSeconds` | Always | +| `SIMULATE_RESULT` | From `validation.dummy.simulateResult` | When `validation.useDummy=true` | +| `GCP_VALIDATOR_IMAGE` | From `validation.real.gcpValidatorImage` | When `validation.useDummy=false` | +| `DISABLED_VALIDATORS` | From `validation.real.disabledValidators` | When `validation.useDummy=false` | +| `REQUIRED_APIS` | From `validation.real.requiredApis` | When `validation.useDummy=false` | +| `VALIDATOR_LOG_LEVEL` | From `validation.real.logLevel` | When `validation.useDummy=false` | +| `STOP_ON_FIRST_FAILURE` | From `validation.real.stopOnFirstFailure` | When `validation.useDummy=false` | ## License diff --git a/charts/configs/validation-adapter.yaml b/charts/configs/validation-adapter.yaml new file mode 100644 index 0000000..2f2b803 --- /dev/null +++ b/charts/configs/validation-adapter.yaml @@ -0,0 +1,319 @@ +# HyperFleet GCP Validation Adapter Configuration +# +# This adapter creates a validation job for GCP clusters to verify +# cluster readiness and configuration. +apiVersion: hyperfleet.redhat.com/v1alpha1 +kind: AdapterConfig +metadata: + name: validation-adapter + namespace: hyperfleet-system + labels: + hyperfleet.io/adapter-type: validation + hyperfleet.io/component: adapter + hyperfleet.io/provider: gcp +spec: + adapter: + version: "0.1.0" + # ============================================================================ + # HyperFleet API Configuration + # ============================================================================ + hyperfleetApi: + timeout: 2s + retryAttempts: 3 + # ============================================================================ + # Kubernetes Configuration + # ============================================================================ + kubernetes: + apiVersion: "batch/v1" + # ============================================================================ + # Parameters + # ============================================================================ + params: + - name: "hyperfleetApiBaseUrl" + source: "env.HYPERFLEET_API_BASE_URL" + type: "string" + description: "Base URL for the HyperFleet API" + required: true + - name: "hyperfleetApiVersion" + source: "env.HYPERFLEET_API_VERSION" + type: "string" + default: "v1" + description: "API version to use" + - name: "clusterId" + source: "event.id" + type: "string" + description: "Unique identifier for the target cluster" + required: true + - name: "statusReporterImage" + source: "env.STATUS_REPORTER_IMAGE" + type: "string" + default: "registry.ci.openshift.org/ci/status-reporter:latest" + description: "Container image for the status reporter" + # GCP Validator configuration + - name: "gcpValidatorImage" + source: "env.GCP_VALIDATOR_IMAGE" + type: "string" + default: "registry.ci.openshift.org/ci/gcp-validator:latest" + description: "GCP validator container image" + - name: "disabledValidators" + source: "env.DISABLED_VALIDATORS" + type: "string" + default: "quota-check" + description: "Comma-separated list of validators to disable" + - name: "requiredApis" + source: "env.REQUIRED_APIS" + type: "string" + default: "compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com" + description: "Comma-separated list of required GCP APIs to validate" + - name: "resultPath" + source: "env.RESULTS_PATH" + type: "string" + default: "/results/adapter-result.json" + description: "Adapter shared result path with status reporter" + - name: "maxWaitTimeSeconds" + source: "env.MAX_WAIT_TIME_SECONDS" + type: "string" + default: "300" + description: "Maximum time to wait for validation completion" + - name: "logLevel" + source: "env.VALIDATOR_LOG_LEVEL" + type: "string" + default: "info" + description: "Log level for validation containers (debug, info, warn, error)" + - name: "stopOnFirstFailure" + source: "env.STOP_ON_FIRST_FAILURE" + type: "string" + default: "false" + description: "Stop validation execution on first failure (true/false)" + - name: "adapterTaskServiceAccount" + source: "env.ADAPTER_TASK_SERVICE_ACCOUNT" + type: "string" + default: "validation-adapter-task-sa" + description: "Kubernetes ServiceAccount name for the adapter task" + - name: "managedByResourceName" + source: "env.MANAGED_BY_RESOURCE_NAME" + type: "string" + default: "validation-adapter" + description: "The value for hyperfleet.io/managed-by" + # ============================================================================ + # Preconditions + # ============================================================================ + # Preconditions run before resource operations to validate state + preconditions: + - name: "clusterStatus" + apiCall: + method: "GET" + url: "{{ .hyperfleetApiBaseUrl }}/api/hyperfleet/{{ .hyperfleetApiVersion }}/clusters/{{ .clusterId }}" + timeout: 10s + retryAttempts: 3 + retryBackoff: "exponential" + capture: + - name: "clusterName" + field: "name" + - name: "clusterPhase" + field: "status.phase" + - name: "generationId" + field: "generation" + # Customer GCP project ID + - name: "projectId" + field: "spec.platform.gcp.projectID" + conditions: + - field: "clusterPhase" + operator: "in" + values: ["NotReady", "Ready"] + # Ensure Customer GCP project ID is configured + - field: "projectId" + operator: "exists" + + - name: "clusterAdapterStatus" + apiCall: + method: "GET" + url: "{{ .hyperfleetApiBaseUrl }}/api/hyperfleet/{{ .hyperfleetApiVersion }}/clusters/{{ .clusterId }}/statuses" + timeout: 10s + retryAttempts: 3 + retryBackoff: "exponential" + capture: + - name: "clusterNamespaceStatus" + field: "{.items[?(@.adapter=='landing-zone-adapter')].data.namespace.status}" + conditions: + - field: "clusterNamespaceStatus" + operator: "equals" + values: "Active" + # ============================================================================ + # Resources + # ============================================================================ + resources: + # ========================================================================== + # Resource: ServiceAccount for Adapter Task + # ========================================================================== + - name: "adapterTaskServiceAccount" + manifest: + # ServiceAccount for the adapter task + apiVersion: v1 + kind: ServiceAccount + metadata: + name: "{{ .adapterTaskServiceAccount }}" + namespace: "{{ .clusterId | lower }}" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + hyperfleet.io/resource-type: "service-account" + annotations: + hyperfleet.io/generation: "{{ .generationId }}" + discovery: + bySelectors: + labelSelector: + hyperfleet.io/resource-type: "service-account" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + # ========================================================================== + # Resource: Role with necessary permissions for status reporter + # ========================================================================== + - name: "adapterTaskRole" + manifest: + # Role with necessary permissions for status reporter + apiVersion: rbac.authorization.k8s.io/v1 + kind: Role + metadata: + name: status-reporter + namespace: "{{ .clusterId | lower }}" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + hyperfleet.io/resource-type: "role" + annotations: + hyperfleet.io/generation: "{{ .generationId }}" + rules: + # Permission to get and update job status + - apiGroups: [ "batch" ] + resources: [ "jobs" ] + verbs: [ "get" ] + - apiGroups: [ "batch" ] + resources: [ "jobs/status" ] + verbs: [ "get", "update", "patch" ] + # Permission to get pod status + - apiGroups: [ "" ] + resources: [ "pods" ] + verbs: [ "get", "list" ] + discovery: + bySelectors: + labelSelector: + hyperfleet.io/resource-type: "role" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + # ========================================================================== + # Rolebinding to grant permissions to the service account + # ========================================================================== + - name: "adapterTaskRoleBinding" + manifest: + # RoleBinding to grant permissions to the service account + apiVersion: rbac.authorization.k8s.io/v1 + kind: RoleBinding + metadata: + name: status-reporter + namespace: "{{ .clusterId | lower }}" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + hyperfleet.io/resource-type: "role-binding" + annotations: + hyperfleet.io/generation: "{{ .generationId }}" + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: status-reporter + subjects: + - kind: ServiceAccount + name: "{{ .adapterTaskServiceAccount }}" + namespace: "{{ .clusterId | lower }}" + discovery: + bySelectors: + labelSelector: + hyperfleet.io/resource-type: "role-binding" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + # ========================================================================== + # Resource: GCP Validation Job + # ========================================================================== + - name: "gcpValidationJob" + manifest: + ref: "./validation-job-adapter-task.yaml" + discovery: + bySelectors: + labelSelector: + hyperfleet.io/resource-type: "validation-job" + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + # ============================================================================ + # Post-Processing + # ============================================================================ + post: + payloads: + # Build status payload inline + - name: "clusterStatusPayload" + build: + adapter: "{{ .metadata.name }}" + conditions: + # Applied: Job successfully created + - type: "Applied" + status: + expression: | + has(resources.gcpValidationJob) ? "True" : "False" + reason: + expression: | + has(resources.gcpValidationJob) + ? "JobApplied" + : "JobPending" + message: + expression: | + has(resources.gcpValidationJob) + ? "Validation job applied successfully" + : "Validation job is pending to applied" + # Available: Check job status conditions + - type: "Available" + status: + expression: | + resources.?gcpValidationJob.?status.?conditions.orValue([]).exists(c, c.type == "Available") + ? resources.gcpValidationJob.status.conditions.filter(c, c.type == "Available")[0].status : "False" + reason: + expression: | + resources.?gcpValidationJob.?status.?conditions.orValue([]).exists(c, c.type == "Available") + ? resources.gcpValidationJob.status.conditions.filter(c, c.type == "Available")[0].reason + : resources.?gcpValidationJob.?status.?conditions.orValue([]).exists(c, c.type == "Failed") ? "ValidationFailed" + : resources.?gcpValidationJob.?status.hasValue() ? "ValidationInProgress" : "ValidationPending" + message: + expression: | + resources.?gcpValidationJob.?status.?conditions.orValue([]).exists(c, c.type == "Available") + ? resources.gcpValidationJob.status.conditions.filter(c, c.type == "Available")[0].message + : resources.?gcpValidationJob.?status.?conditions.orValue([]).exists(c, c.type == "Failed") ? "Validation failed" + : resources.?gcpValidationJob.?status.hasValue() ? "Validation in progress" : "Validation is pending" + # Health: Adapter execution status (runtime) + - type: "Health" + status: + expression: | + adapter.?executionStatus.orValue("") == "success" ? "True" : (adapter.?executionStatus.orValue("") == "failed" ? "False" : "Unknown") + reason: + expression: | + adapter.?errorReason.orValue("") != "" ? adapter.?errorReason.orValue("") : "Healthy" + message: + expression: | + adapter.?errorMessage.orValue("") != "" ? adapter.?errorMessage.orValue("") : "All adapter operations completed successfully" + # Event generation ID metadata field needs to use expression to avoid interpolation issues + observed_generation: + expression: "generationId" + observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}" + # ============================================================================ + # Post Actions + # ============================================================================ + postActions: + - name: "reportClusterStatus" + apiCall: + method: "POST" + url: "{{ .hyperfleetApiBaseUrl }}/api/hyperfleet/{{ .hyperfleetApiVersion }}/clusters/{{ .clusterId }}/statuses" + body: "{{ .clusterStatusPayload }}" + timeout: 30s + retryAttempts: 3 + retryBackoff: "exponential" + headers: + - name: "Content-Type" + value: "application/json" diff --git a/charts/configs/validation-dummy-adapter.yaml b/charts/configs/validation-dummy-adapter.yaml index a78b2cb..dd40959 100644 --- a/charts/configs/validation-dummy-adapter.yaml +++ b/charts/configs/validation-dummy-adapter.yaml @@ -47,7 +47,7 @@ spec: - name: "statusReporterImage" source: "env.STATUS_REPORTER_IMAGE" type: "string" - default: "quay.io/rh-ee-dawang/status-reporter:dev-04e8d0a" + default: "registry.ci.openshift.org/ci/status-reporter:latest" description: "Container image for the status reporter" - name: "simulateResult" source: "env.SIMULATE_RESULT" @@ -64,21 +64,16 @@ spec: type: "string" default: "300" description: "Maximum time to wait for validation completion" - - name: "gcpValidatorServiceAccount" - source: "env.GCP_VALIDATOR_SERVICE_ACCOUNT" + - name: "adapterTaskServiceAccount" + source: "env.ADAPTER_TASK_SERVICE_ACCOUNT" type: "string" - default: "gcp-validator-job-sa" - description: "Maximum time to wait for validation completion" + default: "validation-adapter-task-sa" + description: "Kubernetes ServiceAccount name for the adapter task" - name: "managedByResourceName" - source: "env.MANAGED_By_RESOURCE_NAME" + source: "env.MANAGED_BY_RESOURCE_NAME" type: "string" default: "dummy-validation-adapter" description: "The value for hyperfleet.io/managed-by" - - name: "createdByResourceName" - source: "env.CREATED_BY_RESOURCE_NAME" - type: "string" - default: "hyperfleet-adapter" - description: "The value for hyperfleet.io/created-by:" # ============================================================================ # Preconditions # ============================================================================ @@ -122,22 +117,21 @@ spec: # ============================================================================ resources: # ========================================================================== - # Resource: ServiceAccount for GCP Validation Job + # Resource: ServiceAccount for Adapter Task # ========================================================================== - - name: "gcpValidationServiceAccount" + - name: "adapterTaskServiceAccount" manifest: - # ServiceAccount for the validator job + # ServiceAccount for the adapter task apiVersion: v1 kind: ServiceAccount metadata: - name: "{{ .gcpValidatorServiceAccount }}" + name: "{{ .adapterTaskServiceAccount }}" namespace: "{{ .clusterId | lower }}" labels: hyperfleet.io/cluster-id: "{{ .clusterId }}" hyperfleet.io/managed-by: "{{ .managedByResourceName }}" hyperfleet.io/resource-type: "service-account" annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" discovery: bySelectors: @@ -148,7 +142,7 @@ spec: # ========================================================================== # Resource: Role with necessary permissions for status reporter # ========================================================================== - - name: "gcpValidationRole" + - name: "adapterTaskRole" manifest: # Role with necessary permissions for status reporter apiVersion: rbac.authorization.k8s.io/v1 @@ -161,7 +155,6 @@ spec: hyperfleet.io/managed-by: "{{ .managedByResourceName }}" hyperfleet.io/resource-type: "role" annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" rules: # Permission to get and update job status @@ -184,7 +177,7 @@ spec: # ========================================================================== # Rolebinding to grant permissions to the service account # ========================================================================== - - name: "gcpValidationRoleBinding" + - name: "adapterTaskRoleBinding" manifest: # RoleBinding to grant permissions to the service account apiVersion: rbac.authorization.k8s.io/v1 @@ -197,7 +190,6 @@ spec: hyperfleet.io/managed-by: "{{ .managedByResourceName }}" hyperfleet.io/resource-type: "role-binding" annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" roleRef: apiGroup: rbac.authorization.k8s.io @@ -205,7 +197,7 @@ spec: name: status-reporter subjects: - kind: ServiceAccount - name: "{{ .gcpValidatorServiceAccount }}" + name: "{{ .adapterTaskServiceAccount }}" namespace: "{{ .clusterId | lower }}" discovery: bySelectors: diff --git a/charts/configs/validation-dummy-job-adapter-task.yaml b/charts/configs/validation-dummy-job-adapter-task.yaml index 86f8bfc..d71a029 100644 --- a/charts/configs/validation-dummy-job-adapter-task.yaml +++ b/charts/configs/validation-dummy-job-adapter-task.yaml @@ -9,7 +9,6 @@ metadata: hyperfleet.io/resource-type: "validation-job" app: gcp-validator annotations: - hyperfleet.io/created-by: "{{ .createdByResourceName }}" hyperfleet.io/generation: "{{ .generationId }}" spec: backoffLimit: 0 @@ -22,7 +21,7 @@ spec: hyperfleet.io/cluster-id: "{{ .clusterId }}" spec: # Created before the job is created, as specified in the adapter configuration. - serviceAccountName: "{{ .gcpValidatorServiceAccount }}" + serviceAccountName: "{{ .adapterTaskServiceAccount }}" restartPolicy: Never volumes: - name: results diff --git a/charts/configs/validation-job-adapter-task.yaml b/charts/configs/validation-job-adapter-task.yaml new file mode 100644 index 0000000..0569847 --- /dev/null +++ b/charts/configs/validation-job-adapter-task.yaml @@ -0,0 +1,107 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: "gcp-validator-{{ .clusterId | lower }}-{{ .generationId }}" + namespace: "{{ .clusterId | lower }}" + labels: + hyperfleet.io/cluster-id: "{{ .clusterId }}" + hyperfleet.io/managed-by: "{{ .managedByResourceName }}" + hyperfleet.io/resource-type: "validation-job" + app: gcp-validator + annotations: + hyperfleet.io/generation: "{{ .generationId }}" +spec: + backoffLimit: 0 + # [TODO] This will be passed via parameter once adapter configuration support int parameter. + activeDeadlineSeconds: 310 # maxWaitTimeSeconds + 10 second buffers, maximum time to wait for k8s job completion" + template: + metadata: + labels: + app: gcp-validator + hyperfleet.io/cluster-id: "{{ .clusterId }}" + spec: + # Created before the job is created, as specified in the adapter configuration. + serviceAccountName: "{{ .adapterTaskServiceAccount }}" + restartPolicy: Never + volumes: + - name: results + emptyDir: { } + containers: + # GCP Validator Container + - name: gcp-validator + image: "{{ .gcpValidatorImage }}" + imagePullPolicy: Always + env: + # Required + - name: PROJECT_ID + value: "{{ .projectId }}" + - name: RESULTS_PATH + value: "{{ .resultPath }}" + + # Validator control + - name: DISABLED_VALIDATORS + value: "{{ .disabledValidators }}" + - name: STOP_ON_FIRST_FAILURE + value: "{{ .stopOnFirstFailure }}" + + # API Validator config + - name: REQUIRED_APIS + value: "{{ .requiredApis }}" + + # Logging + - name: LOG_LEVEL + value: "{{ .logLevel }}" + + volumeMounts: + - name: results + mountPath: /results + + resources: + requests: + memory: "128Mi" + cpu: "200m" + limits: + memory: "256Mi" + cpu: "500m" + + # Status reporter sidecar + - name: status-reporter + image: "{{ .statusReporterImage }}" + imagePullPolicy: Always + env: + # Required environment variables + - name: JOB_NAME + value: "gcp-validator-{{ .clusterId | lower }}-{{ .generationId }}" + - name: JOB_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + + # Optional configuration + - name: RESULTS_PATH + value: "{{ .resultPath }}" + - name: POLL_INTERVAL_SECONDS + value: "2" + - name: MAX_WAIT_TIME_SECONDS + value: "{{ .maxWaitTimeSeconds }}" + - name: CONDITION_TYPE + value: "Available" + - name: LOG_LEVEL + value: "{{ .logLevel }}" + - name: ADAPTER_CONTAINER_NAME + value: "gcp-validator" + + volumeMounts: + - name: results + mountPath: /results + resources: + requests: + memory: "64Mi" + cpu: "100m" + limits: + memory: "128Mi" + cpu: "200m" diff --git a/charts/templates/_helpers.tpl b/charts/templates/_helpers.tpl index 56bf5f8..1c887e7 100644 --- a/charts/templates/_helpers.tpl +++ b/charts/templates/_helpers.tpl @@ -79,11 +79,9 @@ Create the name of the broker ConfigMap to use Get the adapter config file name based on deployment mode */}} {{- define "validation-gcp.adapterConfigFile" -}} -{{- if eq .Values.deploymentMode "dummy" }} +{{- if .Values.validation.useDummy }} {{- "validation-dummy-adapter.yaml" }} -{{- else if eq .Values.deploymentMode "real" }} -{{- "validation-gcp-adapter.yaml" }} {{- else }} -{{- fail "deploymentMode must be either 'dummy' or 'real'" }} +{{- "validation-adapter.yaml" }} {{- end }} {{- end }} diff --git a/charts/templates/configmap-app.yaml b/charts/templates/configmap-app.yaml index 173e280..d215f6e 100644 --- a/charts/templates/configmap-app.yaml +++ b/charts/templates/configmap-app.yaml @@ -7,12 +7,16 @@ metadata: app.kubernetes.io/component: adapter data: # Adapter configuration file - # Dynamically loaded based on deploymentMode - # Edit charts/configs/validation-dummy-adapter.yaml or validation-gcp-adapter.yaml to customize + # Dynamically loaded based on validation.useDummy + # Edit charts/configs/validation-dummy-adapter.yaml or validation-adapter.yaml to customize adapter.yaml: | {{ .Files.Get (printf "configs/%s" (include "validation-gcp.adapterConfigFile" .)) | nindent 4 }} - {{- if eq .Values.deploymentMode "dummy" }} - # Adapter task template referenced by the adapter config + {{- if .Values.validation.useDummy }} + # Adapter task template referenced by the adapter config (dummy mode) validation-dummy-job-adapter-task.yaml: | {{ .Files.Get "configs/validation-dummy-job-adapter-task.yaml" | nindent 4 }} + {{- else }} + # Adapter task template referenced by the adapter config (real validation) + validation-job-adapter-task.yaml: | +{{ .Files.Get "configs/validation-job-adapter-task.yaml" | nindent 4 }} {{- end }} diff --git a/charts/templates/deployment.yaml b/charts/templates/deployment.yaml index 17d256c..7d9884a 100644 --- a/charts/templates/deployment.yaml +++ b/charts/templates/deployment.yaml @@ -80,16 +80,29 @@ spec: value: {{ .Values.broker.googlepubsub.projectId | quote }} {{- end }} {{- end }} - {{- if eq .Values.deploymentMode "dummy" }} - # Dummy validation specific environment variables + # Common validation environment variables - name: STATUS_REPORTER_IMAGE value: {{ .Values.validation.statusReporterImage | quote }} - - name: SIMULATE_RESULT - value: {{ .Values.validation.dummy.simulateResult | quote }} - name: RESULTS_PATH - value: {{ .Values.validation.dummy.resultsPath | quote }} + value: {{ .Values.validation.resultsPath | quote }} - name: MAX_WAIT_TIME_SECONDS - value: {{ .Values.validation.dummy.maxWaitTimeSeconds | quote }} + value: {{ .Values.validation.maxWaitTimeSeconds | quote }} + {{- if .Values.validation.useDummy }} + # Dummy validation specific environment variables + - name: SIMULATE_RESULT + value: {{ .Values.validation.dummy.simulateResult | quote }} + {{- else }} + # Real GCP validation specific environment variables + - name: GCP_VALIDATOR_IMAGE + value: {{ .Values.validation.real.gcpValidatorImage | quote }} + - name: DISABLED_VALIDATORS + value: {{ .Values.validation.real.disabledValidators | quote }} + - name: REQUIRED_APIS + value: {{ .Values.validation.real.requiredApis | quote }} + - name: VALIDATOR_LOG_LEVEL + value: {{ .Values.validation.real.logLevel | default "info" | quote }} + - name: STOP_ON_FIRST_FAILURE + value: {{ .Values.validation.real.stopOnFirstFailure | quote }} {{- end }} resources: limits: diff --git a/charts/values.yaml b/charts/values.yaml index 92b989e..5fc1a83 100644 --- a/charts/values.yaml +++ b/charts/values.yaml @@ -3,10 +3,6 @@ # Only environment-specific settings are exposed here. # For advanced customization, modify the templates directly. -# Deployment mode: "dummy" for dummy-gcp-validation, "real" for gcp-validation -# Currently only "dummy" is supported -deploymentMode: "dummy" # "dummy" or "real" - replicaCount: 1 image: @@ -72,19 +68,38 @@ hyperfleetApi: baseUrl: "" version: "v1" -# Validation-specific configuration (only for dummy mode) +# Validation-specific configuration validation: - # Status reporter image (sidecar container) - statusReporterImage: "quay.io/rh-ee-dawang/status-reporter:dev-04e8d0a" + # Mode selection: false = real validation (default), true = dummy/test mode + useDummy: false + + # Common configuration (used by both modes) + statusReporterImage: "registry.ci.openshift.org/ci/status-reporter:latest" + resultsPath: "/results/adapter-result.json" + maxWaitTimeSeconds: "300" - # Dummy validation simulation settings + # Dummy validation simulation settings (only when useDummy: true) dummy: # Simulated result: success, failure, hang, crash, invalid-json, missing-status simulateResult: "success" - # Path where validation results are written - resultsPath: "/results/adapter-result.json" - # Maximum time to wait for validation completion (seconds) - maxWaitTimeSeconds: "300" + + # Real GCP validator settings (only when useDummy: false) + real: + # GCP validator container image + gcpValidatorImage: "registry.ci.openshift.org/ci/gcp-validator:latest" + # Comma-separated list of validators to disable (default: all enabled) + # Note: quota-check is a stub validator (not yet implemented, returns success) + disabledValidators: "quota-check" + # Comma-separated list of required GCP APIs to validate (default: empty) + # Example: "compute.googleapis.com,storage-api.googleapis.com" + requiredApis: "compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com" + # Log level for validation containers (sets VALIDATOR_LOG_LEVEL env var) + # Options: debug, info, warn, error + logLevel: "info" + # Stop validation execution on first failure (default: false) + # When true: stops immediately after any validator fails (fail-fast) + # When false: continues executing all validators even after failures (collect all results) + stopOnFirstFailure: false # Additional environment variables env: [] diff --git a/dummy-validator/README.md b/dummy-validator/README.md index 12e47b5..46b1195 100644 --- a/dummy-validator/README.md +++ b/dummy-validator/README.md @@ -52,7 +52,7 @@ The validator supports the following simulation scenarios via the `SIMULATE_RESU # Replace placeholders and apply sed -e 's||success|g' \ -e 's||your-namespace|g' \ - -e 's||quay.io/rh-ee-dawang/status-reporter:dev-04e8d0a|g' \ + -e 's||registry.ci.openshift.org/ci/status-reporter:latest|g' \ job-template.yaml | kubectl apply -f - ``` @@ -77,7 +77,7 @@ The `job-template.yaml` file includes the following placeholders that should be |-------------|-------------|----------------| | `` | Your Kubernetes namespace | `default`, `validation-testing` | | `` | The test scenario to run | `success`, `failure`, `hang`, `crash`, `invalid-json`, `missing-status` | -| `` | The status-reporter container image | `quay.io/rh-ee-dawang/status-reporter:dev-04e8d0a` | +| `` | The status-reporter container image | `registry.ci.openshift.org/ci/status-reporter:latest` | The `` placeholder is used in multiple places: - Job name: `dummy-validator-` diff --git a/validator/Dockerfile b/validator/Dockerfile new file mode 100644 index 0000000..11dce5c --- /dev/null +++ b/validator/Dockerfile @@ -0,0 +1,41 @@ +ARG BASE_IMAGE=gcr.io/distroless/static-debian12:nonroot + +# Build stage +FROM golang:1.25-alpine AS builder + +# Build arguments passed from build machine +ARG VERSION=0.0.1 +ARG GIT_COMMIT=unknown + +# Install build dependencies +RUN apk add --no-cache make + +WORKDIR /build + +# Copy source code +COPY . . + +# Tidy and verify Go module dependencies +RUN go mod tidy && go mod verify + +# Build binary using make to include version, commit, and build date +RUN make binary VERSION=${VERSION} GIT_COMMIT=${GIT_COMMIT} + +# Runtime stage +FROM ${BASE_IMAGE} + +# Build arguments for labels (must be redeclared after FROM) +ARG VERSION=0.0.1 + +WORKDIR /app + +# Copy binary from builder (make binary outputs to bin/) +COPY --from=builder /build/bin/validator /app/validator + +ENTRYPOINT ["/app/validator"] + +LABEL name="gcp-validator" \ + vendor="Red Hat" \ + version="${VERSION}" \ + summary="GCP Validator - Pre-provisioning validation for GCP clusters" \ + description="Validates GCP prerequisites before cluster provisioning, including API enablement and quota checks" diff --git a/validator/Makefile b/validator/Makefile new file mode 100644 index 0000000..2c1d154 --- /dev/null +++ b/validator/Makefile @@ -0,0 +1,215 @@ +# Makefile for GCP Validator + +# Project metadata +PROJECT_NAME := gcp-validator +VERSION ?= 0.0.1 +IMAGE_REGISTRY ?= quay.io/rh-ee-dawang +IMAGE_TAG ?= latest + +# Build metadata +GIT_COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") +GIT_TAG := $(shell git describe --tags --exact-match 2>/dev/null || echo "") + +# Dev image configuration - set QUAY_USER to push to personal registry +# Usage: QUAY_USER=myuser make image-dev +QUAY_USER ?= +DEV_TAG ?= dev-$(GIT_COMMIT) +BUILD_DATE := $(shell date -u +'%Y-%m-%dT%H:%M:%SZ') + +# LDFLAGS for build +# Note: Variables are in package main, so use main.varName (not full import path) +LDFLAGS := -w -s +LDFLAGS += -X main.version=$(VERSION) +LDFLAGS += -X main.commit=$(GIT_COMMIT) +LDFLAGS += -X main.buildDate=$(BUILD_DATE) +ifneq ($(GIT_TAG),) +LDFLAGS += -X main.tag=$(GIT_TAG) +endif + +# Go parameters +GOCMD := go +GOBUILD := $(GOCMD) build +GOTEST := $(GOCMD) test +GOMOD := $(GOCMD) mod +GOFMT := gofmt +GOIMPORTS := goimports + +# Test parameters +TEST_TIMEOUT := 10m +RACE_FLAG := -race +COVERAGE_OUT := coverage.out +COVERAGE_HTML := coverage.html + +# Container runtime detection +DOCKER_AVAILABLE := $(shell if docker info >/dev/null 2>&1; then echo "true"; else echo "false"; fi) +PODMAN_AVAILABLE := $(shell if podman info >/dev/null 2>&1; then echo "true"; else echo "false"; fi) + +ifeq ($(DOCKER_AVAILABLE),true) + CONTAINER_RUNTIME := docker + CONTAINER_CMD := docker +else ifeq ($(PODMAN_AVAILABLE),true) + CONTAINER_RUNTIME := podman + CONTAINER_CMD := podman +else + CONTAINER_RUNTIME := none + CONTAINER_CMD := sh -c 'echo "No container runtime found. Please install Docker or Podman." && exit 1' +endif + +# Install directory (defaults to $GOPATH/bin or $HOME/go/bin) +GOPATH ?= $(shell $(GOCMD) env GOPATH) +BINDIR ?= $(GOPATH)/bin + +# Directories +# Find all Go packages, excluding vendor and test directories +PKG_DIRS := $(shell $(GOCMD) list ./... 2>/dev/null | grep -v /vendor/ | grep -v /test/) + +.PHONY: help +help: ## Display this help message + @echo "Available targets:" + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +.PHONY: test +test: ## Run unit tests with race detection + @echo "Running unit tests..." + $(GOTEST) -v $(RACE_FLAG) -timeout $(TEST_TIMEOUT) $(PKG_DIRS) + +.PHONY: test-coverage +test-coverage: ## Run unit tests with coverage report + @echo "Running unit tests with coverage..." + $(GOTEST) -v $(RACE_FLAG) -timeout $(TEST_TIMEOUT) -coverprofile=$(COVERAGE_OUT) -covermode=atomic $(PKG_DIRS) + @echo "Coverage report generated: $(COVERAGE_OUT)" + @echo "To view HTML coverage report, run: make test-coverage-html" + +.PHONY: test-coverage-html +test-coverage-html: test-coverage ## Generate HTML coverage report + @echo "Generating HTML coverage report..." + $(GOCMD) tool cover -html=$(COVERAGE_OUT) -o $(COVERAGE_HTML) + @echo "HTML coverage report generated: $(COVERAGE_HTML)" + +.PHONY: test-integration +test-integration: ## Run integration tests (requires GCP credentials and PROJECT_ID) + @echo "Running integration tests..." + @if [ -z "$$PROJECT_ID" ]; then \ + echo "❌ ERROR: PROJECT_ID environment variable is not set"; \ + echo ""; \ + echo "Integration tests require a real GCP project."; \ + echo "Please set PROJECT_ID:"; \ + echo " export PROJECT_ID=your-gcp-project-id"; \ + echo ""; \ + echo "You also need valid GCP credentials:"; \ + echo " gcloud auth application-default login"; \ + echo " OR"; \ + echo " export GOOGLE_APPLICATION_CREDENTIALS=/path/to/key.json"; \ + echo ""; \ + exit 1; \ + fi + @if [ -d "test/integration" ] && [ -n "$$(find test/integration -name '*_test.go' 2>/dev/null)" ]; then \ + echo "PROJECT_ID: $$PROJECT_ID"; \ + echo "Running tests with -tags=integration..."; \ + $(GOTEST) -tags=integration -v $(RACE_FLAG) -timeout $(TEST_TIMEOUT) ./test/integration/...; \ + else \ + echo "No integration tests found in test/integration directory"; \ + echo "Please add integration tests to test/integration directory"; \ + exit 0; \ + fi + +.PHONY: lint +lint: ## Run golangci-lint + @echo "Running golangci-lint..." + @if command -v golangci-lint > /dev/null; then \ + golangci-lint cache clean && golangci-lint run; \ + else \ + echo "Error: golangci-lint not found. Please install it:"; \ + echo " go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \ + exit 1; \ + fi + +.PHONY: fmt +fmt: ## Format code with gofmt and goimports + @echo "Formatting code..." + @if command -v $(GOIMPORTS) > /dev/null; then \ + $(GOIMPORTS) -w .; \ + else \ + $(GOFMT) -w .; \ + fi + +.PHONY: mod-tidy +mod-tidy: ## Tidy Go module dependencies + @echo "Tidying Go modules..." + $(GOMOD) tidy + $(GOMOD) verify + +.PHONY: binary +binary: ## Build binary + @echo "Building $(PROJECT_NAME)..." + @echo "Version: $(VERSION), Commit: $(GIT_COMMIT), BuildDate: $(BUILD_DATE)" + @mkdir -p bin + CGO_ENABLED=0 $(GOBUILD) -ldflags="$(LDFLAGS)" -o bin/validator ./cmd/validator + +.PHONY: build +build: binary ## Alias for 'binary' + +.PHONY: install +install: binary ## Install binary to BINDIR (default: $GOPATH/bin) + @echo "Installing $(PROJECT_NAME) to $(BINDIR)..." + @mkdir -p $(BINDIR) + cp bin/validator $(BINDIR)/validator + @echo "✅ Installed: $(BINDIR)/validator" + +.PHONY: clean +clean: ## Clean build artifacts and test coverage files + @echo "Cleaning..." + rm -rf bin/ + rm -f $(COVERAGE_OUT) $(COVERAGE_HTML) + +.PHONY: image +image: ## Build container image with Docker or Podman +ifeq ($(CONTAINER_RUNTIME),none) + @echo "❌ ERROR: No container runtime found" + @echo "Please install Docker or Podman" + @exit 1 +else + @echo "Building container image with $(CONTAINER_RUNTIME)..." + $(CONTAINER_CMD) build --platform linux/amd64 --no-cache --build-arg VERSION=$(VERSION) --build-arg GIT_COMMIT=$(GIT_COMMIT) -t $(IMAGE_REGISTRY)/$(PROJECT_NAME):$(IMAGE_TAG) . + @echo "✅ Image built: $(IMAGE_REGISTRY)/$(PROJECT_NAME):$(IMAGE_TAG)" +endif + +.PHONY: image-push +image-push: image ## Build and push container image to registry +ifeq ($(CONTAINER_RUNTIME),none) + @echo "❌ ERROR: No container runtime found" + @echo "Please install Docker or Podman" + @exit 1 +else + @echo "Pushing image $(IMAGE_REGISTRY)/$(PROJECT_NAME):$(IMAGE_TAG)..." + $(CONTAINER_CMD) push $(IMAGE_REGISTRY)/$(PROJECT_NAME):$(IMAGE_TAG) + @echo "✅ Image pushed: $(IMAGE_REGISTRY)/$(PROJECT_NAME):$(IMAGE_TAG)" +endif + +.PHONY: image-dev +image-dev: ## Build and push to personal Quay registry (requires QUAY_USER) +ifndef QUAY_USER + @echo "❌ ERROR: QUAY_USER is not set" + @echo "" + @echo "Usage: QUAY_USER=myuser make image-dev" + @echo "" + @echo "This will build and push to: quay.io/$$QUAY_USER/$(PROJECT_NAME):$(DEV_TAG)" + @exit 1 +endif +ifeq ($(CONTAINER_RUNTIME),none) + @echo "❌ ERROR: No container runtime found" + @echo "Please install Docker or Podman" + @exit 1 +else + @echo "Building dev image quay.io/$(QUAY_USER)/$(PROJECT_NAME):$(DEV_TAG)..." + $(CONTAINER_CMD) build --platform linux/amd64 --build-arg BASE_IMAGE=alpine:3.21 --build-arg VERSION=$(VERSION) --build-arg GIT_COMMIT=$(GIT_COMMIT) -t quay.io/$(QUAY_USER)/$(PROJECT_NAME):$(DEV_TAG) . + @echo "Pushing dev image..." + $(CONTAINER_CMD) push quay.io/$(QUAY_USER)/$(PROJECT_NAME):$(DEV_TAG) + @echo "" + @echo "✅ Dev image pushed: quay.io/$(QUAY_USER)/$(PROJECT_NAME):$(DEV_TAG)" +endif + +.PHONY: verify +verify: lint test ## Run all verification checks (lint + test) + +.DEFAULT_GOAL := help diff --git a/validator/README.md b/validator/README.md new file mode 100644 index 0000000..653efa8 --- /dev/null +++ b/validator/README.md @@ -0,0 +1,176 @@ +# GCP Validator + +Extensible Go-based validation framework for GCP prerequisites before cluster provisioning. + +## Features + +- **Parallel Execution**: Validators run concurrently when dependencies allow +- **Dependency Management**: DAG-based scheduling with automatic cycle detection +- **Auto-discovery**: Validators self-register via `init()` +- **WIF Authentication**: Workload Identity Federation for secure GCP access + +## Current Validators + +1. **api-enabled**: Verifies required GCP APIs are enabled +2. **quota-check**: Placeholder stub for future quota validation + +## Quick Start + +### Build + +```bash +make build # Build binary +make test # Run tests +make image # Build container image +``` + +### Run Locally + +```bash +export PROJECT_ID=my-gcp-project +export RESULTS_PATH=/tmp/results.json + +./bin/validator +cat /tmp/results.json +``` + +### Run in Docker + +```bash +docker run --rm \ + -e PROJECT_ID=my-project \ + -v /tmp/results:/results \ + gcp-validator +``` + +## Configuration + +### Required +- `PROJECT_ID` - GCP project ID to validate + +### Optional +- `RESULTS_PATH` - Output file path (default: `/results/adapter-result.json`) +- `DISABLED_VALIDATORS` - Comma-separated list to disable (e.g., `quota-check`). Note: At least one validator must remain enabled. +- `STOP_ON_FIRST_FAILURE` - Stop on first failure (default: `false`) +- `REQUIRED_APIS` - APIs to check (default: `compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com`) +- `LOG_LEVEL` - Log level: `debug`, `info`, `warn`, `error` (default: `info`) + +## Output Format + +### Success +```json +{ + "status": "success", + "reason": "ValidationPassed", + "message": "All GCP validation checks passed successfully", + "details": { + "checks_run": 1, + "checks_passed": 1, + "timestamp": "2026-01-15T10:30:00Z", + "validators": [ + { + "validator_name": "api-enabled", + "status": "success", + "reason": "AllAPIsEnabled", + "message": "All 3 required APIs are enabled", + "duration_ns": 234000000, + "timestamp": "2026-01-15T10:30:00Z" + } + ] + } +} +``` + +### Failure +```json +{ + "status": "failure", + "reason": "ValidationFailed", + "message": "1 validation check(s) failed: api-enabled (forbidden). Passed: 0/1", + "details": { + "checks_run": 1, + "checks_passed": 0, + "failed_checks": ["api-enabled"], + "timestamp": "2026-01-15T10:30:00Z", + "validators": [ + { + "validator_name": "api-enabled", + "status": "failure", + "reason": "forbidden", + "message": "Failed to check API compute.googleapis.com: ...", + "duration_ns": 123000000, + "timestamp": "2026-01-15T10:30:00Z" + } + ] + } +} +``` + +## Adding a New Validator + +Create a file in `pkg/validators/` implementing the `Validator` interface: + +```go +package validators + +import ( + "context" + "validator/pkg/validator" +) + +type MyValidator struct{} + +func init() { + validator.Register(&MyValidator{}) +} + +func (v *MyValidator) Metadata() validator.ValidatorMetadata { + return validator.ValidatorMetadata{ + Name: "my-validator", + Description: "Validates something important", + RunAfter: []string{"api-enabled"}, // Dependencies + Tags: []string{"custom"}, + } +} + +func (v *MyValidator) Enabled(ctx *validator.Context) bool { + return ctx.Config.IsValidatorEnabled("my-validator") +} + +func (v *MyValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { + // Validation logic here + return &validator.Result{ + Status: validator.StatusSuccess, + Reason: "CheckPassed", + Message: "Validation successful", + } +} +``` + +The validator is automatically discovered, ordered by dependencies, and executed in parallel. +- Register validator via `init()` +- Define dependency via `RunAfter` in `Metadata` + +## Testing + +```bash +make test # Run all tests +make lint # Run linter +``` + +Tests use Ginkgo/Gomega BDD framework. + +## Architecture + +### Execution Flow +1. Load configuration from environment variables +2. Discover and register all validators via `init()` +3. Build dependency graph (DAG) and detect cycles +4. Execute validators in parallel by dependency level +5. Aggregate results and write to output file + +### Security +- Uses GCP Application Default Credentials (ADC) +- Supports Workload Identity Federation in Kubernetes +- Minimal read-only scopes per service +- Each validator gets only the permissions it needs diff --git a/validator/cmd/validator/main.go b/validator/cmd/validator/main.go new file mode 100644 index 0000000..7687e67 --- /dev/null +++ b/validator/cmd/validator/main.go @@ -0,0 +1,135 @@ +package main + +import ( + "context" + "encoding/json" + "log/slog" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "validator/pkg/config" + "validator/pkg/validator" + _ "validator/pkg/validators" // Import to trigger init() registration +) + + +// main is the entry point for the GCP validator application. +// It loads configuration, executes all enabled validators, aggregates results, +// and writes the output to a JSON file. +func main() { + // Load configuration first to get log level + cfg, err := config.LoadFromEnv() + if err != nil { + slog.Error("Configuration error", "error", err) + os.Exit(1) + } + + // Set up structured logger based on log level + logLevel := parseLogLevel(cfg.LogLevel) + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: logLevel, + })) + slog.SetDefault(logger) + + logger.Info("Starting GCP Validator") + logger.Info("Loaded configuration", + "gcp_project", cfg.ProjectID, + "results_path", cfg.ResultsPath, + "log_level", cfg.LogLevel, + "max_wait_time_seconds", cfg.MaxWaitTimeSeconds) + + // Validate disabled validators against registry + if len(cfg.DisabledValidators) > 0 { + logger.Info("Disabled validators", "validators", cfg.DisabledValidators) + for _, name := range cfg.DisabledValidators { + if _, exists := validator.Get(name); !exists { + logger.Warn("Unknown validator in DISABLED_VALIDATORS - will be ignored", + "validator", name, + "hint", "Check for typos. Run without DISABLED_VALIDATORS to see available validators.") + } + } + } + + // Create validation context with lazy client initialization + // Services will only be created when validators actually need them (least privilege) + vctx := validator.NewContext(cfg, logger) + + // Create context with timeout (max time for all validators) + validationTimeout := time.Duration(cfg.MaxWaitTimeSeconds) * time.Second + ctx, cancel := context.WithTimeout(context.Background(), validationTimeout) + defer cancel() + + // Set up signal handling for graceful shutdown + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + go func() { + sig := <-sigCh + logger.Warn("Received shutdown signal, cancelling validation", "signal", sig) + cancel() + }() + + // Execute all validators + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + if err != nil { + logger.Error("Validator execution failed", "error", err) + os.Exit(1) + } + + // Aggregate results + aggregated := validator.Aggregate(results) + + // Write to output file + outputFile := cfg.ResultsPath + logger.Info("Writing results", "path", outputFile) + + data, err := json.MarshalIndent(aggregated, "", " ") + if err != nil { + logger.Error("Failed to marshal results", "error", err) + os.Exit(1) + } + + // Ensure output directory exists + // Note: In Kubernetes, the /results directory should be pre-created via volumeMounts + if err := os.WriteFile(outputFile, data, 0644); err != nil { + logger.Error("Failed to write results", "error", err, "path", outputFile) + os.Exit(1) + } + + // Log the results content for easy access via logs (useful in containerized environments) + logger.Info("Results written successfully", + "path", outputFile, + "content", string(data)) + + logger.Info("Validation completed", + "status", aggregated.Status, + "message", aggregated.Message) + + // Exit with appropriate code + if aggregated.Status == validator.StatusFailure { + logger.Warn("Validation FAILED - exiting with code 1") + os.Exit(1) + } + + logger.Info("Validation PASSED - exiting with code 0") +} + +// parseLogLevel converts string log level to slog.Level +func parseLogLevel(level string) slog.Level { + switch strings.ToLower(level) { + case "debug": + return slog.LevelDebug + case "info": + return slog.LevelInfo + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} diff --git a/validator/go.mod b/validator/go.mod new file mode 100644 index 0000000..42b7690 --- /dev/null +++ b/validator/go.mod @@ -0,0 +1,44 @@ +module validator + +go 1.25.0 + +require ( + github.com/onsi/ginkgo/v2 v2.27.5 + github.com/onsi/gomega v1.39.0 + golang.org/x/oauth2 v0.34.0 + google.golang.org/api v0.260.0 +) + +require ( + cloud.google.com/go/auth v0.18.0 // indirect + cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect + github.com/google/s2a-go v0.1.9 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.16.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/otel v1.39.0 // indirect + go.opentelemetry.io/otel/metric v1.39.0 // indirect + go.opentelemetry.io/otel/trace v1.39.0 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/mod v0.31.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/tools v0.40.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect +) diff --git a/validator/go.sum b/validator/go.sum new file mode 100644 index 0000000..045b625 --- /dev/null +++ b/validator/go.sum @@ -0,0 +1,122 @@ +cloud.google.com/go/auth v0.18.0 h1:wnqy5hrv7p3k7cShwAU/Br3nzod7fxoqG+k0VZ+/Pk0= +cloud.google.com/go/auth v0.18.0/go.mod h1:wwkPM1AgE1f2u6dG443MiWoD8C3BtOywNsUMcUTVDRo= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs= +github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= +github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= +github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/gax-go/v2 v2.16.0 h1:iHbQmKLLZrexmb0OSsNGTeSTS0HO4YvFOG8g5E4Zd0Y= +github.com/googleapis/gax-go/v2 v2.16.0/go.mod h1:o1vfQjjNZn4+dPnRdl/4ZD7S9414Y4xA+a/6Icj6l14= +github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE= +github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo= +github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg= +github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE= +github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A= +github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= +github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= +github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= +github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA= +github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM= +github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4= +github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU= +github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY= +github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI= +golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA= +golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/api v0.260.0 h1:XbNi5E6bOVEj/uLXQRlt6TKuEzMD7zvW/6tNwltE4P4= +google.golang.org/api v0.260.0/go.mod h1:Shj1j0Phr/9sloYrKomICzdYgsSDImpTxME8rGLaZ/o= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= +google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3 h1:C4WAdL+FbjnGlpp2S+HMVhBeCq2Lcib4xZqfPNF6OoQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260114163908-3f89685c29c3/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/validator/pkg/config/config.go b/validator/pkg/config/config.go new file mode 100644 index 0000000..68e0105 --- /dev/null +++ b/validator/pkg/config/config.go @@ -0,0 +1,132 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// Config holds all configuration from environment variables +type Config struct { + // Output + ResultsPath string // Default: /results/adapter-result.json + + // GCP Configuration + ProjectID string // Required + GCPRegion string // Optional, for regional checks + + // Validator Control + DisabledValidators []string // Comma-separated list of validators to disable + StopOnFirstFailure bool // Default: false + + // API Validator Config + RequiredAPIs []string // Default: compute.googleapis.com, iam.googleapis.com, etc. + + // Quota Validator Config (Post-MVP) + RequiredVCPUs int // Default: 0 (skip quota check) + RequiredDiskGB int + RequiredIPAddresses int + + // Network Validator Config (Post-MVP) + VPCName string + SubnetName string + + // Logging + LogLevel string // debug, info, warn, error + + // Timeout + MaxWaitTimeSeconds int // Default: 300 (5 minutes), maximum time for all validators to complete +} + +// LoadFromEnv loads configuration from environment variables +func LoadFromEnv() (*Config, error) { + cfg := &Config{ + ResultsPath: getEnv("RESULTS_PATH", "/results/adapter-result.json"), + ProjectID: os.Getenv("PROJECT_ID"), + GCPRegion: getEnv("GCP_REGION", ""), + StopOnFirstFailure: getEnvBool("STOP_ON_FIRST_FAILURE", false), + LogLevel: getEnv("LOG_LEVEL", "info"), + RequiredVCPUs: getEnvInt("REQUIRED_VCPUS", 0), + RequiredDiskGB: getEnvInt("REQUIRED_DISK_GB", 0), + RequiredIPAddresses: getEnvInt("REQUIRED_IP_ADDRESSES", 0), + VPCName: getEnv("VPC_NAME", ""), + SubnetName: getEnv("SUBNET_NAME", ""), + MaxWaitTimeSeconds: getEnvInt("MAX_WAIT_TIME_SECONDS", 300), + } + + // Parse disabled validators + if disabled := os.Getenv("DISABLED_VALIDATORS"); disabled != "" { + cfg.DisabledValidators = strings.Split(disabled, ",") + // Trim whitespace + for i, v := range cfg.DisabledValidators { + cfg.DisabledValidators[i] = strings.TrimSpace(v) + } + } + + // Parse required APIs + defaultAPIs := []string{ + "compute.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", + } + if apis := os.Getenv("REQUIRED_APIS"); apis != "" { + cfg.RequiredAPIs = strings.Split(apis, ",") + // Trim whitespace + for i, v := range cfg.RequiredAPIs { + cfg.RequiredAPIs[i] = strings.TrimSpace(v) + } + } else { + cfg.RequiredAPIs = defaultAPIs + } + + // Validation + if cfg.ProjectID == "" { + return nil, fmt.Errorf("PROJECT_ID is required") + } + + return cfg, nil +} + +// getEnv retrieves an environment variable or returns a default value if not set +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +// getEnvBool retrieves a boolean environment variable or returns a default value if not set or invalid +func getEnvBool(key string, defaultValue bool) bool { + if value := os.Getenv(key); value != "" { + b, err := strconv.ParseBool(value) + if err == nil { + return b + } + } + return defaultValue +} + +// getEnvInt retrieves an integer environment variable or returns a default value if not set or invalid +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + i, err := strconv.Atoi(value) + if err == nil { + return i + } + } + return defaultValue +} + +// IsValidatorEnabled checks if a validator should run +// All validators are enabled by default unless explicitly disabled +func (c *Config) IsValidatorEnabled(name string) bool { + // Check if explicitly disabled + for _, disabled := range c.DisabledValidators { + if disabled == name { + return false + } + } + // Not disabled = enabled + return true +} diff --git a/validator/pkg/config/config_suite_test.go b/validator/pkg/config/config_suite_test.go new file mode 100644 index 0000000..91c7adc --- /dev/null +++ b/validator/pkg/config/config_suite_test.go @@ -0,0 +1,13 @@ +package config_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestConfig(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Config Suite") +} diff --git a/validator/pkg/config/config_test.go b/validator/pkg/config/config_test.go new file mode 100644 index 0000000..62e81aa --- /dev/null +++ b/validator/pkg/config/config_test.go @@ -0,0 +1,228 @@ +package config_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" +) + +var _ = Describe("Config", func() { + BeforeEach(func() { + // Clear environment variables - GinkgoT().Setenv automatically restores them + envVars := []string{ + "RESULTS_PATH", "PROJECT_ID", "GCP_REGION", + "DISABLED_VALIDATORS", "STOP_ON_FIRST_FAILURE", + "REQUIRED_APIS", "LOG_LEVEL", + "REQUIRED_VCPUS", "REQUIRED_DISK_GB", "REQUIRED_IP_ADDRESSES", + "VPC_NAME", "SUBNET_NAME", "MAX_WAIT_TIME_SECONDS", + } + for _, key := range envVars { + GinkgoT().Setenv(key, "") + } + }) + + Describe("LoadFromEnv", func() { + Context("with minimal required configuration", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project-123") + }) + + It("should load config with defaults", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ProjectID).To(Equal("test-project-123")) + Expect(cfg.ResultsPath).To(Equal("/results/adapter-result.json")) + Expect(cfg.LogLevel).To(Equal("info")) + Expect(cfg.StopOnFirstFailure).To(BeFalse()) + }) + + It("should set default required APIs", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredAPIs).To(ConsistOf( + "compute.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", + )) + }) + }) + + Context("without required PROJECT_ID", func() { + It("should return an error", func() { + _, err := config.LoadFromEnv() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("PROJECT_ID is required")) + }) + }) + + Context("with custom configuration", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "custom-project") + GinkgoT().Setenv("RESULTS_PATH", "/custom/path/results.json") + GinkgoT().Setenv("GCP_REGION", "us-central1") + GinkgoT().Setenv("LOG_LEVEL", "debug") + GinkgoT().Setenv("STOP_ON_FIRST_FAILURE", "true") + }) + + It("should load all custom values", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ProjectID).To(Equal("custom-project")) + Expect(cfg.ResultsPath).To(Equal("/custom/path/results.json")) + Expect(cfg.GCPRegion).To(Equal("us-central1")) + Expect(cfg.LogLevel).To(Equal("debug")) + Expect(cfg.StopOnFirstFailure).To(BeTrue()) + }) + }) + + Context("with disabled validators", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check,network-check") + }) + + It("should parse the disabled validators list", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.DisabledValidators).To(ConsistOf("quota-check", "network-check")) + }) + }) + + Context("with disabled validators containing whitespace", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("DISABLED_VALIDATORS", " quota-check , network-check ") + }) + + It("should trim whitespace from validator names", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.DisabledValidators).To(ConsistOf("quota-check", "network-check")) + }) + }) + + Context("with custom required APIs", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_APIS", "compute.googleapis.com,storage.googleapis.com") + }) + + It("should parse the required APIs list", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredAPIs).To(ConsistOf("compute.googleapis.com", "storage.googleapis.com")) + }) + }) + + Context("with integer configurations", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_VCPUS", "100") + GinkgoT().Setenv("REQUIRED_DISK_GB", "500") + GinkgoT().Setenv("REQUIRED_IP_ADDRESSES", "10") + }) + + It("should parse integer values", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredVCPUs).To(Equal(100)) + Expect(cfg.RequiredDiskGB).To(Equal(500)) + Expect(cfg.RequiredIPAddresses).To(Equal(10)) + }) + }) + + Context("with invalid integer values", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_VCPUS", "not-a-number") + }) + + It("should use default value for invalid integers", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.RequiredVCPUs).To(Equal(0)) + }) + }) + + Context("with invalid boolean values", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("STOP_ON_FIRST_FAILURE", "not-a-bool") + }) + + It("should use default value for invalid booleans", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.StopOnFirstFailure).To(BeFalse()) + }) + }) + + Context("with network validator config", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("VPC_NAME", "my-vpc") + GinkgoT().Setenv("SUBNET_NAME", "my-subnet") + }) + + It("should load network configuration", func() { + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.VPCName).To(Equal("my-vpc")) + Expect(cfg.SubnetName).To(Equal("my-subnet")) + }) + }) + }) + + Describe("IsValidatorEnabled", func() { + var cfg *config.Config + + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "test-project") + }) + + Context("with no disabled list", func() { + BeforeEach(func() { + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should enable all validators by default", func() { + Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) + Expect(cfg.IsValidatorEnabled("quota-check")).To(BeTrue()) + Expect(cfg.IsValidatorEnabled("any-validator")).To(BeTrue()) + }) + }) + + Context("with disabled validators list", func() { + BeforeEach(func() { + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check") + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should disable validators in the list", func() { + Expect(cfg.IsValidatorEnabled("quota-check")).To(BeFalse()) + Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) + Expect(cfg.IsValidatorEnabled("network-check")).To(BeTrue()) + }) + }) + + Context("with multiple disabled validators", func() { + BeforeEach(func() { + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check,network-check") + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + }) + + It("should disable all validators in the list", func() { + Expect(cfg.IsValidatorEnabled("quota-check")).To(BeFalse()) + Expect(cfg.IsValidatorEnabled("network-check")).To(BeFalse()) + Expect(cfg.IsValidatorEnabled("api-enabled")).To(BeTrue()) + }) + }) + }) +}) diff --git a/validator/pkg/gcp/client.go b/validator/pkg/gcp/client.go new file mode 100644 index 0000000..5298758 --- /dev/null +++ b/validator/pkg/gcp/client.go @@ -0,0 +1,227 @@ +package gcp + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "time" + + "golang.org/x/oauth2/google" + "google.golang.org/api/cloudresourcemanager/v1" + "google.golang.org/api/compute/v1" + "google.golang.org/api/googleapi" + "google.golang.org/api/iam/v1" + "google.golang.org/api/monitoring/v3" + "google.golang.org/api/option" + "google.golang.org/api/serviceusage/v1" +) + +const ( + // Retry configuration + initialBackoff = 100 * time.Millisecond + maxBackoff = 30 * time.Second + maxRetries = 5 + + // Retryable HTTP status codes + statusRateLimited = 429 + statusServiceUnavail = 503 + statusInternalError = 500 +) + +// getDefaultClient creates an HTTP client with WIF authentication +// Creates a new client for each call with the specified scopes +// google.DefaultClient handles connection pooling and credential caching internally +func getDefaultClient(ctx context.Context, scopes ...string) (*http.Client, error) { + return google.DefaultClient(ctx, scopes...) +} + +// retryWithBackoff wraps GCP API calls with exponential backoff retry logic +func retryWithBackoff(ctx context.Context, operation func() error) error { + var lastErr error + backoff := initialBackoff + + for attempt := 0; attempt < maxRetries; attempt++ { + if attempt > 0 { + // Calculate exponential backoff with jitter + if backoff < maxBackoff { + backoff = backoff * 2 + if backoff > maxBackoff { + backoff = maxBackoff + } + } + slog.Debug("Retrying GCP API call", "attempt", attempt, "backoff", backoff) + + select { + case <-time.After(backoff): + case <-ctx.Done(): + return fmt.Errorf("context cancelled during retry: %w", ctx.Err()) + } + } + + lastErr = operation() + if lastErr == nil { + return nil // Success + } + + // Check if error is retryable + if apiErr, ok := lastErr.(*googleapi.Error); ok { + // Retry on rate limit, service unavailable, and internal errors + if apiErr.Code == statusRateLimited || + apiErr.Code == statusServiceUnavail || + apiErr.Code == statusInternalError { + continue + } + // Don't retry on other errors (4xx client errors, etc.) + return lastErr + } + + // Retry on network/context errors + if ctx.Err() != nil { + return fmt.Errorf("context error: %w", ctx.Err()) + } + } + + return fmt.Errorf("max retries exceeded: %w", lastErr) +} + +// ClientFactory creates GCP service clients with WIF authentication +type ClientFactory struct { + projectID string + logger *slog.Logger +} + +// NewClientFactory creates a new GCP client factory +func NewClientFactory(projectID string, logger *slog.Logger) *ClientFactory { + return &ClientFactory{ + projectID: projectID, + logger: logger, + } +} + +// CreateComputeService creates a Compute Engine service client with minimal scopes +func (f *ClientFactory) CreateComputeService(ctx context.Context) (*compute.Service, error) { + f.logger.Debug("Creating Compute Engine service client with WIF") + + // Use readonly scope for read-only operations (quota checks, list instances, etc.) + client, err := getDefaultClient(ctx, compute.ComputeReadonlyScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *compute.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = compute.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create compute service: %w", err) + } + + return svc, nil +} + +// CreateIAMService creates an IAM service client with minimal scopes +func (f *ClientFactory) CreateIAMService(ctx context.Context) (*iam.Service, error) { + f.logger.Debug("Creating IAM service client with WIF") + + // Use readonly scope for validation (checking service accounts, roles, etc.) + client, err := getDefaultClient(ctx, "https://www.googleapis.com/auth/cloud-platform.read-only") + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *iam.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = iam.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create IAM service: %w", err) + } + + return svc, nil +} + +// CreateCloudResourceManagerService creates a Cloud Resource Manager service client with minimal scopes +func (f *ClientFactory) CreateCloudResourceManagerService(ctx context.Context) (*cloudresourcemanager.Service, error) { + f.logger.Debug("Creating Cloud Resource Manager service client with WIF") + + // Use readonly scope for read-only project operations + client, err := getDefaultClient(ctx, cloudresourcemanager.CloudPlatformReadOnlyScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *cloudresourcemanager.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = cloudresourcemanager.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create cloud resource manager service: %w", err) + } + + return svc, nil +} + +// CreateServiceUsageService creates a Service Usage service client with minimal scopes +func (f *ClientFactory) CreateServiceUsageService(ctx context.Context) (*serviceusage.Service, error) { + f.logger.Debug("Creating Service Usage service client with WIF") + + // Use readonly scope for checking API enablement status + client, err := getDefaultClient(ctx, serviceusage.CloudPlatformReadOnlyScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *serviceusage.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = serviceusage.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create service usage service: %w", err) + } + + return svc, nil +} + +// CreateMonitoringService creates a Monitoring service client with minimal scopes +func (f *ClientFactory) CreateMonitoringService(ctx context.Context) (*monitoring.Service, error) { + f.logger.Debug("Creating Monitoring service client with WIF") + + // Use readonly scope for reading metrics/alerts + client, err := getDefaultClient(ctx, monitoring.MonitoringReadScope) + if err != nil { + return nil, fmt.Errorf("failed to create default client: %w", err) + } + + var svc *monitoring.Service + err = retryWithBackoff(ctx, func() error { + var createErr error + svc, createErr = monitoring.NewService(ctx, option.WithHTTPClient(client)) + return createErr + }) + if err != nil { + return nil, fmt.Errorf("failed to create monitoring service: %w", err) + } + + return svc, nil +} + +// Test helpers - exported for testing purposes only + +// GetDefaultClientForTesting exposes getDefaultClient for testing +func GetDefaultClientForTesting(ctx context.Context, scopes ...string) (*http.Client, error) { + return getDefaultClient(ctx, scopes...) +} + +// RetryWithBackoffForTesting exposes retryWithBackoff for testing +func RetryWithBackoffForTesting(ctx context.Context, operation func() error) error { + return retryWithBackoff(ctx, operation) +} diff --git a/validator/pkg/gcp/client_test.go b/validator/pkg/gcp/client_test.go new file mode 100644 index 0000000..bce2a8a --- /dev/null +++ b/validator/pkg/gcp/client_test.go @@ -0,0 +1,189 @@ +package gcp_test + +import ( + "context" + "errors" + "log/slog" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "google.golang.org/api/googleapi" + + "validator/pkg/gcp" +) + +var _ = Describe("GCP Client", func() { + Describe("getDefaultClient", func() { + Context("with different scopes", func() { + It("should create new clients for each scope", func() { + ctx := context.Background() + scopes1 := []string{"https://www.googleapis.com/auth/cloud-platform.read-only"} + scopes2 := []string{"https://www.googleapis.com/auth/compute.readonly"} + + // First call with scopes1 + client1, err1 := gcp.GetDefaultClientForTesting(ctx, scopes1...) + Expect(err1).NotTo(HaveOccurred()) + Expect(client1).NotTo(BeNil()) + + // Second call with scopes2 should return a different instance + client2, err2 := gcp.GetDefaultClientForTesting(ctx, scopes2...) + Expect(err2).NotTo(HaveOccurred()) + Expect(client2).NotTo(BeNil()) + Expect(client2).NotTo(BeIdenticalTo(client1), "Expected different client instances for different scopes") + }) + + It("should create valid clients", func() { + ctx := context.Background() + scopes := []string{"https://www.googleapis.com/auth/cloud-platform.read-only"} + + client, err := gcp.GetDefaultClientForTesting(ctx, scopes...) + Expect(err).NotTo(HaveOccurred()) + Expect(client).NotTo(BeNil()) + Expect(client.Transport).NotTo(BeNil()) + }) + }) + }) + + Describe("retryWithBackoff", func() { + var ctx context.Context + + BeforeEach(func() { + ctx = context.Background() + }) + + Context("when operation succeeds on first attempt", func() { + It("should return success without retrying", func() { + callCount := 0 + operation := func() error { + callCount++ + return nil + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).NotTo(HaveOccurred()) + Expect(callCount).To(Equal(1), "Should only call once on success") + }) + }) + + Context("with retryable errors", func() { + DescribeTable("should retry based on error code", + func(errorCode int, shouldRetry bool, expectedAttempts int) { + callCount := 0 + operation := func() error { + callCount++ + return &googleapi.Error{Code: errorCode} + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred(), "Should return error") + Expect(callCount).To(Equal(expectedAttempts)) + }, + Entry("429 Rate Limit - should retry", 429, true, 5), + Entry("503 Service Unavailable - should retry", 503, true, 5), + Entry("500 Internal Error - should retry", 500, true, 5), + Entry("404 Not Found - should not retry", 404, false, 1), + Entry("403 Forbidden - should not retry", 403, false, 1), + ) + }) + + Context("when context is cancelled during retry", func() { + It("should stop retrying and return context error", func() { + ctx, cancel := context.WithCancel(context.Background()) + callCount := 0 + + operation := func() error { + callCount++ + if callCount == 2 { + cancel() // Cancel on second attempt + } + return &googleapi.Error{Code: 503} // Retryable error + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, context.Canceled)).To(BeTrue(), "Should return context.Canceled error") + Expect(callCount).To(Equal(2), "Should have attempted twice before cancellation") + }) + }) + + Context("when context times out", func() { + It("should return deadline exceeded error", func() { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + operation := func() error { + return &googleapi.Error{Code: 503} // Keep retrying + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, context.DeadlineExceeded)).To(BeTrue(), "Should return deadline exceeded error") + }) + }) + + Context("when max retries are exceeded", func() { + It("should return error after 5 attempts", func() { + callCount := 0 + operation := func() error { + callCount++ + return &googleapi.Error{Code: 503} // Always fail with retryable error + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("max retries exceeded")) + Expect(callCount).To(Equal(5), "Should attempt 5 times (initial + 4 retries)") + }) + }) + + Context("with non-googleapi errors", func() { + It("should retry generic errors until max retries", func() { + callCount := 0 + operation := func() error { + callCount++ + return errors.New("generic error") + } + + err := gcp.RetryWithBackoffForTesting(ctx, operation) + Expect(err).To(HaveOccurred()) + Expect(callCount).To(Equal(5), "Should retry generic errors until max retries") + }) + }) + }) + + Describe("ClientFactory", func() { + var ( + projectID string + logger *slog.Logger + ) + + BeforeEach(func() { + projectID = "test-project" + logger = slog.Default() + }) + + Describe("NewClientFactory", func() { + It("should create a new factory with correct values", func() { + factory := gcp.NewClientFactory(projectID, logger) + Expect(factory).NotTo(BeNil()) + + // Note: We can't directly test private fields, but we can test behavior + // by using the factory to create services (which would fail if projectID is wrong) + }) + + It("should accept different project IDs", func() { + factory := gcp.NewClientFactory("my-test-project", logger) + Expect(factory).NotTo(BeNil()) + }) + }) + + // Note: Testing actual GCP service creation requires either: + // 1. Mocking google.DefaultClient (complex, requires dependency injection) + // 2. Integration tests with real GCP credentials + // 3. Using interfaces and dependency injection (architectural change) + // + // For now, we test the factory creation and leave service creation for integration tests. + // The CreateXXXService methods follow the same pattern, so testing one validates the pattern. + }) +}) diff --git a/validator/pkg/gcp/gcp_suite_test.go b/validator/pkg/gcp/gcp_suite_test.go new file mode 100644 index 0000000..d414a44 --- /dev/null +++ b/validator/pkg/gcp/gcp_suite_test.go @@ -0,0 +1,13 @@ +package gcp_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestGCP(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "GCP Suite") +} diff --git a/validator/pkg/validator/context.go b/validator/pkg/validator/context.go new file mode 100644 index 0000000..054dc41 --- /dev/null +++ b/validator/pkg/validator/context.go @@ -0,0 +1,148 @@ +package validator + +import ( + "context" + "fmt" + "log/slog" + "sync" + + "google.golang.org/api/cloudresourcemanager/v1" + "google.golang.org/api/compute/v1" + "google.golang.org/api/iam/v1" + "google.golang.org/api/monitoring/v3" + "google.golang.org/api/serviceusage/v1" + + "validator/pkg/config" + "validator/pkg/gcp" +) + +// Context provides shared resources and configuration to all validators +// Implements least-privilege principle through lazy initialization: +// - Services are only created when first requested by validators +// - OAuth scopes are only requested for services that are actually used +// - Disabled validators never trigger authentication for their services +// Thread-safe: Uses sync.Once to ensure services are initialized exactly once +type Context struct { + // Configuration + Config *config.Config + + // Client factory for creating GCP service clients + clientFactory *gcp.ClientFactory + + // GCP Clients (lazily initialized, shared across validators) + // These are private to enforce use of getter methods + computeService *compute.Service + iamService *iam.Service + cloudResourceManagerSvc *cloudresourcemanager.Service + serviceUsageService *serviceusage.Service + monitoringService *monitoring.Service + + // Thread-safe lazy initialization guards + // Each sync.Once ensures its corresponding service is created exactly once, + // even when called concurrently from multiple validators + computeOnce sync.Once + iamOnce sync.Once + cloudResourceMgrOnce sync.Once + serviceUsageOnce sync.Once + monitoringOnce sync.Once + + // Shared state between validators + ProjectNumber int64 + + // Results from previous validators (for dependency checking) + Results map[string]*Result +} + +// NewContext creates a new validation context with a client factory +func NewContext(cfg *config.Config, logger *slog.Logger) *Context { + return &Context{ + Config: cfg, + clientFactory: gcp.NewClientFactory(cfg.ProjectID, logger), + Results: make(map[string]*Result), + } +} + +// GetComputeService returns the Compute Engine service, creating it lazily on first use +// Only requests compute.readonly scope when a validator actually needs it +// Thread-safe: Uses sync.Once to ensure the service is created exactly once +func (c *Context) GetComputeService(ctx context.Context) (*compute.Service, error) { + var err error + c.computeOnce.Do(func() { + c.computeService, err = c.clientFactory.CreateComputeService(ctx) + if err != nil { + err = fmt.Errorf("failed to create compute service: %w", err) + } + }) + if err != nil { + return nil, err + } + return c.computeService, nil +} + +// GetIAMService returns the IAM service, creating it lazily on first use +// Only requests cloud-platform.read-only scope when a validator actually needs it +// Thread-safe: Uses sync.Once to ensure the service is created exactly once +func (c *Context) GetIAMService(ctx context.Context) (*iam.Service, error) { + var err error + c.iamOnce.Do(func() { + c.iamService, err = c.clientFactory.CreateIAMService(ctx) + if err != nil { + err = fmt.Errorf("failed to create IAM service: %w", err) + } + }) + if err != nil { + return nil, err + } + return c.iamService, nil +} + +// GetCloudResourceManagerService returns the Cloud Resource Manager service, creating it lazily on first use +// Only requests cloudresourcemanager.readonly scope when a validator actually needs it +// Thread-safe: Uses sync.Once to ensure the service is created exactly once +func (c *Context) GetCloudResourceManagerService(ctx context.Context) (*cloudresourcemanager.Service, error) { + var err error + c.cloudResourceMgrOnce.Do(func() { + c.cloudResourceManagerSvc, err = c.clientFactory.CreateCloudResourceManagerService(ctx) + if err != nil { + err = fmt.Errorf("failed to create cloud resource manager service: %w", err) + } + }) + if err != nil { + return nil, err + } + return c.cloudResourceManagerSvc, nil +} + +// GetServiceUsageService returns the Service Usage service, creating it lazily on first use +// Only requests serviceusage.readonly scope when a validator actually needs it +// Thread-safe: Uses sync.Once to ensure the service is created exactly once +func (c *Context) GetServiceUsageService(ctx context.Context) (*serviceusage.Service, error) { + var err error + c.serviceUsageOnce.Do(func() { + c.serviceUsageService, err = c.clientFactory.CreateServiceUsageService(ctx) + if err != nil { + err = fmt.Errorf("failed to create service usage service: %w", err) + } + }) + if err != nil { + return nil, err + } + return c.serviceUsageService, nil +} + +// GetMonitoringService returns the Monitoring service, creating it lazily on first use +// Only requests monitoring.read scope when a validator actually needs it +// Thread-safe: Uses sync.Once to ensure the service is created exactly once +func (c *Context) GetMonitoringService(ctx context.Context) (*monitoring.Service, error) { + var err error + c.monitoringOnce.Do(func() { + c.monitoringService, err = c.clientFactory.CreateMonitoringService(ctx) + if err != nil { + err = fmt.Errorf("failed to create monitoring service: %w", err) + } + }) + if err != nil { + return nil, err + } + return c.monitoringService, nil +} diff --git a/validator/pkg/validator/context_test.go b/validator/pkg/validator/context_test.go new file mode 100644 index 0000000..5ab197e --- /dev/null +++ b/validator/pkg/validator/context_test.go @@ -0,0 +1,268 @@ +package validator_test + +import ( + "context" + "log/slog" + "os" + "sync" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" +) + +var _ = Describe("Context", func() { + var ( + cfg *config.Config + logger *slog.Logger + vctx *validator.Context + ) + + BeforeEach(func() { + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project-lazy-init") + + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + }) + + Describe("NewContext", func() { + Context("with valid configuration", func() { + It("should create a new context with proper initialization", func() { + vctx = validator.NewContext(cfg, logger) + + Expect(vctx).NotTo(BeNil()) + Expect(vctx.Config).To(Equal(cfg)) + Expect(vctx.Results).NotTo(BeNil()) + Expect(vctx.Results).To(BeEmpty()) + }) + + It("should initialize with correct project ID", func() { + vctx = validator.NewContext(cfg, logger) + + Expect(vctx.Config.ProjectID).To(Equal("test-project-lazy-init")) + }) + + It("should create Results map ready for use", func() { + vctx = validator.NewContext(cfg, logger) + + // Should be able to add results without nil pointer panic + vctx.Results["test"] = &validator.Result{ + ValidatorName: "test", + Status: validator.StatusSuccess, + } + Expect(vctx.Results).To(HaveKey("test")) + }) + }) + + Context("with different configurations", func() { + It("should handle different project IDs", func() { + GinkgoT().Setenv("PROJECT_ID", "production-123") + cfg2, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + vctx = validator.NewContext(cfg2, logger) + Expect(vctx.Config.ProjectID).To(Equal("production-123")) + }) + }) + }) + + Describe("Lazy Initialization - Least Privilege Guarantee", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + Context("GetServiceUsageService", func() { + It("should create service on first call", func() { + ctx := context.Background() + + // First call should create the service + svc1, err := vctx.GetServiceUsageService(ctx) + + // Note: This will fail without valid GCP credentials + // For unit tests, we expect an error but verify the method works + if err != nil { + // Expected in test environment without GCP credentials + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(Or( + ContainSubstring("could not find default credentials"), + ContainSubstring("ADC"), + ContainSubstring("GOOGLE_APPLICATION_CREDENTIALS"), + )) + } else { + // If credentials exist (e.g., in CI with WIF), verify service is created + Expect(svc1).NotTo(BeNil()) + } + }) + + }) + + Context("GetComputeService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetComputeService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create compute service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + + Context("GetIAMService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetIAMService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create IAM service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + + Context("GetCloudResourceManagerService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetCloudResourceManagerService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create cloud resource manager service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + + Context("GetMonitoringService", func() { + It("should handle missing credentials gracefully", func() { + ctx := context.Background() + + svc, err := vctx.GetMonitoringService(ctx) + + if err != nil { + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to create monitoring service")) + } else { + Expect(svc).NotTo(BeNil()) + } + }) + }) + }) + + Describe("Context Cancellation", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + + It("should not panic with cancelled context", func() { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + // Should not panic, even if it doesn't check context + Expect(func() { + _, _ = vctx.GetServiceUsageService(ctx) + }).NotTo(Panic()) + }) + }) + + Describe("Thread Safety", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + It("should handle concurrent access to the SAME getter safely", func() { + ctx := context.Background() + var wg sync.WaitGroup + const numGoroutines = 50 + + // This test validates the critical race condition fix: + // Multiple goroutines calling the same getter concurrently should only + // create the service once, not 50 times. + // Before the sync.Once fix, all 50 goroutines could pass the nil check + // and create duplicate service instances (resource waste + race condition). + for i := 0; i < numGoroutines; i++ { + wg.Add(1) + go func() { + defer GinkgoRecover() + defer wg.Done() + _, _ = vctx.GetServiceUsageService(ctx) + // Don't check error - we just verify sync.Once prevents race conditions + }() + } + + // Should complete without race conditions or panics + // Run with -race flag to detect any data races + wg.Wait() + }) + + It("should handle concurrent access to ALL getters from many goroutines", func() { + ctx := context.Background() + var wg sync.WaitGroup + const goroutinesPerGetter = 20 + + // All service getters + getters := []func(context.Context) (interface{}, error){ + func(ctx context.Context) (interface{}, error) { return vctx.GetComputeService(ctx) }, + func(ctx context.Context) (interface{}, error) { return vctx.GetIAMService(ctx) }, + func(ctx context.Context) (interface{}, error) { return vctx.GetServiceUsageService(ctx) }, + func(ctx context.Context) (interface{}, error) { return vctx.GetMonitoringService(ctx) }, + func(ctx context.Context) (interface{}, error) { return vctx.GetCloudResourceManagerService(ctx) }, + } + + // Launch multiple goroutines for each getter + // This simulates real-world parallel validator execution + for _, getter := range getters { + for i := 0; i < goroutinesPerGetter; i++ { + wg.Add(1) + go func(g func(context.Context) (interface{}, error)) { + defer GinkgoRecover() + defer wg.Done() + _, _ = g(ctx) + }(getter) + } + } + + // Should complete without race conditions or panics + wg.Wait() + }) + }) + + Describe("Shared State", func() { + BeforeEach(func() { + vctx = validator.NewContext(cfg, logger) + }) + + It("should maintain ProjectNumber across operations", func() { + vctx.ProjectNumber = 12345678 + + Expect(vctx.ProjectNumber).To(Equal(int64(12345678))) + }) + + It("should maintain Results map across operations", func() { + vctx.Results["validator-1"] = &validator.Result{ + ValidatorName: "validator-1", + Status: validator.StatusSuccess, + } + + Expect(vctx.Results).To(HaveLen(1)) + Expect(vctx.Results["validator-1"].Status).To(Equal(validator.StatusSuccess)) + }) + }) +}) diff --git a/validator/pkg/validator/executor.go b/validator/pkg/validator/executor.go new file mode 100644 index 0000000..4120a16 --- /dev/null +++ b/validator/pkg/validator/executor.go @@ -0,0 +1,189 @@ +package validator + +import ( + "context" + "fmt" + "log/slog" + "runtime/debug" + "sync" + "time" +) + +// Executor orchestrates validator execution +type Executor struct { + ctx *Context + logger *slog.Logger + mu sync.Mutex // Protects results map during parallel execution +} + +// NewExecutor creates a new executor +func NewExecutor(ctx *Context, logger *slog.Logger) *Executor { + return &Executor{ + ctx: ctx, + logger: logger, + } +} + +// ExecuteAll runs validators with dependency resolution and parallel execution +func (e *Executor) ExecuteAll(ctx context.Context) ([]*Result, error) { + // 1. Get all registered validators + allValidators := GetAll() + + // 2. Filter enabled validators using config + enabledValidators := []Validator{} + for _, v := range allValidators { + meta := v.Metadata() + if e.ctx.Config.IsValidatorEnabled(meta.Name) { + enabledValidators = append(enabledValidators, v) + } else { + e.logger.Info("Validator disabled, skipping", "validator", meta.Name) + } + } + + if len(enabledValidators) == 0 { + return nil, fmt.Errorf("no validators enabled") + } + + e.logger.Info("Found enabled validators", "count", len(enabledValidators)) + + // 3. Resolve dependencies and build execution plan + resolver := NewDependencyResolver(enabledValidators) + groups, err := resolver.ResolveExecutionGroups() + if err != nil { + return nil, fmt.Errorf("dependency resolution failed: %w", err) + } + + e.logger.Info("Execution plan created", "groups", len(groups)) + + // Log dependency graphs + e.logger.Debug("Validator dependency graph (raw dependencies):\n" + resolver.ToMermaid()) + e.logger.Info("Validator execution plan (with levels):\n" + resolver.ToMermaidWithLevels(groups)) + + for _, group := range groups { + e.logger.Debug("Execution group", + "level", group.Level, + "validators", len(group.Validators), + "mode", "parallel") + } + + // 4. Execute validators group by group + allResults := []*Result{} + for _, group := range groups { + e.logger.Info("Executing level", + "level", group.Level, + "validators", len(group.Validators)) + + groupResults := e.executeGroup(ctx, group) + allResults = append(allResults, groupResults...) + + // Check stop on failure + if e.ctx.Config.StopOnFirstFailure { + for _, result := range groupResults { + if result.Status == StatusFailure { + e.logger.Warn("Stopping due to failure", "validator", result.ValidatorName) + return allResults, nil + } + } + } + } + + return allResults, nil +} + +// executeGroup runs all validators in a group in parallel +func (e *Executor) executeGroup(ctx context.Context, group ExecutionGroup) []*Result { + var wg sync.WaitGroup + results := make([]*Result, len(group.Validators)) + + for i, v := range group.Validators { + wg.Add(1) + go func(index int, validator Validator) { + defer wg.Done() + + // Add panic recovery to prevent one validator from crashing all validators + defer func() { + if r := recover(); r != nil { + stack := string(debug.Stack()) + meta := validator.Metadata() + e.logger.Error("Validator panicked", + "validator", meta.Name, + "panic", r, + "stack", stack) + + // Create failure result for panicked validator + panicResult := &Result{ + ValidatorName: meta.Name, + Status: StatusFailure, + Reason: "ValidatorPanic", + Message: fmt.Sprintf("Validator crashed: %v", r), + Details: map[string]interface{}{ + "panic": fmt.Sprint(r), + "panic_type": fmt.Sprintf("%T", r), + "stack": stack, + }, + Duration: 0, + Timestamp: time.Now().UTC(), + } + + // Thread-safe result storage + e.mu.Lock() + e.ctx.Results[meta.Name] = panicResult + results[index] = panicResult + e.mu.Unlock() + } + }() + + meta := validator.Metadata() + e.logger.Info("Running validator", "validator", meta.Name) + + start := time.Now() + result := validator.Validate(ctx, e.ctx) + + // Defensive nil check - validator.Validate should never return nil, + // but handle it to prevent nil pointer panics + if result == nil { + e.logger.Error("Validator returned nil result", + "validator", meta.Name) + result = &Result{ + ValidatorName: meta.Name, + Status: StatusFailure, + Reason: "NilResult", + Message: "Validator returned nil result (this is a validator implementation bug)", + Duration: time.Since(start), + Timestamp: time.Now().UTC(), + } + } else { + result.Duration = time.Since(start) + result.Timestamp = time.Now().UTC() + result.ValidatorName = meta.Name + } + + // Thread-safe result storage + e.mu.Lock() + e.ctx.Results[meta.Name] = result + e.mu.Unlock() + + results[index] = result + + // Log based on result status + logAttrs := []any{ + "validator", meta.Name, + "status", result.Status, + "duration", result.Duration, + } + switch result.Status { + case StatusFailure: + // Add reason and message for failures to help with debugging + logAttrs = append(logAttrs, + "reason", result.Reason, + "message", result.Message) + e.logger.Warn("Validator completed with failure", logAttrs...) + default: + e.logger.Info("Validator completed", logAttrs...) + } + }(i, v) + } + + wg.Wait() // Wait for all validators in this group + return results +} diff --git a/validator/pkg/validator/executor_test.go b/validator/pkg/validator/executor_test.go new file mode 100644 index 0000000..e005e8c --- /dev/null +++ b/validator/pkg/validator/executor_test.go @@ -0,0 +1,325 @@ +package validator_test + +import ( + "context" + "log/slog" + "os" + "sync" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" +) + +var _ = Describe("Executor", func() { + var ( + ctx context.Context + vctx *validator.Context + executor *validator.Executor + logger *slog.Logger + ) + + BeforeEach(func() { + ctx = context.Background() + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, // Reduce noise in test output + })) + + // Clear the global registry before each test + validator.ClearRegistry() + + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project") + + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + // Use NewContext constructor for proper initialization + vctx = validator.NewContext(cfg, logger) + }) + + Describe("ExecuteAll", func() { + Context("with no validators registered", func() { + It("should return error when no validators are enabled", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no validators enabled")) + Expect(results).To(BeNil()) + }) + }) + + Context("with single validator", func() { + var mockValidator *MockValidator + + BeforeEach(func() { + mockValidator = &MockValidator{ + name: "test-validator", + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + return &validator.Result{ + ValidatorName: "test-validator", + Status: validator.StatusSuccess, + Reason: "TestPassed", + Message: "Test validation successful", + } + }, + } + validator.Register(mockValidator) + }) + + It("should execute the validator", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].ValidatorName).To(Equal("test-validator")) + Expect(results[0].Status).To(Equal(validator.StatusSuccess)) + }) + + It("should store result in context", func() { + executor = validator.NewExecutor(vctx, logger) + _, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(vctx.Results).To(HaveKey("test-validator")) + }) + + It("should set timestamp and duration", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results[0].Timestamp).NotTo(BeZero()) + Expect(results[0].Duration).To(BeNumerically(">", 0)) + }) + }) + + Context("with disabled validator", func() { + var mockValidator *MockValidator + + BeforeEach(func() { + mockValidator = &MockValidator{ + name: "disabled-validator", + } + validator.Register(mockValidator) + // Disable the validator via config + vctx.Config.DisabledValidators = []string{"disabled-validator"} + }) + + It("should skip disabled validators", func() { + executor = validator.NewExecutor(vctx, logger) + _, err := executor.ExecuteAll(ctx) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("no validators enabled")) + }) + }) + + Context("with multiple independent validators", func() { + BeforeEach(func() { + for i := 1; i <= 3; i++ { + name := "validator-" + string(rune('a'+i-1)) + n := name // Capture loop variable for closure + validator.Register(&MockValidator{ + name: n, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + time.Sleep(10 * time.Millisecond) // Simulate work + return &validator.Result{ + ValidatorName: n, + Status: validator.StatusSuccess, + Reason: "Success", + Message: "Passed", + } + }, + }) + } + }) + + It("should execute all independent validators successfully", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(3)) + // All validators should complete successfully + for _, result := range results { + Expect(result.Status).To(Equal(validator.StatusSuccess)) + } + }) + + It("should store all results in context", func() { + executor = validator.NewExecutor(vctx, logger) + _, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(vctx.Results).To(HaveLen(3)) + }) + }) + + Context("with dependent validators", func() { + var executionOrder []string + var mu sync.Mutex + + BeforeEach(func() { + executionOrder = []string{} + + // Level 0 validator + validator.Register(&MockValidator{ + name: "validator-a", + runAfter: []string{}, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, "validator-a") + mu.Unlock() + return &validator.Result{ + ValidatorName: "validator-a", + Status: validator.StatusSuccess, + } + }, + }) + + // Level 1 validators (depend on validator-a) + for _, name := range []string{"validator-b", "validator-c"} { + n := name + validator.Register(&MockValidator{ + name: n, + runAfter: []string{"validator-a"}, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, n) + mu.Unlock() + return &validator.Result{ + ValidatorName: n, + Status: validator.StatusSuccess, + } + }, + }) + } + }) + + It("should execute validators in dependency order", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(3)) + + // validator-a should execute before b and c + Expect(executionOrder[0]).To(Equal("validator-a")) + Expect(executionOrder[1:]).To(ConsistOf("validator-b", "validator-c")) + }) + + It("should handle out-of-order registration (dependencies registered before dependents)", func() { + // Clear previous validators and reset execution order + validator.ClearRegistry() + executionOrder = []string{} + + // Register in reverse order: dependents (b, c) before dependency (a) + // This tests that the resolver can handle forward references + for _, name := range []string{"validator-b", "validator-c"} { + n := name + validator.Register(&MockValidator{ + name: n, + runAfter: []string{"validator-a"}, // depends on validator-a which isn't registered yet + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, n) + mu.Unlock() + return &validator.Result{ + ValidatorName: n, + Status: validator.StatusSuccess, + } + }, + }) + } + + // Now register validator-a (after its dependents) + validator.Register(&MockValidator{ + name: "validator-a", + runAfter: []string{}, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + mu.Lock() + executionOrder = append(executionOrder, "validator-a") + mu.Unlock() + return &validator.Result{ + ValidatorName: "validator-a", + Status: validator.StatusSuccess, + } + }, + }) + + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(3)) + + // Regardless of registration order, validator-a should execute before b and c + Expect(executionOrder[0]).To(Equal("validator-a")) + Expect(executionOrder[1:]).To(ConsistOf("validator-b", "validator-c")) + }) + }) + + Context("with StopOnFirstFailure enabled", func() { + BeforeEach(func() { + vctx.Config.StopOnFirstFailure = true + + // First validator fails + validator.Register(&MockValidator{ + name: "failing-validator", + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + return &validator.Result{ + ValidatorName: "failing-validator", + Status: validator.StatusFailure, + Reason: "TestFailure", + Message: "Intentional failure", + } + }, + }) + + // Second validator should not run + validator.Register(&MockValidator{ + name: "should-not-run", + runAfter: []string{"failing-validator"}, + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + Fail("This validator should not execute") + return nil + }, + }) + }) + + It("should stop execution after first failure", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Status).To(Equal(validator.StatusFailure)) + }) + }) + + Context("with validator that returns failure", func() { + BeforeEach(func() { + validator.Register(&MockValidator{ + name: "failing-validator", + validateFunc: func(ctx context.Context, vctx *validator.Context) *validator.Result { + return &validator.Result{ + ValidatorName: "failing-validator", + Status: validator.StatusFailure, + Reason: "ValidationFailed", + Message: "Validation check failed", + Details: map[string]interface{}{ + "error": "Test error", + }, + } + }, + }) + }) + + It("should return the failure result", func() { + executor = validator.NewExecutor(vctx, logger) + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(results).To(HaveLen(1)) + Expect(results[0].Status).To(Equal(validator.StatusFailure)) + Expect(results[0].Reason).To(Equal("ValidationFailed")) + }) + }) + }) +}) diff --git a/validator/pkg/validator/registry.go b/validator/pkg/validator/registry.go new file mode 100644 index 0000000..b1f9e42 --- /dev/null +++ b/validator/pkg/validator/registry.go @@ -0,0 +1,83 @@ +package validator + +import ( + "fmt" + "sync" +) + +// Registry holds all registered validators +var globalRegistry = NewRegistry() + +type Registry struct { + mu sync.RWMutex + validators map[string]Validator +} + +// NewRegistry creates a new validator registry +func NewRegistry() *Registry { + return &Registry{ + validators: make(map[string]Validator), + } +} + +// Register adds a validator to the registry +func (r *Registry) Register(v Validator) { + r.mu.Lock() + defer r.mu.Unlock() + + meta := v.Metadata() + // Allow overwriting for testing purposes + r.validators[meta.Name] = v +} + +// GetAll returns all registered validators +func (r *Registry) GetAll() []Validator { + r.mu.RLock() + defer r.mu.RUnlock() + + validators := make([]Validator, 0, len(r.validators)) + for _, v := range r.validators { + validators = append(validators, v) + } + return validators +} + +// Get retrieves a validator by name +func (r *Registry) Get(name string) (Validator, bool) { + r.mu.RLock() + defer r.mu.RUnlock() + v, ok := r.validators[name] + return v, ok +} + +// Package-level functions for global registry + +// Register adds a validator to the global registry +// This is called from init() functions in validator implementations +func Register(v Validator) { + meta := v.Metadata() + globalRegistry.mu.Lock() + defer globalRegistry.mu.Unlock() + + if _, exists := globalRegistry.validators[meta.Name]; exists { + panic(fmt.Sprintf("validator already registered: %s", meta.Name)) + } + globalRegistry.validators[meta.Name] = v +} + +// GetAll returns all registered validators from global registry +func GetAll() []Validator { + return globalRegistry.GetAll() +} + +// Get retrieves a validator by name from global registry +func Get(name string) (Validator, bool) { + return globalRegistry.Get(name) +} + +// ClearRegistry clears all validators from the global registry (for testing) +func ClearRegistry() { + globalRegistry.mu.Lock() + defer globalRegistry.mu.Unlock() + globalRegistry.validators = make(map[string]Validator) +} diff --git a/validator/pkg/validator/registry_test.go b/validator/pkg/validator/registry_test.go new file mode 100644 index 0000000..510d1c7 --- /dev/null +++ b/validator/pkg/validator/registry_test.go @@ -0,0 +1,136 @@ +package validator_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/validator" +) + +// Mock validator for testing +type MockValidator struct { + name string + description string + runAfter []string + tags []string + validateFunc func(ctx context.Context, vctx *validator.Context) *validator.Result +} + +func (m *MockValidator) Metadata() validator.ValidatorMetadata { + return validator.ValidatorMetadata{ + Name: m.name, + Description: m.description, + RunAfter: m.runAfter, + Tags: m.tags, + } +} + +func (m *MockValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { + if m.validateFunc != nil { + return m.validateFunc(ctx, vctx) + } + return &validator.Result{ + ValidatorName: m.name, + Status: validator.StatusSuccess, + Reason: "TestSuccess", + Message: "Test validation passed", + } +} + +var _ = Describe("Registry", func() { + var ( + testRegistry *validator.Registry + mockValidator1 *MockValidator + mockValidator2 *MockValidator + ) + + BeforeEach(func() { + testRegistry = validator.NewRegistry() + mockValidator1 = &MockValidator{ + name: "test-validator-1", + description: "First test validator", + runAfter: []string{}, + tags: []string{"test", "mock"}, + } + mockValidator2 = &MockValidator{ + name: "test-validator-2", + description: "Second test validator", + runAfter: []string{"test-validator-1"}, + tags: []string{"test", "dependent"}, + } + }) + + Describe("Register", func() { + Context("when registering a new validator", func() { + It("should add the validator to the registry", func() { + testRegistry.Register(mockValidator1) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(1)) + Expect(validators[0].Metadata().Name).To(Equal("test-validator-1")) + }) + }) + + Context("when registering multiple validators", func() { + It("should add all validators to the registry", func() { + testRegistry.Register(mockValidator1) + testRegistry.Register(mockValidator2) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(2)) + }) + }) + + Context("when registering a validator with duplicate name", func() { + It("should overwrite the existing validator", func() { + testRegistry.Register(mockValidator1) + duplicate := &MockValidator{ + name: "test-validator-1", + description: "Duplicate validator", + } + testRegistry.Register(duplicate) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(1)) + Expect(validators[0].Metadata().Description).To(Equal("Duplicate validator")) + }) + }) + }) + + Describe("GetAll", func() { + Context("when registry is empty", func() { + It("should return an empty slice", func() { + validators := testRegistry.GetAll() + Expect(validators).To(BeEmpty()) + }) + }) + + Context("when registry has validators", func() { + It("should return all registered validators", func() { + testRegistry.Register(mockValidator1) + testRegistry.Register(mockValidator2) + validators := testRegistry.GetAll() + Expect(validators).To(HaveLen(2)) + }) + }) + }) + + Describe("Get", func() { + BeforeEach(func() { + testRegistry.Register(mockValidator1) + testRegistry.Register(mockValidator2) + }) + + Context("when getting a validator by name", func() { + It("should return the validator if it exists", func() { + v, exists := testRegistry.Get("test-validator-1") + Expect(exists).To(BeTrue()) + Expect(v.Metadata().Name).To(Equal("test-validator-1")) + }) + + It("should return false if validator doesn't exist", func() { + _, exists := testRegistry.Get("non-existent") + Expect(exists).To(BeFalse()) + }) + }) + }) +}) diff --git a/validator/pkg/validator/resolver.go b/validator/pkg/validator/resolver.go new file mode 100644 index 0000000..b2c6162 --- /dev/null +++ b/validator/pkg/validator/resolver.go @@ -0,0 +1,219 @@ +package validator + +import ( + "fmt" + "sort" +) + +// ExecutionGroup represents validators that can run in parallel +type ExecutionGroup struct { + Level int // Execution level (0 = first, 1 = second, etc.) + Validators []Validator // Validators to run in parallel at this level +} + +// DependencyResolver builds execution plan from validators +type DependencyResolver struct { + validators map[string]Validator +} + +// NewDependencyResolver creates a new resolver +func NewDependencyResolver(validators []Validator) *DependencyResolver { + m := make(map[string]Validator) + for _, v := range validators { + meta := v.Metadata() + m[meta.Name] = v + } + return &DependencyResolver{validators: m} +} + +// ResolveExecutionGroups organizes validators into parallel execution groups +// Validators with no dependencies or same dependencies can run in parallel +func (r *DependencyResolver) ResolveExecutionGroups() ([]ExecutionGroup, error) { + // 1. Detect cycles + if err := r.detectCycles(); err != nil { + return nil, err + } + + // 2. Topological sort with level assignment + levels := r.assignLevels() + + // 3. Group by level + groups := make([]ExecutionGroup, 0) + for level := 0; ; level++ { + var validators []Validator + for _, v := range r.validators { + meta := v.Metadata() + if levels[meta.Name] == level { + validators = append(validators, v) + } + } + if len(validators) == 0 { + break + } + + // Sort alphabetically by name within the same level for deterministic execution + sort.Slice(validators, func(i, j int) bool { + return validators[i].Metadata().Name < validators[j].Metadata().Name + }) + + groups = append(groups, ExecutionGroup{ + Level: level, + Validators: validators, + }) + } + + return groups, nil +} + +// assignLevels performs topological sort and assigns execution levels +func (r *DependencyResolver) assignLevels() map[string]int { + levels := make(map[string]int) + + // Recursive DFS to calculate max depth + var calcLevel func(name string) int + calcLevel = func(name string) int { + if level, ok := levels[name]; ok { + return level + } + + v := r.validators[name] + meta := v.Metadata() + + maxDepLevel := -1 + // Check dependencies from metadata + for _, dep := range meta.RunAfter { + if depValidator, exists := r.validators[dep]; exists { + depLevel := calcLevel(depValidator.Metadata().Name) + if depLevel > maxDepLevel { + maxDepLevel = depLevel + } + } + } + // If RunAfter is empty, maxDepLevel stays -1, so level = 0 + + level := maxDepLevel + 1 + levels[name] = level + return level + } + + for name := range r.validators { + calcLevel(name) + } + + return levels +} + +// detectCycles detects circular dependencies using DFS +func (r *DependencyResolver) detectCycles() error { + visited := make(map[string]bool) + recStack := make(map[string]bool) + + var dfs func(name string) error + dfs = func(name string) error { + visited[name] = true + recStack[name] = true + + v := r.validators[name] + meta := v.Metadata() + + // Check all dependencies from metadata + for _, dep := range meta.RunAfter { + // Skip dependencies that don't exist (will be ignored in level assignment) + if _, exists := r.validators[dep]; !exists { + continue + } + + if !visited[dep] { + if err := dfs(dep); err != nil { + return err + } + } else if recStack[dep] { + return fmt.Errorf("circular dependency detected: %s -> %s", name, dep) + } + } + + recStack[name] = false + return nil + } + + for name := range r.validators { + if !visited[name] { + if err := dfs(name); err != nil { + return err + } + } + } + + return nil +} + +// ToMermaid generates a Mermaid flowchart showing raw dependency relationships +// This visualization shows which validators depend on others based on their RunAfter declarations +func (r *DependencyResolver) ToMermaid() string { + var result string + result += "flowchart TD\n" + + // Collect all validators to ensure orphans are shown + allValidators := make(map[string]bool) + for name := range r.validators { + allValidators[name] = true + } + + // Track which validators have dependencies + hasDependencies := make(map[string]bool) + + // Add edges for all dependencies + for name, v := range r.validators { + meta := v.Metadata() + for _, dep := range meta.RunAfter { + // Only show edge if dependency exists in our validator set + if _, exists := r.validators[dep]; exists { + result += fmt.Sprintf(" %s --> %s\n", name, dep) + // Only mark as having dependencies when at least one edge is actually emitted + hasDependencies[name] = true + } + } + } + + // Add standalone nodes (validators with no dependencies) + for name := range allValidators { + if !hasDependencies[name] { + result += fmt.Sprintf(" %s\n", name) + } + } + + return result +} + +// ToMermaidWithLevels generates a Mermaid flowchart showing the execution plan with levels +// Each level is rendered as a subgraph showing which validators run in parallel +func (r *DependencyResolver) ToMermaidWithLevels(groups []ExecutionGroup) string { + var result string + result += "flowchart TD\n" + + // Create subgraphs for each level + for _, group := range groups { + parallelInfo := "" + if len(group.Validators) > 1 { + parallelInfo = fmt.Sprintf(" - %d Validators in Parallel", len(group.Validators)) + } + result += fmt.Sprintf(" subgraph \"Level %d%s\"\n", group.Level, parallelInfo) + for _, v := range group.Validators { + meta := v.Metadata() + result += fmt.Sprintf(" %s\n", meta.Name) + } + result += " end\n\n" + } + + // Add dependency edges + for _, v := range r.validators { + meta := v.Metadata() + for _, dep := range meta.RunAfter { + if _, exists := r.validators[dep]; exists { + result += fmt.Sprintf(" %s --> %s\n", meta.Name, dep) + } + } + } + + return result +} diff --git a/validator/pkg/validator/resolver_test.go b/validator/pkg/validator/resolver_test.go new file mode 100644 index 0000000..32d929a --- /dev/null +++ b/validator/pkg/validator/resolver_test.go @@ -0,0 +1,528 @@ +package validator_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/validator" +) + +var _ = Describe("DependencyResolver", func() { + var ( + resolver *validator.DependencyResolver + validators []validator.Validator + ) + + Describe("ResolveExecutionGroups", func() { + Context("with validators that have no dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{}, + }, + &MockValidator{ + name: "validator-b", + runAfter: []string{}, + }, + &MockValidator{ + name: "validator-c", + runAfter: []string{}, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should place all validators in level 0", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(1)) + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators).To(HaveLen(3)) + }) + + It("should sort validators alphabetically within the same level", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + names := make([]string, len(groups[0].Validators)) + for i, v := range groups[0].Validators { + names[i] = v.Metadata().Name + } + Expect(names).To(Equal([]string{"validator-a", "validator-b", "validator-c"})) + }) + }) + + Context("with linear dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{}, + }, + &MockValidator{ + name: "validator-b", + runAfter: []string{"validator-a"}, + }, + &MockValidator{ + name: "validator-c", + runAfter: []string{"validator-b"}, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should create separate levels for each validator", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(3)) + + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators).To(HaveLen(1)) + Expect(groups[0].Validators[0].Metadata().Name).To(Equal("validator-a")) + + Expect(groups[1].Level).To(Equal(1)) + Expect(groups[1].Validators).To(HaveLen(1)) + Expect(groups[1].Validators[0].Metadata().Name).To(Equal("validator-b")) + + Expect(groups[2].Level).To(Equal(2)) + Expect(groups[2].Validators).To(HaveLen(1)) + Expect(groups[2].Validators[0].Metadata().Name).To(Equal("validator-c")) + }) + }) + + Context("with parallel dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "wif-check", + runAfter: []string{}, + }, + &MockValidator{ + name: "api-enabled", + runAfter: []string{"wif-check"}, + }, + &MockValidator{ + name: "quota-check", + runAfter: []string{"wif-check"}, + }, + &MockValidator{ + name: "network-check", + runAfter: []string{"wif-check"}, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should group validators with same dependencies at the same level", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(2)) + + // Level 0: wif-check + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators).To(HaveLen(1)) + Expect(groups[0].Validators[0].Metadata().Name).To(Equal("wif-check")) + + // Level 1: api-enabled, quota-check, network-check (parallel) + Expect(groups[1].Level).To(Equal(1)) + Expect(groups[1].Validators).To(HaveLen(3)) + names := make([]string, 3) + for i, v := range groups[1].Validators { + names[i] = v.Metadata().Name + } + Expect(names).To(ConsistOf("api-enabled", "quota-check", "network-check")) + }) + }) + + Context("with complex dependency graph", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "wif-check", + runAfter: []string{}, + }, + &MockValidator{ + name: "api-enabled", + runAfter: []string{"wif-check"}, + }, + &MockValidator{ + name: "quota-check", + runAfter: []string{"wif-check"}, + }, + &MockValidator{ + name: "iam-check", + runAfter: []string{"api-enabled"}, + }, + &MockValidator{ + name: "network-check", + runAfter: []string{"api-enabled", "quota-check"}, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should create correct levels based on dependencies", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(3)) + + // Level 0: wif-check + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators[0].Metadata().Name).To(Equal("wif-check")) + + // Level 1: api-enabled, quota-check + Expect(groups[1].Level).To(Equal(1)) + Expect(groups[1].Validators).To(HaveLen(2)) + + // Level 2: iam-check, network-check + Expect(groups[2].Level).To(Equal(2)) + Expect(groups[2].Validators).To(HaveLen(2)) + }) + }) + + Context("with dependencies across multiple levels", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "wif-check", + runAfter: []string{}, + }, + &MockValidator{ + name: "api-enabled", + runAfter: []string{"wif-check"}, + }, + &MockValidator{ + name: "quota-check", + runAfter: []string{"wif-check"}, + }, + &MockValidator{ + name: "network-check", + runAfter: []string{"wif-check", "api-enabled"}, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should place validator at correct level when depending on multiple levels", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(HaveLen(3)) + + // Level 0: wif-check + Expect(groups[0].Level).To(Equal(0)) + Expect(groups[0].Validators).To(HaveLen(1)) + Expect(groups[0].Validators[0].Metadata().Name).To(Equal("wif-check")) + + // Level 1: api-enabled, quota-check + Expect(groups[1].Level).To(Equal(1)) + Expect(groups[1].Validators).To(HaveLen(2)) + names := make([]string, 2) + for i, v := range groups[1].Validators { + names[i] = v.Metadata().Name + } + Expect(names).To(ConsistOf("api-enabled", "quota-check")) + + // Level 2: network-check (depends on both level 0 and level 1) + Expect(groups[2].Level).To(Equal(2)) + Expect(groups[2].Validators).To(HaveLen(1)) + Expect(groups[2].Validators[0].Metadata().Name).To(Equal("network-check")) + }) + }) + + Context("with circular dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{"validator-b"}, + }, + &MockValidator{ + name: "validator-b", + runAfter: []string{"validator-a"}, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should detect the circular dependency and return an error", func() { + _, err := resolver.ResolveExecutionGroups() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("circular dependency")) + }) + }) + + Context("with self-referencing dependency", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{"validator-a"}, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should detect the circular dependency and return an error", func() { + _, err := resolver.ResolveExecutionGroups() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("circular dependency")) + }) + }) + + Context("with multi-level circular dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{"validator-c"}, + }, + &MockValidator{ + name: "validator-b", + runAfter: []string{"validator-a"}, + }, + &MockValidator{ + name: "validator-c", + runAfter: []string{"validator-b"}, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should detect the circular dependency chain and return an error", func() { + _, err := resolver.ResolveExecutionGroups() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("circular dependency")) + }) + }) + + Context("with missing dependency", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{ + name: "validator-a", + runAfter: []string{"non-existent"}, + }, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should handle missing dependencies gracefully", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + // Missing dependencies are ignored, validator runs at level 0 + Expect(groups).To(HaveLen(1)) + Expect(groups[0].Level).To(Equal(0)) + }) + }) + + Context("with empty validator list", func() { + BeforeEach(func() { + validators = []validator.Validator{} + resolver = validator.NewDependencyResolver(validators) + }) + + It("should return empty groups", func() { + groups, err := resolver.ResolveExecutionGroups() + Expect(err).NotTo(HaveOccurred()) + Expect(groups).To(BeEmpty()) + }) + }) + }) + + Describe("ToMermaid", func() { + Context("with validators that have no dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}}, + &MockValidator{name: "validator-b", runAfter: []string{}}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render standalone nodes", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("validator-a")) + Expect(mermaid).To(ContainSubstring("validator-b")) + Expect(mermaid).NotTo(ContainSubstring("-->")) + }) + }) + + Context("with linear dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}}, + &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}}, + &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render dependency arrows", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("validator-b --> validator-a")) + Expect(mermaid).To(ContainSubstring("validator-c --> validator-b")) + }) + }) + + Context("with complex dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "wif-check", runAfter: []string{}}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}}, + &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render all dependency relationships", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("api-enabled --> wif-check")) + Expect(mermaid).To(ContainSubstring("quota-check --> wif-check")) + Expect(mermaid).To(ContainSubstring("network-check --> api-enabled")) + Expect(mermaid).To(ContainSubstring("network-check --> quota-check")) + }) + }) + + Context("with missing dependency", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{"non-existent"}}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should not render edges for missing dependencies but still show the validator as standalone node", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).NotTo(ContainSubstring("-->")) + Expect(mermaid).NotTo(ContainSubstring("non-existent")) + // Validator should still appear as a standalone node since no edges were emitted + Expect(mermaid).To(ContainSubstring("validator-a")) + }) + }) + + Context("with partial missing dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}}, + &MockValidator{name: "validator-b", runAfter: []string{"validator-a", "non-existent"}}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render edges only for existing dependencies and not show validator as standalone", func() { + mermaid := resolver.ToMermaid() + Expect(mermaid).To(ContainSubstring("flowchart TD")) + // Should have edge to existing dependency + Expect(mermaid).To(ContainSubstring("validator-b --> validator-a")) + // Should not reference missing dependency + Expect(mermaid).NotTo(ContainSubstring("non-existent")) + // validator-b should not appear as standalone since it has at least one valid edge + // validator-a should appear as standalone + Expect(mermaid).To(MatchRegexp(`(?m)^\s+validator-a\s*$`)) + }) + }) + }) + + Describe("ToMermaidWithLevels", func() { + Context("with validators that have no dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}}, + &MockValidator{name: "validator-b", runAfter: []string{}}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render all validators in Level 0 subgraph", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0 - 2 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("validator-a")) + Expect(mermaid).To(ContainSubstring("validator-b")) + }) + }) + + Context("with linear dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "validator-a", runAfter: []string{}}, + &MockValidator{name: "validator-b", runAfter: []string{"validator-a"}}, + &MockValidator{name: "validator-c", runAfter: []string{"validator-b"}}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render separate levels with dependency arrows", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 1\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 2\"")) + Expect(mermaid).To(ContainSubstring("validator-b --> validator-a")) + Expect(mermaid).To(ContainSubstring("validator-c --> validator-b")) + }) + }) + + Context("with parallel dependencies", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "wif-check", runAfter: []string{}}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}}, + &MockValidator{name: "network-check", runAfter: []string{"wif-check"}}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should show parallel validators in the same level", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 1 - 3 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("wif-check")) + Expect(mermaid).To(ContainSubstring("api-enabled")) + Expect(mermaid).To(ContainSubstring("quota-check")) + Expect(mermaid).To(ContainSubstring("network-check")) + }) + }) + + Context("with complex dependency graph", func() { + BeforeEach(func() { + validators = []validator.Validator{ + &MockValidator{name: "wif-check", runAfter: []string{}}, + &MockValidator{name: "api-enabled", runAfter: []string{"wif-check"}}, + &MockValidator{name: "quota-check", runAfter: []string{"wif-check"}}, + &MockValidator{name: "iam-check", runAfter: []string{"api-enabled"}}, + &MockValidator{name: "network-check", runAfter: []string{"api-enabled", "quota-check"}}, + } + resolver = validator.NewDependencyResolver(validators) + }) + + It("should render correct levels and all dependency edges", func() { + groups, _ := resolver.ResolveExecutionGroups() + mermaid := resolver.ToMermaidWithLevels(groups) + + Expect(mermaid).To(ContainSubstring("flowchart TD")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 0\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 1 - 2 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("subgraph \"Level 2 - 2 Validators in Parallel\"")) + Expect(mermaid).To(ContainSubstring("api-enabled --> wif-check")) + Expect(mermaid).To(ContainSubstring("quota-check --> wif-check")) + Expect(mermaid).To(ContainSubstring("iam-check --> api-enabled")) + Expect(mermaid).To(ContainSubstring("network-check --> api-enabled")) + Expect(mermaid).To(ContainSubstring("network-check --> quota-check")) + }) + }) + }) +}) diff --git a/validator/pkg/validator/validator.go b/validator/pkg/validator/validator.go new file mode 100644 index 0000000..8c2acaa --- /dev/null +++ b/validator/pkg/validator/validator.go @@ -0,0 +1,104 @@ +package validator + +import ( + "context" + "fmt" + "strings" + "time" +) + +// ValidatorMetadata contains all validator configuration +// This is the single source of truth for validator properties +type ValidatorMetadata struct { + Name string // Unique identifier (e.g., "wif-check") + Description string // Human-readable description + RunAfter []string // Validators this should run after (dependencies) + Tags []string // For grouping/filtering (e.g., "mvp", "network", "quota") +} + +// Validator is the core interface all validators must implement +type Validator interface { + // Metadata returns validator configuration (name, dependencies, etc.) + Metadata() ValidatorMetadata + + // Validate performs the actual validation logic + Validate(ctx context.Context, vctx *Context) *Result +} + +// Status represents the validation outcome +type Status string + +const ( + StatusSuccess Status = "success" + StatusFailure Status = "failure" +) + +// Result represents the outcome of a single validator +type Result struct { + ValidatorName string `json:"validator_name"` + Status Status `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + Details map[string]interface{} `json:"details,omitempty"` + Duration time.Duration `json:"duration_ns"` + Timestamp time.Time `json:"timestamp"` +} + +// AggregatedResult combines all validator results into the expected output format +type AggregatedResult struct { + Status Status `json:"status"` + Reason string `json:"reason"` + Message string `json:"message"` + Details map[string]interface{} `json:"details"` +} + +// Aggregate combines multiple validator results into final output +func Aggregate(results []*Result) *AggregatedResult { + checksRun := len(results) + checksPassed := 0 + var failedChecks []string + var failureDescriptions []string + + // Single pass to collect all failure information + for _, r := range results { + switch r.Status { + case StatusSuccess: + checksPassed++ + case StatusFailure: + failedChecks = append(failedChecks, r.ValidatorName) + failureDescriptions = append(failureDescriptions, fmt.Sprintf("%s (%s)", r.ValidatorName, r.Reason)) + } + } + + details := map[string]interface{}{ + "checks_run": checksRun, + "checks_passed": checksPassed, + "timestamp": time.Now().UTC().Format(time.RFC3339), + "validators": results, + } + + if checksPassed == checksRun { + return &AggregatedResult{ + Status: StatusSuccess, + Reason: "ValidationPassed", + Message: "All GCP validation checks passed successfully", + Details: details, + } + } + + details["failed_checks"] = failedChecks + + // Build informative failure message with pass ratio and reasons + message := fmt.Sprintf("%d validation check(s) failed: %s. Passed: %d/%d", + len(failureDescriptions), + strings.Join(failureDescriptions, ", "), + checksPassed, + checksRun) + + return &AggregatedResult{ + Status: StatusFailure, + Reason: "ValidationFailed", + Message: message, + Details: details, + } +} diff --git a/validator/pkg/validator/validator_suite_test.go b/validator/pkg/validator/validator_suite_test.go new file mode 100644 index 0000000..e36b5a1 --- /dev/null +++ b/validator/pkg/validator/validator_suite_test.go @@ -0,0 +1,13 @@ +package validator_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestValidator(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Validator Suite") +} diff --git a/validator/pkg/validators/api_enabled.go b/validator/pkg/validators/api_enabled.go new file mode 100644 index 0000000..954d7c4 --- /dev/null +++ b/validator/pkg/validators/api_enabled.go @@ -0,0 +1,174 @@ +package validators + +import ( + "context" + "errors" + "fmt" + "log/slog" + "time" + + "google.golang.org/api/googleapi" + "validator/pkg/validator" +) + +const ( + // Timeout for overall API validation + apiValidationTimeout = 2 * time.Minute + // Timeout for individual API check requests + apiRequestTimeout = 30 * time.Second +) + +// extractErrorReason extracts a structured error reason from GCP API errors +// Prioritizes GCP-specific error reasons, falls back to HTTP status code +func extractErrorReason(err error, fallbackReason string) string { + if err == nil { + return fallbackReason + } + + var apiErr *googleapi.Error + if errors.As(err, &apiErr) { + // First, try to get GCP-specific reason (more detailed) + if len(apiErr.Errors) > 0 && apiErr.Errors[0].Reason != "" { + return apiErr.Errors[0].Reason + } + + // No specific reason provided, return generic HTTP code + return fmt.Sprintf("HTTP_%d", apiErr.Code) + } + + // Not a GCP API error, use fallback + return fallbackReason +} + +// APIEnabledValidator checks if required GCP APIs are enabled +type APIEnabledValidator struct{} + +// init registers the APIEnabledValidator with the global validator registry +func init() { + validator.Register(&APIEnabledValidator{}) +} + +// Metadata returns the validator configuration including name, description, and dependencies +func (v *APIEnabledValidator) Metadata() validator.ValidatorMetadata { + return validator.ValidatorMetadata{ + Name: "api-enabled", + Description: "Verify required GCP APIs are enabled in the target project", + RunAfter: []string{}, // No dependencies - WIF is implicitly validated when API calls succeed + Tags: []string{"mvp", "gcp-api"}, + } +} + +// Validate performs the actual validation logic to check if required GCP APIs are enabled +func (v *APIEnabledValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { + slog.Info("Checking if required GCP APIs are enabled") + + // Add timeout for overall validation + ctx, cancel := context.WithTimeout(ctx, apiValidationTimeout) + defer cancel() + + // Get Service Usage client from context (lazy initialization with least privilege) + // Only requests serviceusage.readonly scope when this validator actually runs + svc, err := vctx.GetServiceUsageService(ctx) + if err != nil { + // Log full error for debugging + slog.Error("Failed to get Service Usage client", + "error", err.Error(), + "project_id", vctx.Config.ProjectID) + + // Extract structured reason + reason := extractErrorReason(err, "ServiceUsageClientError") + + return &validator.Result{ + Status: validator.StatusFailure, + Reason: reason, + Message: fmt.Sprintf("Failed to get Service Usage client (check WIF configuration): %v", err), + Details: map[string]interface{}{ + //"error": err.Error(), + "error_type": fmt.Sprintf("%T", err), + "project_id": vctx.Config.ProjectID, + "hint": "Verify WIF annotation on KSA and IAM bindings for GSA", + }, + } + } + + // Check each required API + requiredAPIs := vctx.Config.RequiredAPIs + enabledAPIs := []string{} + disabledAPIs := []string{} + + for _, apiName := range requiredAPIs { + // Add per-request timeout + reqCtx, reqCancel := context.WithTimeout(ctx, apiRequestTimeout) + + serviceName := fmt.Sprintf("projects/%s/services/%s", vctx.Config.ProjectID, apiName) + + slog.Debug("Checking API", "api", apiName) + service, err := svc.Services.Get(serviceName).Context(reqCtx).Do() + reqCancel() // Clean up context + + if err != nil { + // Log full error for debugging + slog.Error("Failed to check API", + "api", apiName, + "error", err.Error(), + "project_id", vctx.Config.ProjectID, + "service_name", serviceName) + + // Extract structured reason + reason := extractErrorReason(err, "APICheckFailed") + + return &validator.Result{ + Status: validator.StatusFailure, + Reason: reason, + Message: fmt.Sprintf("Failed to check API %s: %v", apiName, err), + Details: map[string]interface{}{ + "api": apiName, + //"error": err.Error(), + "error_type": fmt.Sprintf("%T", err), + "project_id": vctx.Config.ProjectID, + "service_name": serviceName, + }, + } + } + + if service.State == "ENABLED" { + enabledAPIs = append(enabledAPIs, apiName) + slog.Debug("API is enabled", "api", apiName) + } else { + disabledAPIs = append(disabledAPIs, apiName) + slog.Warn("API is NOT enabled", "api", apiName, "state", service.State) + } + } + + // Check if any APIs are disabled + if len(disabledAPIs) > 0 { + return &validator.Result{ + Status: validator.StatusFailure, + Reason: "RequiredAPIsDisabled", + Message: fmt.Sprintf("%d required API(s) are not enabled", len(disabledAPIs)), + Details: map[string]interface{}{ + "disabled_apis": disabledAPIs, + "enabled_apis": enabledAPIs, + "project_id": vctx.Config.ProjectID, + "hint": "Enable APIs with: gcloud services enable ", + }, + } + } + + // Build success message based on whether APIs were checked + message := fmt.Sprintf("All %d required APIs are enabled", len(enabledAPIs)) + if len(enabledAPIs) == 0 { + message = "No required APIs to validate" + } + slog.Info(message) + + return &validator.Result{ + Status: validator.StatusSuccess, + Reason: "AllAPIsEnabled", + Message: message, + Details: map[string]interface{}{ + "enabled_apis": enabledAPIs, + "project_id": vctx.Config.ProjectID, + }, + } +} diff --git a/validator/pkg/validators/api_enabled_test.go b/validator/pkg/validators/api_enabled_test.go new file mode 100644 index 0000000..50dafd3 --- /dev/null +++ b/validator/pkg/validators/api_enabled_test.go @@ -0,0 +1,145 @@ +package validators_test + +import ( + "log/slog" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" + "validator/pkg/validators" +) + +var _ = Describe("APIEnabledValidator", func() { + var ( + v *validators.APIEnabledValidator + vctx *validator.Context + ) + + BeforeEach(func() { + v = &validators.APIEnabledValidator{} + + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project") + GinkgoT().Setenv("REQUIRED_APIS", "") + + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + // Use NewContext constructor for proper initialization + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + vctx = validator.NewContext(cfg, logger) + }) + + Describe("Metadata", func() { + It("should return correct metadata", func() { + meta := v.Metadata() + Expect(meta.Name).To(Equal("api-enabled")) + Expect(meta.Description).To(ContainSubstring("GCP APIs")) + Expect(meta.RunAfter).To(BeEmpty()) // No dependencies - WIF is implicitly validated + Expect(meta.Tags).To(ContainElement("mvp")) + Expect(meta.Tags).To(ContainElement("gcp-api")) + }) + + It("should have no dependencies (Level 0)", func() { + meta := v.Metadata() + Expect(meta.RunAfter).To(BeEmpty()) + }) + }) + + Describe("Enabled Status", func() { + Context("when validator is not explicitly disabled", func() { + It("should be enabled by default in config", func() { + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) + Expect(enabled).To(BeTrue()) + }) + }) + + Context("when validator is explicitly disabled", func() { + BeforeEach(func() { + GinkgoT().Setenv("DISABLED_VALIDATORS", "api-enabled") + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should be disabled in config", func() { + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) + Expect(enabled).To(BeFalse()) + }) + }) + + }) + + Describe("Configuration", func() { + It("should use default required APIs", func() { + Expect(vctx.Config.RequiredAPIs).To(ConsistOf( + "compute.googleapis.com", + "iam.googleapis.com", + "cloudresourcemanager.googleapis.com", + )) + }) + + Context("with custom required APIs", func() { + BeforeEach(func() { + GinkgoT().Setenv("REQUIRED_APIS", "storage.googleapis.com,bigquery.googleapis.com") + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should use custom APIs list", func() { + Expect(vctx.Config.RequiredAPIs).To(ConsistOf( + "storage.googleapis.com", + "bigquery.googleapis.com", + )) + }) + }) + + Context("with APIs containing whitespace", func() { + BeforeEach(func() { + GinkgoT().Setenv("REQUIRED_APIS", " storage.googleapis.com , bigquery.googleapis.com ") + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should trim whitespace from API names", func() { + Expect(vctx.Config.RequiredAPIs).To(ConsistOf( + "storage.googleapis.com", + "bigquery.googleapis.com", + )) + }) + }) + }) + + Describe("GCP Project Configuration", func() { + It("should have GCP project ID from config", func() { + Expect(vctx.Config.ProjectID).To(Equal("test-project")) + }) + + Context("with different project ID", func() { + BeforeEach(func() { + GinkgoT().Setenv("PROJECT_ID", "production-project-456") + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should use the specified project ID", func() { + Expect(vctx.Config.ProjectID).To(Equal("production-project-456")) + }) + }) + }) + + // Note: Testing Validate() method requires either: + // 1. A real GCP project with Service Usage API enabled (integration test) + // 2. Mocked GCP client (complex setup) + // These tests would be added in integration test suite +}) diff --git a/validator/pkg/validators/quota_check.go b/validator/pkg/validators/quota_check.go new file mode 100644 index 0000000..a747a17 --- /dev/null +++ b/validator/pkg/validators/quota_check.go @@ -0,0 +1,86 @@ +package validators + +import ( + "context" + "log/slog" + + "validator/pkg/validator" +) + +// QuotaCheckValidator verifies sufficient GCP quota is available +// TODO: Implement actual quota checking logic +type QuotaCheckValidator struct{} + +// init registers the QuotaCheckValidator with the global validator registry +func init() { + validator.Register(&QuotaCheckValidator{}) +} + +// Metadata returns the validator configuration including name, description, and dependencies +func (v *QuotaCheckValidator) Metadata() validator.ValidatorMetadata { + return validator.ValidatorMetadata{ + Name: "quota-check", + Description: "Verify sufficient GCP quota is available (stub - requires implementation)", + RunAfter: []string{"api-enabled"}, // Depends on api-enabled to ensure GCP access works + Tags: []string{"post-mvp", "quota", "stub"}, + } +} + +// Validate performs the actual validation logic (currently a stub returning success) +func (v *QuotaCheckValidator) Validate(ctx context.Context, vctx *validator.Context) *validator.Result { + slog.Info("Running quota check validator (stub implementation)") + + // TODO: Implement actual quota validation + // This should check: + // 1. Compute Engine quota (CPUs, disk, IPs, etc.) + // 2. Use the Compute API to get quota information + // 3. Compare against required resources for cluster creation + // + // Example implementation structure: + // + // // Get Compute service from context (lazy initialization with least privilege) + // computeSvc, err := vctx.GetComputeService(ctx) + // if err != nil { + // return &validator.Result{ + // Status: validator.StatusFailure, + // Reason: "ComputeClientError", + // Message: fmt.Sprintf("Failed to get Compute client: %v", err), + // } + // } + // + // // Get project quota + // project, err := computeSvc.Projects.Get(vctx.Config.ProjectID).Context(ctx).Do() + // if err != nil { + // return &validator.Result{ + // Status: validator.StatusFailure, + // Reason: "QuotaCheckFailed", + // Message: fmt.Sprintf("Failed to get project quota: %v", err), + // } + // } + // + // // Check specific quotas + // for _, quota := range project.Quotas { + // if quota.Metric == "CPUS" && quota.Limit-quota.Usage < requiredCPUs { + // return &validator.Result{ + // Status: validator.StatusFailure, + // Reason: "InsufficientQuota", + // Message: fmt.Sprintf("Insufficient CPU quota: available=%d, required=%d", + // int(quota.Limit-quota.Usage), requiredCPUs), + // } + // } + // } + + slog.Warn("Quota check not yet implemented - returning success by default") + + return &validator.Result{ + Status: validator.StatusSuccess, + Reason: "QuotaCheckStub", + Message: "Quota check validation not yet implemented (stub returning success)", + Details: map[string]interface{}{ + "stub": true, + "implemented": false, + "project_id": vctx.Config.ProjectID, + "note": "This validator needs to be implemented to check actual GCP quotas", + }, + } +} diff --git a/validator/pkg/validators/quota_check_test.go b/validator/pkg/validators/quota_check_test.go new file mode 100644 index 0000000..6f047bc --- /dev/null +++ b/validator/pkg/validators/quota_check_test.go @@ -0,0 +1,101 @@ +package validators_test + +import ( + "context" + "log/slog" + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" + "validator/pkg/validators" +) + +var _ = Describe("QuotaCheckValidator", func() { + var ( + v *validators.QuotaCheckValidator + vctx *validator.Context + ) + + BeforeEach(func() { + v = &validators.QuotaCheckValidator{} + + // Set up minimal config with automatic cleanup + GinkgoT().Setenv("PROJECT_ID", "test-project") + + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + + // Use NewContext constructor for proper initialization + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelWarn, + })) + vctx = validator.NewContext(cfg, logger) + }) + + Describe("Metadata", func() { + It("should return correct metadata", func() { + meta := v.Metadata() + Expect(meta.Name).To(Equal("quota-check")) + Expect(meta.Description).To(ContainSubstring("quota")) + Expect(meta.Description).To(ContainSubstring("stub")) + Expect(meta.RunAfter).To(ConsistOf("api-enabled")) // Depends on api-enabled + Expect(meta.Tags).To(ContainElement("post-mvp")) + Expect(meta.Tags).To(ContainElement("quota")) + Expect(meta.Tags).To(ContainElement("stub")) + }) + + It("should depend on api-enabled (Level 1)", func() { + meta := v.Metadata() + Expect(meta.RunAfter).To(ConsistOf("api-enabled")) + }) + }) + + Describe("Enabled Status", func() { + Context("when validator is not explicitly disabled", func() { + It("should be enabled by default in config", func() { + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) + Expect(enabled).To(BeTrue()) + }) + }) + + Context("when validator is explicitly disabled", func() { + BeforeEach(func() { + GinkgoT().Setenv("DISABLED_VALIDATORS", "quota-check") + cfg, err := config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + vctx.Config = cfg + }) + + It("should be disabled in config", func() { + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) + Expect(enabled).To(BeFalse()) + }) + }) + + }) + + Describe("Validate", func() { + It("should return success with stub message", func() { + ctx := context.Background() + result := v.Validate(ctx, vctx) + Expect(result).NotTo(BeNil()) + Expect(result.Status).To(Equal(validator.StatusSuccess)) + Expect(result.Reason).To(Equal("QuotaCheckStub")) + Expect(result.Message).To(ContainSubstring("not yet implemented")) + }) + + It("should include stub metadata in details", func() { + ctx := context.Background() + result := v.Validate(ctx, vctx) + Expect(result.Details).To(HaveKey("stub")) + Expect(result.Details["stub"]).To(BeTrue()) + Expect(result.Details).To(HaveKey("implemented")) + Expect(result.Details["implemented"]).To(BeFalse()) + }) + }) +}) diff --git a/validator/pkg/validators/validators_suite_test.go b/validator/pkg/validators/validators_suite_test.go new file mode 100644 index 0000000..4e4c55f --- /dev/null +++ b/validator/pkg/validators/validators_suite_test.go @@ -0,0 +1,13 @@ +package validators_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestValidators(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Validators Suite") +} diff --git a/validator/test/integration/README.md b/validator/test/integration/README.md new file mode 100644 index 0000000..4a3b9bb --- /dev/null +++ b/validator/test/integration/README.md @@ -0,0 +1,49 @@ +# Integration Tests + +This directory contains **real integration tests** that interact with actual GCP APIs to validate the validator implementation. + +## ⚠️ Requirements + +These tests **require**: + +1. **Real GCP Project** - with a valid PROJECT_ID +2. **Valid GCP Authentication** - one of: + - Workload Identity Federation (WIF) in Kubernetes + - Service Account key file + - Application Default Credentials (ADC) via `gcloud auth` +3. **Network Access** - to GCP APIs (*.googleapis.com) +4. **IAM Permissions** - on the target GCP project: + - `serviceusage.services.get` (Service Usage Viewer) + - `resourcemanager.projects.get` (Project Viewer) + - `compute.projects.get` (Compute Viewer) + - `iam.roles.get` (IAM Role Viewer) + +## 🚀 Running Tests Locally + +### Step 1: Authenticate with GCP + +```bash +# Option A: Use your user credentials (recommended for local dev) +gcloud auth application-default login + +# Option B: Use a service account key file +export GOOGLE_APPLICATION_CREDENTIALS="/path/to/service-account-key.json" +``` + +### Step 2: Set Required Environment Variables + +```bash +export PROJECT_ID="your-gcp-project-id" + +# Optional: Customize API list (defaults are provided) +export REQUIRED_APIS="compute.googleapis.com,iam.googleapis.com,cloudresourcemanager.googleapis.com" + +# Optional: Set log level +export LOG_LEVEL="info" +``` + +### Step 3: Run Integration Tests + +```bash +make test-integration +``` diff --git a/validator/test/integration/context_integration_test.go b/validator/test/integration/context_integration_test.go new file mode 100644 index 0000000..9b3c96c --- /dev/null +++ b/validator/test/integration/context_integration_test.go @@ -0,0 +1,272 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "context" + "log/slog" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" +) + +var _ = Describe("Context Integration Tests", func() { + var ( + ctx context.Context + cancel context.CancelFunc + vctx *validator.Context + cfg *config.Config + logger *slog.Logger + ) + + BeforeEach(func() { + // Create context with reasonable timeout for integration tests + ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second) + + // Set up logger + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + // Load configuration from environment + // Requires: PROJECT_ID environment variable + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred(), "Failed to load config - ensure PROJECT_ID is set") + Expect(cfg.ProjectID).NotTo(BeEmpty(), "PROJECT_ID must be set for integration tests") + + // Create new context with client factory + vctx = validator.NewContext(cfg, logger) + }) + + AfterEach(func() { + cancel() + }) + + Describe("Lazy Initialization with Real GCP Services", func() { + Context("GetServiceUsageService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetServiceUsageService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + // First call - creates the service + svc1, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(svc1).NotTo(BeNil()) + + // Second call - should return cached instance + svc2, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(svc2).NotTo(BeNil()) + + // Verify it's the exact same instance (pointer equality) + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + + It("should successfully make API calls with created service", func() { + svc, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Make a real API call to verify the service works + serviceName := "projects/" + cfg.ProjectID + "/services/compute.googleapis.com" + service, err := svc.Services.Get(serviceName).Context(ctx).Do() + + // This may fail if compute API is not enabled, but shouldn't fail on auth + if err != nil { + // Log the error but don't fail - API might not be enabled + logger.Info("API check failed (might not be enabled)", "error", err.Error()) + } else { + Expect(service).NotTo(BeNil()) + logger.Info("Successfully called Service Usage API", "state", service.State) + } + }) + }) + + Context("GetComputeService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetComputeService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetComputeService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetComputeService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + }) + + Context("GetIAMService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetIAMService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetIAMService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetIAMService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + }) + + Context("GetCloudResourceManagerService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetCloudResourceManagerService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + + It("should successfully make API calls with created service", func() { + svc, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Make a real API call to get project details + project, err := svc.Projects.Get(cfg.ProjectID).Context(ctx).Do() + + Expect(err).NotTo(HaveOccurred(), "Should successfully get project details") + Expect(project).NotTo(BeNil()) + Expect(project.ProjectId).To(Equal(cfg.ProjectID)) + logger.Info("Successfully retrieved project", + "projectId", project.ProjectId, + "projectNumber", project.ProjectNumber, + "state", project.LifecycleState) + }) + }) + + Context("GetMonitoringService", func() { + It("should successfully create service with valid credentials", func() { + svc, err := vctx.GetMonitoringService(ctx) + + Expect(err).NotTo(HaveOccurred(), "Should create service with valid GCP credentials") + Expect(svc).NotTo(BeNil(), "Service should not be nil") + }) + + It("should return cached service on subsequent calls", func() { + svc1, err := vctx.GetMonitoringService(ctx) + Expect(err).NotTo(HaveOccurred()) + + svc2, err := vctx.GetMonitoringService(ctx) + Expect(err).NotTo(HaveOccurred()) + + Expect(svc2).To(BeIdenticalTo(svc1), "Should return cached service instance") + }) + }) + }) + + + Describe("Context Cancellation with Real Services", func() { + It("should respect context timeout during service creation", func() { + // Create a context with very short timeout + shortCtx, shortCancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer shortCancel() + + // Wait for context to expire + time.Sleep(10 * time.Millisecond) + + // Try to create service with expired context + _, err := vctx.GetServiceUsageService(shortCtx) + + // Should fail due to context timeout + // Note: Might still succeed if service was already cached + if err != nil { + Expect(err.Error()).To(Or( + ContainSubstring("context"), + ContainSubstring("deadline"), + ContainSubstring("timeout"), + ), "Error should be context-related") + } + }) + + It("should handle context cancellation gracefully", func() { + cancelCtx, cancelFunc := context.WithCancel(context.Background()) + cancelFunc() // Cancel immediately + + // Create new context (not cached yet) with cancelled context + freshVctx := validator.NewContext(cfg, logger) + + _, err := freshVctx.GetServiceUsageService(cancelCtx) + + // Should fail gracefully (no panic) + if err != nil { + logger.Info("Context cancellation handled", "error", err.Error()) + } + }) + }) + + Describe("Least Privilege Verification", func() { + It("should only create services when getters are called", func() { + // Create a fresh context + freshVctx := validator.NewContext(cfg, logger) + + // At this point, NO services should be created + // We can't directly verify this without exposing internals, + // but we can verify that calling different getters succeeds + + // Call only ServiceUsageService + svc, err := freshVctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + Expect(svc).NotTo(BeNil()) + + // Other services should be lazily created only when needed + // This verifies the lazy initialization pattern + + logger.Info("Verified lazy initialization - service created only when requested") + }) + + It("should create all services when all getters are called", func() { + // Call all getters + computeSvc, err1 := vctx.GetComputeService(ctx) + iamSvc, err2 := vctx.GetIAMService(ctx) + crmSvc, err3 := vctx.GetCloudResourceManagerService(ctx) + suSvc, err4 := vctx.GetServiceUsageService(ctx) + monSvc, err5 := vctx.GetMonitoringService(ctx) + + // All should succeed + Expect(err1).NotTo(HaveOccurred()) + Expect(err2).NotTo(HaveOccurred()) + Expect(err3).NotTo(HaveOccurred()) + Expect(err4).NotTo(HaveOccurred()) + Expect(err5).NotTo(HaveOccurred()) + + Expect(computeSvc).NotTo(BeNil()) + Expect(iamSvc).NotTo(BeNil()) + Expect(crmSvc).NotTo(BeNil()) + Expect(suSvc).NotTo(BeNil()) + Expect(monSvc).NotTo(BeNil()) + + logger.Info("Successfully created all 5 GCP service clients") + }) + }) +}) diff --git a/validator/test/integration/suite_test.go b/validator/test/integration/suite_test.go new file mode 100644 index 0000000..5d5eaf7 --- /dev/null +++ b/validator/test/integration/suite_test.go @@ -0,0 +1,16 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestIntegration(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "Integration Suite") +} diff --git a/validator/test/integration/validator_integration_test.go b/validator/test/integration/validator_integration_test.go new file mode 100644 index 0000000..ca6b797 --- /dev/null +++ b/validator/test/integration/validator_integration_test.go @@ -0,0 +1,256 @@ +//go:build integration +// +build integration + +package integration_test + +import ( + "context" + "log/slog" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "validator/pkg/config" + "validator/pkg/validator" + _ "validator/pkg/validators" // Import to trigger validator registration +) + +var _ = Describe("Validator Integration Tests", func() { + var ( + ctx context.Context + cancel context.CancelFunc + vctx *validator.Context + cfg *config.Config + logger *slog.Logger + ) + + BeforeEach(func() { + // Create context with reasonable timeout + ctx, cancel = context.WithTimeout(context.Background(), 60*time.Second) + + // Set up logger + logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + })) + + // Load configuration from environment + var err error + cfg, err = config.LoadFromEnv() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg.ProjectID).NotTo(BeEmpty(), "PROJECT_ID must be set") + + // Create validation context + vctx = validator.NewContext(cfg, logger) + }) + + AfterEach(func() { + cancel() + }) + + Describe("End-to-End Validator Execution", func() { + Context("with all validators enabled", func() { + It("should execute all enabled validators successfully", func() { + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + + Expect(err).NotTo(HaveOccurred(), "Executor should complete without error") + Expect(results).NotTo(BeEmpty(), "Should have at least one validator result") + + logger.Info("Validator execution completed", + "total_validators", len(results), + "project_id", cfg.ProjectID) + + // Log each result + for _, result := range results { + logger.Info("Validator result", + "name", result.ValidatorName, + "status", result.Status, + "reason", result.Reason, + "message", result.Message, + "duration", result.Duration) + } + }) + }) + + Context("api-enabled validator", func() { + It("should successfully check if required APIs are enabled", func() { + // Get the api-enabled validator + v, exists := validator.Get("api-enabled") + Expect(exists).To(BeTrue(), "api-enabled validator should be registered") + + // Check if it's enabled using config + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) + if !enabled { + Skip("api-enabled validator is disabled in configuration") + } + + // Execute the validator + result := v.Validate(ctx, vctx) + + Expect(result).NotTo(BeNil()) + // Note: ValidatorName is set by Executor, not by Validate method directly + + // Log the result + logger.Info("API enabled check result", + "status", result.Status, + "reason", result.Reason, + "message", result.Message, + "details", result.Details) + + // Verify result structure + // Note: Timestamp, Duration, and ValidatorName are set by Executor, not by Validate directly + Expect(result.Status).To(BeElementOf( + validator.StatusSuccess, + validator.StatusFailure, + ), "Status should be success or failure") + Expect(result.Reason).NotTo(BeEmpty(), "Reason should not be empty") + Expect(result.Message).NotTo(BeEmpty(), "Message should not be empty") + }) + }) + + Context("quota-check validator", func() { + It("should run quota-check validator (stub)", func() { + v, exists := validator.Get("quota-check") + Expect(exists).To(BeTrue(), "quota-check validator should be registered") + + // Check if it's enabled using config + meta := v.Metadata() + enabled := vctx.Config.IsValidatorEnabled(meta.Name) + if !enabled { + Skip("quota-check validator is disabled in configuration") + } + + result := v.Validate(ctx, vctx) + + Expect(result).NotTo(BeNil()) + // Note: ValidatorName is set by Executor, not by Validate method directly + + logger.Info("Quota check result", + "status", result.Status, + "reason", result.Reason, + "message", result.Message) + + // Currently a stub, so should succeed + Expect(result.Status).To(Equal(validator.StatusSuccess)) + }) + }) + }) + + Describe("Validator Aggregation", func() { + It("should aggregate multiple validator results correctly", func() { + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Aggregate results + aggregated := validator.Aggregate(results) + + Expect(aggregated).NotTo(BeNil()) + Expect(aggregated.Status).To(BeElementOf( + validator.StatusSuccess, + validator.StatusFailure, + )) + Expect(aggregated.Message).NotTo(BeEmpty()) + Expect(aggregated.Details).NotTo(BeEmpty()) + + // Extract counts from Details map + checksRun, ok := aggregated.Details["checks_run"].(int) + Expect(ok).To(BeTrue(), "checks_run should be an int") + Expect(checksRun).To(Equal(len(results))) + + checksPassed, ok := aggregated.Details["checks_passed"].(int) + Expect(ok).To(BeTrue(), "checks_passed should be an int") + + successCount := 0 + failureCount := 0 + for _, r := range results { + if r.Status == validator.StatusSuccess { + successCount++ + } else { + failureCount++ + } + } + + Expect(checksPassed).To(Equal(successCount)) + Expect(checksRun - checksPassed).To(Equal(failureCount)) + + logger.Info("Aggregated results", + "status", aggregated.Status, + "checks_run", checksRun, + "checks_passed", checksPassed, + "checks_failed", checksRun-checksPassed, + "message", aggregated.Message) + }) + }) + + Describe("Shared State Between Validators", func() { + It("should maintain shared state in context across validators", func() { + executor := validator.NewExecutor(vctx, logger) + + results, err := executor.ExecuteAll(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Verify results are stored in context + Expect(vctx.Results).To(HaveLen(len(results))) + + for _, result := range results { + Expect(vctx.Results).To(HaveKey(result.ValidatorName)) + Expect(vctx.Results[result.ValidatorName]).To(Equal(result)) + } + + logger.Info("Verified shared state", + "validators_in_context", len(vctx.Results)) + }) + }) + + Describe("Real GCP API Integration", func() { + Context("when checking actual GCP project state", func() { + It("should successfully interact with GCP APIs", func() { + // Get Cloud Resource Manager service + svc, err := vctx.GetCloudResourceManagerService(ctx) + Expect(err).NotTo(HaveOccurred()) + + // Make real API call + project, err := svc.Projects.Get(cfg.ProjectID).Context(ctx).Do() + Expect(err).NotTo(HaveOccurred()) + + Expect(project.ProjectId).To(Equal(cfg.ProjectID)) + Expect(project.ProjectNumber).To(BeNumerically(">", 0)) + Expect(project.LifecycleState).To(Equal("ACTIVE")) + + // Store project number in context (validators might use this) + vctx.ProjectNumber = project.ProjectNumber + + logger.Info("Successfully retrieved real project details", + "projectId", project.ProjectId, + "projectNumber", project.ProjectNumber, + "name", project.Name, + "state", project.LifecycleState) + }) + + It("should successfully check if Compute API is accessible", func() { + svc, err := vctx.GetServiceUsageService(ctx) + Expect(err).NotTo(HaveOccurred()) + + serviceName := "projects/" + cfg.ProjectID + "/services/compute.googleapis.com" + service, err := svc.Services.Get(serviceName).Context(ctx).Do() + + if err != nil { + logger.Warn("Failed to get Compute API status", "error", err.Error()) + // Skip test - API might not be enabled + Skip("Compute API not accessible: " + err.Error()) + } + + Expect(service).NotTo(BeNil()) + logger.Info("Compute API status", + "name", service.Name, + "state", service.State) + }) + }) + }) +})