diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 298bd7b4..661e74ad 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,7 @@ repos: # File hygiene - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v6.0.0 # v6.0.0 + rev: 3e8a8703264a2f4a69428a0aa4dcb512790b2c8c # v6.0.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer diff --git a/Dockerfile b/Dockerfile index ef854f8e..9ba2afa3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ ARG BASE_IMAGE=registry.access.redhat.com/ubi9-micro:latest -FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1781757851 AS builder +FROM registry.access.redhat.com/ubi9/go-toolset:9.8-1779959429 AS builder ARG GIT_SHA=unknown ARG GIT_DIRTY="" diff --git a/README.md b/README.md index 38f74bad..110a9aa2 100644 --- a/README.md +++ b/README.md @@ -28,13 +28,13 @@ The adapter requires a running message broker and HyperFleet API. The [hyperflee |---------|-----------------| | `make local-up-gcp` | GKE cluster + images + API + adapters + Maestro | | `make install-hyperfleet` | Everything on an existing K8s cluster using RabbitMQ (no GCP needed) | -| `make install-adapters` | Install sample Hyperfleet Adapters | +| `make install-hyperfleet-adapters` | Install sample Hyperfleet Adapters | | `make status` | Verify the deployment | Make sure you define the following environment variables: * `HELMFILE_ENV`: accepted values : `kind`, `gcp` * `NAMESPACE`: namespace where HyperFleet components will be deployed -* `REGISTRY`: The registry namespace from which to pull the images. `quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet` for released images +* `REGISTRY`: The registry namespace from which to pull the images. `quay.io/openshift-hyperfleet` for released images * `API_IMAGE_TAG`: image tag for `hyperfleet-api` container image * `SENTINEL_IMAGE_TAG`: image tag for `hyperfleet-sentinel` container image * `ADAPTER_IMAGE_TAG`: image tag for `hyperfleet-adapter` container image diff --git a/charts/examples/kubernetes/dryrun-discovery.json b/charts/examples/kubernetes/dryrun-discovery.json deleted file mode 100644 index 605ce8e9..00000000 --- a/charts/examples/kubernetes/dryrun-discovery.json +++ /dev/null @@ -1,75 +0,0 @@ -{ - "abc123-k8s": { - "apiVersion": "v1", - "kind": "Namespace", - "metadata": { - "name": "abc123-k8s", - "uid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "resourceVersion": "100", - "creationTimestamp": "2026-02-18T10:22:10Z", - "labels": { - "hyperfleet.io/cluster-id": "abc123", - "hyperfleet.io/cluster-name": "abc123", - "hyperfleet.io/managed-by": "kubernetes-example", - "hyperfleet.io/resource-type": "namespace" - }, - "annotations": { - "hyperfleet.io/created-by": "hyperfleet-adapter", - "hyperfleet.io/generation": "77" - } - }, - "spec": { - "finalizers": ["kubernetes"] - }, - "status": { - "phase": "Active" - } - }, - "hello-world-job": { - "apiVersion": "batch/v1", - "kind": "Job", - "metadata": { - "name": "hello-world-job", - "namespace": "abc123-k8s", - "uid": "b2c3d4e5-f6a7-8901-bcde-f12345678901", - "resourceVersion": "200", - "creationTimestamp": "2026-02-18T10:22:11Z", - "labels": { - "hyperfleet.io/cluster-id": "abc123", - "hyperfleet.io/resource-type": "job" - }, - "annotations": { - "hyperfleet.io/generation": "77" - } - }, - "spec": { - "backoffLimit": 0, - "template": { - "spec": { - "restartPolicy": "Never", - "containers": [ - { - "name": "hello-world", - "image": "alpine:3.19" - } - ] - } - } - }, - "status": { - "conditions": [ - { - "type": "Complete", - "status": "True", - "reason": "Completed", - "message": "Job completed successfully", - "lastTransitionTime": "2026-02-18T10:22:30Z" - } - ], - "startTime": "2026-02-18T10:22:12Z", - "completionTime": "2026-02-18T10:22:30Z", - "succeeded": 1, - "active": 0 - } - } -} diff --git a/charts/examples/kubernetes/dryrun.sh b/charts/examples/kubernetes/dryrun.sh deleted file mode 100755 index 72ad4952..00000000 --- a/charts/examples/kubernetes/dryrun.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Run the kubernetes chart example as a dry-run inside a container. -# The binary is built on the host (for Linux, matching host architecture) and -# mounted into a minimal container. The resource file is mounted to /etc/adapter/ -# so that the task config's manifest.ref: /etc/adapter/job.yaml resolves correctly. -# -# Execute from the repository root: -# bash charts/examples/kubernetes/dryrun.sh - -REPO_ROOT=$(git rev-parse --show-toplevel) -EXAMPLE_DIR="charts/examples/kubernetes" -DRYRUN_DIR="test/testdata/dryrun" -# Map host arch to Go/Docker equivalents -case "$(uname -m)" in - arm64|aarch64) GOARCH=arm64; PLATFORM=linux/arm64 ;; - x86_64|amd64) GOARCH=amd64; PLATFORM=linux/amd64 ;; - *) echo "Unsupported architecture: $(uname -m)"; exit 1 ;; -esac - -BINARY="$REPO_ROOT/bin/hyperfleet-adapter-linux-$GOARCH" - -if [[ ! -f "$BINARY" ]]; then - echo "Binary not found at bin/hyperfleet-adapter-linux-$GOARCH — building for linux/$GOARCH..." - (cd "$REPO_ROOT" && GOOS=linux GOARCH=$GOARCH CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$BINARY" ./cmd/adapter) -fi - -CONTAINER_TOOL=$(command -v podman 2>/dev/null || command -v docker 2>/dev/null) - -$CONTAINER_TOOL run --rm \ - --platform "$PLATFORM" \ - -v "$BINARY":/usr/local/bin/hyperfleet-adapter:z \ - -v "$REPO_ROOT/$EXAMPLE_DIR/adapter-task-config.yaml":/etc/adapter/adapter-task-config.yaml:z \ - -v "$REPO_ROOT/$EXAMPLE_DIR/adapter-task-resource-job.yaml":/etc/adapter/job.yaml:z \ - -v "$REPO_ROOT/$EXAMPLE_DIR/dryrun-discovery.json":/example/dryrun-discovery.json:z \ - -v "$REPO_ROOT/$DRYRUN_DIR":/dryrun:z \ - alpine:3.21 \ - /usr/local/bin/hyperfleet-adapter serve \ - --dry-run-verbose \ - --config /dryrun/dryrun-kubernetes-adapter-config.yaml \ - --task-config /etc/adapter/adapter-task-config.yaml \ - --dry-run-event /dryrun/event.json \ - --dry-run-api-responses /dryrun/dryrun-api-responses.json \ - --dry-run-discovery /example/dryrun-discovery.json diff --git a/charts/examples/maestro-two-resources/dryrun-discovery.json b/charts/examples/maestro-two-resources/dryrun-discovery.json deleted file mode 100644 index 4daf7ba4..00000000 --- a/charts/examples/maestro-two-resources/dryrun-discovery.json +++ /dev/null @@ -1,209 +0,0 @@ -{ - "abc123-mw2-cm": { - "apiVersion": "work.open-cluster-management.io/v1", - "kind": "ManifestWork", - "metadata": { - "name": "abc123-mw2-cm", - "namespace": "cluster1", - "uid": "aa111111-36df-5ebf-9007-87a65a27dfab", - "resourceVersion": "1", - "creationTimestamp": "2026-02-18T10:22:10Z", - "generation": 1, - "labels": { - "hyperfleet.io/cluster-id": "abc123", - "hyperfleet.io/adapter": "maestro-two-resources-example", - "hyperfleet.io/component": "configuration", - "hyperfleet.io/generation": "77", - "maestro.io/source-id": "maestro-two-resources-example" - } - }, - "spec": { - "workload": { - "manifests": [ - { - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { - "name": "cluster-config", - "namespace": "abc123-mw2", - "labels": { - "hyperfleet.io/cluster-id": "abc123", - "hyperfleet.io/cluster-name": "abc123", - "hyperfleet.io/managed-by": "maestro-two-resources-example" - } - }, - "data": { - "cluster_id": "abc123", - "cluster_name": "abc123" - } - } - ] - }, - "deleteOption": { - "propagationPolicy": "Background" - } - }, - "status": { - "conditions": [ - { - "type": "Applied", - "status": "True", - "reason": "AppliedManifestWorkComplete", - "message": "Apply manifest work complete", - "lastTransitionTime": "2026-02-18T10:22:10Z", - "observedGeneration": 1 - }, - { - "type": "Available", - "status": "True", - "reason": "ResourcesAvailable", - "message": "All resources are available", - "lastTransitionTime": "2026-02-18T10:22:10Z", - "observedGeneration": 1 - } - ], - "resourceStatus": { - "manifests": [ - { - "resourceMeta": { - "ordinal": 0, - "group": "", - "version": "v1", - "kind": "ConfigMap", - "resource": "configmaps", - "name": "cluster-config", - "namespace": "abc123-mw2" - }, - "conditions": [ - { - "type": "Applied", - "status": "True", - "reason": "AppliedManifestComplete", - "message": "Apply manifest complete", - "lastTransitionTime": "2026-02-18T10:22:10Z" - }, - { - "type": "Available", - "status": "True", - "reason": "ResourceAvailable", - "message": "Resource is available", - "lastTransitionTime": "2026-02-18T10:22:10Z" - } - ], - "statusFeedback": {} - } - ] - } - } - }, - "abc123-mw2": { - "apiVersion": "work.open-cluster-management.io/v1", - "kind": "ManifestWork", - "metadata": { - "name": "abc123-mw2", - "namespace": "cluster1", - "uid": "bb222222-36df-5ebf-9007-87a65a27dfab", - "resourceVersion": "1", - "creationTimestamp": "2026-02-18T10:22:10Z", - "generation": 1, - "labels": { - "hyperfleet.io/cluster-id": "abc123", - "hyperfleet.io/adapter": "maestro-two-resources-example", - "hyperfleet.io/component": "infrastructure", - "hyperfleet.io/generation": "77", - "maestro.io/source-id": "maestro-two-resources-example" - } - }, - "spec": { - "workload": { - "manifests": [ - { - "apiVersion": "v1", - "kind": "Namespace", - "metadata": { - "name": "abc123-mw2", - "labels": { - "hyperfleet.io/cluster-id": "abc123", - "hyperfleet.io/cluster-name": "abc123", - "hyperfleet.io/managed-by": "maestro-two-resources-example", - "hyperfleet.io/resource-type": "namespace" - } - } - } - ] - }, - "deleteOption": { - "propagationPolicy": "Foreground" - } - }, - "status": { - "conditions": [ - { - "type": "Applied", - "status": "True", - "reason": "AppliedManifestWorkComplete", - "message": "Apply manifest work complete", - "lastTransitionTime": "2026-02-18T10:22:10Z", - "observedGeneration": 1 - }, - { - "type": "Available", - "status": "True", - "reason": "ResourcesAvailable", - "message": "All resources are available", - "lastTransitionTime": "2026-02-18T10:22:10Z", - "observedGeneration": 1 - } - ], - "resourceStatus": { - "manifests": [ - { - "resourceMeta": { - "ordinal": 0, - "group": "", - "version": "v1", - "kind": "Namespace", - "resource": "namespaces", - "name": "abc123-mw2", - "namespace": "" - }, - "conditions": [ - { - "type": "Applied", - "status": "True", - "reason": "AppliedManifestComplete", - "message": "Apply manifest complete", - "lastTransitionTime": "2026-02-18T10:22:10Z" - }, - { - "type": "Available", - "status": "True", - "reason": "ResourceAvailable", - "message": "Resource is available", - "lastTransitionTime": "2026-02-18T10:22:10Z" - }, - { - "type": "StatusFeedbackSynced", - "status": "True", - "reason": "StatusFeedbackSynced", - "message": "", - "lastTransitionTime": "2026-02-18T10:22:10Z" - } - ], - "statusFeedback": { - "values": [ - { - "name": "phase", - "fieldValue": { - "type": "String", - "string": "Active" - } - } - ] - } - } - ] - } - } - } -} diff --git a/charts/examples/maestro-two-resources/dryrun.sh b/charts/examples/maestro-two-resources/dryrun.sh deleted file mode 100755 index a3e2cbe5..00000000 --- a/charts/examples/maestro-two-resources/dryrun.sh +++ /dev/null @@ -1,49 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Run the maestro-two-resources chart example as a dry-run inside a container. -# The binary is built on the host (for Linux, matching host architecture) and -# mounted into a minimal container. The two resource files are mounted to /etc/adapter/ -# so that the task config's manifest.ref paths resolve correctly: -# /etc/adapter/manifestwork-configmap.yaml -# /etc/adapter/manifestwork-namespace.yaml -# -# Execute from the repository root: -# bash charts/examples/maestro-two-resources/dryrun.sh - -REPO_ROOT=$(git rev-parse --show-toplevel) -EXAMPLE_DIR="charts/examples/maestro-two-resources" -DRYRUN_DIR="test/testdata/dryrun" - -# Map host arch to Go/Docker equivalents -case "$(uname -m)" in - arm64|aarch64) GOARCH=arm64; PLATFORM=linux/arm64 ;; - x86_64|amd64) GOARCH=amd64; PLATFORM=linux/amd64 ;; - *) echo "Unsupported architecture: $(uname -m)"; exit 1 ;; -esac - -BINARY="$REPO_ROOT/bin/hyperfleet-adapter-linux-$GOARCH" - -if [[ ! -f "$BINARY" ]]; then - echo "Binary not found at bin/hyperfleet-adapter-linux-$GOARCH — building for linux/$GOARCH..." - (cd "$REPO_ROOT" && GOOS=linux GOARCH=$GOARCH CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$BINARY" ./cmd/adapter) -fi - -CONTAINER_TOOL=$(command -v podman 2>/dev/null || command -v docker 2>/dev/null) - -$CONTAINER_TOOL run --rm \ - --platform "$PLATFORM" \ - -v "$BINARY":/usr/local/bin/hyperfleet-adapter:z \ - -v "$REPO_ROOT/$EXAMPLE_DIR/adapter-task-config.yaml":/etc/adapter/adapter-task-config.yaml:z \ - -v "$REPO_ROOT/$EXAMPLE_DIR/adapter-task-resource-manifestwork-configmap.yaml":/etc/adapter/manifestwork-configmap.yaml:z \ - -v "$REPO_ROOT/$EXAMPLE_DIR/adapter-task-resource-manifestwork-namespace.yaml":/etc/adapter/manifestwork-namespace.yaml:z \ - -v "$REPO_ROOT/$EXAMPLE_DIR/dryrun-discovery.json":/example/dryrun-discovery.json:z \ - -v "$REPO_ROOT/$DRYRUN_DIR":/dryrun:z \ - alpine:3.21 \ - /usr/local/bin/hyperfleet-adapter serve \ - --dry-run-verbose \ - --config /dryrun/dryrun-maestro-adapter-config.yaml \ - --task-config /etc/adapter/adapter-task-config.yaml \ - --dry-run-event /dryrun/event.json \ - --dry-run-api-responses /dryrun/dryrun-api-responses.json \ - --dry-run-discovery /example/dryrun-discovery.json diff --git a/charts/examples/maestro/dryrun-discovery.json b/charts/examples/maestro/dryrun-discovery.json deleted file mode 100644 index e1befce1..00000000 --- a/charts/examples/maestro/dryrun-discovery.json +++ /dev/null @@ -1,158 +0,0 @@ -{ - "abc123-mw": { - "apiVersion": "work.open-cluster-management.io/v1", - "kind": "ManifestWork", - "metadata": { - "name": "abc123-mw", - "namespace": "cluster1", - "uid": "fc58b0b8-36df-5ebf-9007-87a65a27dfab", - "resourceVersion": "1", - "creationTimestamp": "2026-02-18T10:22:10Z", - "generation": 1, - "labels": { - "hyperfleet.io/cluster-id": "abc123", - "hyperfleet.io/adapter": "maestro-example", - "hyperfleet.io/component": "infrastructure", - "hyperfleet.io/generation": "77", - "hyperfleet.io/resource-group": "cluster-setup", - "maestro.io/source-id": "maestro-example", - "app.kubernetes.io/managed-by": "maestro-example" - } - }, - "spec": { - "workload": { - "manifests": [ - { - "apiVersion": "v1", - "kind": "Namespace", - "metadata": { - "name": "abc123-mw", - "labels": { - "hyperfleet.io/cluster-id": "abc123", - "hyperfleet.io/managed-by": "maestro-example", - "hyperfleet.io/resource-type": "namespace" - } - } - }, - { - "apiVersion": "v1", - "kind": "ConfigMap", - "metadata": { - "name": "cluster-config", - "namespace": "abc123-mw", - "labels": { - "hyperfleet.io/cluster-id": "abc123", - "hyperfleet.io/cluster-name": "abc123" - } - }, - "data": { - "cluster_id": "abc123", - "cluster_name": "abc123" - } - } - ] - }, - "deleteOption": { - "propagationPolicy": "Foreground", - "gracePeriodSeconds": 30 - } - }, - "status": { - "conditions": [ - { - "type": "Applied", - "status": "True", - "reason": "AppliedManifestWorkComplete", - "message": "Apply manifest work complete", - "lastTransitionTime": "2026-02-18T10:22:10Z", - "observedGeneration": 1 - }, - { - "type": "Available", - "status": "True", - "reason": "ResourcesAvailable", - "message": "All resources are available", - "lastTransitionTime": "2026-02-18T10:22:10Z", - "observedGeneration": 1 - } - ], - "resourceStatus": { - "manifests": [ - { - "resourceMeta": { - "ordinal": 0, - "group": "", - "version": "v1", - "kind": "Namespace", - "resource": "namespaces", - "name": "abc123-mw", - "namespace": "" - }, - "conditions": [ - { - "type": "Applied", - "status": "True", - "reason": "AppliedManifestComplete", - "message": "Apply manifest complete", - "lastTransitionTime": "2026-02-18T10:22:10Z" - }, - { - "type": "Available", - "status": "True", - "reason": "ResourceAvailable", - "message": "Resource is available", - "lastTransitionTime": "2026-02-18T10:22:10Z" - }, - { - "type": "StatusFeedbackSynced", - "status": "True", - "reason": "StatusFeedbackSynced", - "message": "", - "lastTransitionTime": "2026-02-18T10:22:10Z" - } - ], - "statusFeedback": { - "values": [ - { - "name": "phase", - "fieldValue": { - "type": "String", - "string": "Active" - } - } - ] - } - }, - { - "resourceMeta": { - "ordinal": 1, - "group": "", - "version": "v1", - "kind": "ConfigMap", - "resource": "configmaps", - "name": "cluster-config", - "namespace": "abc123-mw" - }, - "conditions": [ - { - "type": "Applied", - "status": "True", - "reason": "AppliedManifestComplete", - "message": "Apply manifest complete", - "lastTransitionTime": "2026-02-18T10:22:10Z" - }, - { - "type": "Available", - "status": "True", - "reason": "ResourceAvailable", - "message": "Resource is available", - "lastTransitionTime": "2026-02-18T10:22:10Z" - } - ], - "statusFeedback": {} - } - ] - } - } - } -} diff --git a/charts/examples/maestro/dryrun.sh b/charts/examples/maestro/dryrun.sh deleted file mode 100755 index 827b3bfd..00000000 --- a/charts/examples/maestro/dryrun.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Run the maestro chart example as a dry-run inside a container. -# The binary is built on the host (for Linux, matching host architecture) and -# mounted into a minimal container. The resource file is mounted to /etc/adapter/ -# so that the task config's manifest.ref: /etc/adapter/manifestwork.yaml resolves correctly. -# -# Execute from the repository root: -# bash charts/examples/maestro/dryrun.sh - -REPO_ROOT=$(git rev-parse --show-toplevel) -EXAMPLE_DIR="charts/examples/maestro" -DRYRUN_DIR="test/testdata/dryrun" - -# Map host arch to Go/Docker equivalents -case "$(uname -m)" in - arm64|aarch64) GOARCH=arm64; PLATFORM=linux/arm64 ;; - x86_64|amd64) GOARCH=amd64; PLATFORM=linux/amd64 ;; - *) echo "Unsupported architecture: $(uname -m)"; exit 1 ;; -esac - -BINARY="$REPO_ROOT/bin/hyperfleet-adapter-linux-$GOARCH" - -if [[ ! -f "$BINARY" ]]; then - echo "Binary not found at bin/hyperfleet-adapter-linux-$GOARCH — building for linux/$GOARCH..." - (cd "$REPO_ROOT" && GOOS=linux GOARCH=$GOARCH CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o "$BINARY" ./cmd/adapter) -fi - -CONTAINER_TOOL=$(command -v podman 2>/dev/null || command -v docker 2>/dev/null) - -$CONTAINER_TOOL run --rm \ - --platform "$PLATFORM" \ - -v "$BINARY":/usr/local/bin/hyperfleet-adapter:z \ - -v "$REPO_ROOT/$EXAMPLE_DIR/adapter-task-config.yaml":/etc/adapter/adapter-task-config.yaml:z \ - -v "$REPO_ROOT/$EXAMPLE_DIR/adapter-task-resource-manifestwork.yaml":/etc/adapter/manifestwork.yaml:z \ - -v "$REPO_ROOT/$EXAMPLE_DIR/dryrun-discovery.json":/example/dryrun-discovery.json:z \ - -v "$REPO_ROOT/$DRYRUN_DIR":/dryrun:z \ - alpine:3.21 \ - /usr/local/bin/hyperfleet-adapter serve \ - --dry-run-verbose \ - --config /dryrun/dryrun-maestro-adapter-config.yaml \ - --task-config /etc/adapter/adapter-task-config.yaml \ - --dry-run-event /dryrun/event.json \ - --dry-run-api-responses /dryrun/dryrun-api-responses.json \ - --dry-run-discovery /example/dryrun-discovery.json diff --git a/docs/adapter-authoring-guide.md b/docs/adapter-authoring-guide.md index e5b9d347..642933b4 100644 --- a/docs/adapter-authoring-guide.md +++ b/docs/adapter-authoring-guide.md @@ -244,7 +244,84 @@ preconditions: retry_backoff: "exponential" # also: linear, constant ``` -URLs are **relative** — the base URL comes from the `AdapterConfig` `clients.hyperfleet_api.base_url` setting. You only write the path. +URLs are **relative** — the base URL comes from the `AdapterConfig` `clients.hyperfleet_api.base_url` setting. You only write the path. Absolute URLs are also accepted and bypass the base URL. + +**`api_call` fields:** + +| Field | Type | Description | +|-------|------|-------------| +| `method` | string | HTTP method: `GET`, `POST`, `PUT`, `PATCH`, `DELETE` | +| `url` | string | URL or path. Supports Go template rendering against params. | +| `body` | string | Request body for `POST`/`PUT`/`PATCH`. Supports Go template rendering. | +| `headers` | list | Additional HTTP headers. Each entry has `name` and `value` (value supports Go templates). | +| `authorization` | object | Bearer token injected as `Authorization: Bearer `. See [Authenticating API calls](#authenticating-api-calls). Overrides any `Authorization` entry in `headers`. | +| `timeout` | duration | Per-request timeout (e.g. `10s`, `30s`). Overrides the client default. | +| `retry_attempts` | int | Number of retry attempts on failure. | +| `retry_backoff` | string | Backoff strategy: `exponential` (default), `linear`, `constant`. | + +### Authenticating API calls + +Use the `authorization` block to inject a `Bearer` token into the `Authorization` header. Two sources are supported: + +#### Static token + +The token value is a Go template rendered against the current params. Use a param sourced from an environment variable to avoid hardcoding secrets: + +```yaml +# In params config — source the token from an env var +params: + - name: "apiToken" + source: "env.MY_API_TOKEN" + +# In the api_call +preconditions: + - name: "fetchData" + api_call: + method: "GET" + url: "https://external-service/api/resource" + authorization: + type: static + token: "{{ .apiToken }}" +``` + +#### Kubernetes ServiceAccount token + +When the adapter runs inside a Kubernetes cluster, it can use the pod's ServiceAccount identity. + +**Mounted token (no audience)** — reads the token file projected into every pod at `/var/run/secrets/kubernetes.io/serviceaccount/token`. Simple and always available in-cluster: + +```yaml +api_call: + method: "GET" + url: "https://internal-service/api/data" + authorization: + type: kubernetes +``` + +**TokenRequest (with audience)** — calls the Kubernetes TokenRequest API to create a short-lived bound token for a specific audience. Requires RBAC permission to create tokens for the ServiceAccount: + +```yaml +api_call: + method: "GET" + url: "https://internal-service/api/data" + authorization: + type: kubernetes + audience: "internal-service" + service_account: "hyperfleet-adapter" # SA to bind the token to + namespace: "hyperfleet" # defaults to pod's own namespace + expiration_seconds: 1800 # defaults to 3600 +``` + +**`authorization` fields:** + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `type` | string | yes | `static` or `kubernetes` | +| `token` | string | if `type=static` | Bearer token value. Supports Go template rendering. | +| `audience` | string | no | For `type=kubernetes`: triggers TokenRequest API for this audience. When omitted, reads the mounted SA token file. | +| `service_account` | string | if `audience` set | ServiceAccount name to create the TokenRequest for. | +| `namespace` | string | no | Namespace of the ServiceAccount. Defaults to the pod's own namespace. | +| `expiration_seconds` | int | no | Token lifetime in seconds (default: 3600). Only applies to TokenRequest. | ### Capturing fields @@ -785,29 +862,24 @@ The resource executor treats apply and delete operations differently when they f This means a list containing both apply and delete operations behaves predictably: a delete failure does not prevent the next resource from being deleted, but an apply failure stops further processing. -### Resource not found (404 handling) - -When a precondition API call returns `404 Not Found`, it can mean the resource no longer exists (e.g., deleted externally, incorrect ID in the event, direct DB removal) or that the precondition URL itself is misconfigured. The adapter distinguishes between two types of 404: - -- **Resource not found** (default): any 404 is treated as a legitimate "resource does not exist" unless proven otherwise. This includes responses with specific error codes (`HYPERFLEET-NTF-001`, `HYPERFLEET-NTF-002`, `HYPERFLEET-NTF-003`), as well as 404s where the response body was stripped by a proxy or gateway. The adapter handles this gracefully — resources are skipped and post-actions still execute. -- **Broken endpoint** (error code `HYPERFLEET-NTF-000`): the catch-all 404 handler confirms no route matched the URL. The adapter treats this as a configuration error and reports failure status. +### Force-deleted resources (404 handling) -When the adapter detects a resource-not-found 404: +When a resource is force-deleted externally (e.g., removed from the HyperFleet API while the adapter is running), the precondition API call returns a `404 Not Found`. Instead of treating this as a hard failure, the adapter handles it gracefully: - `adapter.resourcesSkipped` is set to `true` - `adapter.skipReason` is set to `"ResourceNotFound"` - The resources phase is skipped entirely - Post-actions still execute, so the adapter can report the skip back to the API -This means your post-action CEL expressions can detect the missing resource and report an appropriate status: +This means your post-action CEL expressions can detect force-deletion and report an appropriate status: ```cel adapter.?skipReason.orValue("") == "ResourceNotFound" - ? "Resource does not exist" + ? "Resource was deleted externally" : adapter.?skipReason.orValue("unknown reason") ``` -The same 404 handling applies during post-action execution: a post-action 404 is treated as resource-not-found and remaining post-actions are skipped gracefully, unless the response contains error code `HYPERFLEET-NTF-000` indicating a misconfigured URL. +The same 404 handling applies during post-action execution: if a post-action API call returns 404, remaining post-actions are skipped gracefully rather than failed. ### Partial delete failures @@ -1758,4 +1830,4 @@ See also [Preconditions — Supported operators](#supported-operators). | `CEL expression parse error` | Invalid CEL syntax | Verify parentheses, string quoting, and optional chaining syntax (`?.` for safe field access). | | Discovery returns empty | Labels don't match or wrong namespace | Verify `discovery.namespace` is correct. Use `by_name` for a simpler lookup. Check resource labels match the selector exactly. | | `observed_generation` is a string | Using Go Template instead of CEL expression | Use `expression: "generation"` instead of `"{{ .generation }}"`. | -| Post-action API call returns 404 with error status | Wrong status endpoint path (error code `HYPERFLEET-NTF-000`) | Cluster statuses: `/clusters/{id}/statuses`. NodePool statuses: `/clusters/{id}/nodepools/{id}/statuses`. | +| Post-action API call returns 404 | Wrong status endpoint path | Cluster statuses: `/clusters/{id}/statuses`. NodePool statuses: `/clusters/{id}/nodepools/{id}/statuses`. | diff --git a/docs/deployment.md b/docs/deployment.md index 2850652a..a3121eac 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -6,8 +6,6 @@ This guide explains how to configure and deploy an adapter instance using the He ## Configuration Overview -The HyperFleet Adapter Helm chart is released as an OCI artifact at `oci://quay.io/redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-adapter-chart`. - An adapter deployment requires three pieces of configuration, all settable through Helm values: | Config | Helm value | Purpose | @@ -203,6 +201,62 @@ gcloud projects add-iam-policy-binding MY_PROJECT \ --- +## API Call Authorization + +The adapter injects a bearer token on outbound `api_call` HTTP requests. Authorization can be configured in two places: + +- **Per `api_call` block** — set in the task config `authorization` field. Only applies to that call. +- **Global default** — set in the adapter deployment config (`authorization`). Applied to any `api_call` that has no per-call authorization. + +Per-call authorization always takes precedence over the global default. To use the global default, omit `authorization` from the task config `api_call` block and configure `authorization` in `adapter-config.yaml`: + +```yaml +authorization: + type: kubernetes # or "static" + audience: "hyperfleet-api" # optional: for TokenRequest API + service_account: "hyperfleet-adapter" # required when audience is set +``` + +The `token` field for `type: static` can be injected via the `HYPERFLEET_DEFAULT_API_CALL_AUTH_TOKEN` environment variable to avoid storing secrets in the config file. + +Deployment requirements depend on the authorization mode: + +### `type: static` + +No cluster configuration required. The token value is rendered from a Go template at runtime using the event's params. Inject secrets via environment variables and expose them as params. + +### `type: kubernetes` (mounted token, no `audience`) + +The adapter reads the pod's projected ServiceAccount token from `/var/run/secrets/kubernetes.io/serviceaccount/token`. Kubernetes mounts this automatically — no Helm changes needed unless `automountServiceAccountToken` was explicitly disabled on the pod or ServiceAccount. + +If it was disabled, re-enable it: + +```yaml +serviceAccount: + automountServiceAccountToken: true +``` + +### `type: kubernetes` with `audience` (TokenRequest API) + +The adapter calls the Kubernetes TokenRequest API to create a short-lived bound token. The pod's ServiceAccount needs `create` permission on the `serviceaccounts/token` subresource. + +Use `rbac.rules` (not `rbac.resources`) since subresources require a separate RBAC rule: + +```yaml +rbac: + create: true + rules: + - apiGroups: [""] + resources: ["serviceaccounts/token"] + verbs: ["create"] +``` + +The ClusterRole created by the chart will include this rule alongside any `rbac.resources` entries. + +> **Note:** The TokenRequest is scoped to the namespace where the adapter runs. If `namespace` is not set in the `authorization` block, the adapter reads it from the pod's mounted namespace file — which is the correct default for in-cluster deployments. + +--- + ## Examples ### Minimal (RabbitMQ, chart-packaged files) @@ -210,8 +264,8 @@ gcloud projects add-iam-policy-binding MY_PROJECT \ ```yaml image: registry: quay.io - repository: redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-adapter - tag: + repository: openshift-hyperfleet/hyperfleet-adapter + tag: v0.2.0 adapterConfig: create: true @@ -239,15 +293,15 @@ broker: ```yaml image: registry: quay.io - repository: redhat-services-prod/hyperfleet-tenant/hyperfleet/hyperfleet-adapter - tag: + repository: openshift-hyperfleet/my-adapter + tag: v1.0.0 adapterConfig: create: true yaml: adapter: name: my-adapter - version: "" + version: "1.0.0" clients: hyperfleet_api: base_url: http://hyperfleet-api:8000 diff --git a/go.mod b/go.mod index 1bb970b0..10691cdf 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,13 @@ module github.com/openshift-hyperfleet/hyperfleet-adapter go 1.25.0 require ( - github.com/Masterminds/semver/v3 v3.5.0 + github.com/Masterminds/semver/v3 v3.4.0 github.com/cloudevents/sdk-go/v2 v2.16.2 - github.com/go-playground/validator/v10 v10.30.3 + github.com/go-playground/validator/v10 v10.30.1 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/google/cel-go v0.26.1 github.com/mitchellh/copystructure v1.2.0 - github.com/openshift-hyperfleet/hyperfleet-broker v1.1.1 + github.com/openshift-hyperfleet/hyperfleet-broker v1.1.0 github.com/openshift-online/maestro v0.0.0-20260202062555-48b47506a254 github.com/openshift-online/ocm-sdk-go v0.1.493 github.com/prometheus/client_golang v1.23.2 @@ -26,7 +26,7 @@ require ( go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 - golang.org/x/text v0.37.0 + golang.org/x/text v0.35.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.34.3 k8s.io/client-go v0.34.3 @@ -73,7 +73,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect - github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/gabriel-vasile/mimetype v1.4.12 // indirect github.com/getsentry/sentry-go v0.20.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -164,13 +164,13 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.52.0 // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/term v0.41.0 // indirect golang.org/x/time v0.15.0 // indirect google.golang.org/api v0.274.0 // indirect google.golang.org/genproto v0.0.0-20260523011958-0a33c5d7ca68 // indirect diff --git a/go.sum b/go.sum index f1245781..253fa326 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= -github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE= -github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ThreeDotsLabs/watermill v1.5.1 h1:t5xMivyf9tpmU3iozPqyrCZXHvoV1XQDfihas4sV0fY= @@ -99,8 +99,8 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= -github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= -github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw= +github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/getsentry/sentry-go v0.20.0 h1:bwXW98iMRIWxn+4FgPW7vMrjmbym6HblXALmhjHmQaQ= github.com/getsentry/sentry-go v0.20.0/go.mod h1:lc76E2QywIyW8WuBnwl8Lc4bkmQH4+w1gwTf25trprY= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= @@ -155,8 +155,8 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= -github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= +github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w= +github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= 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= @@ -277,8 +277,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= -github.com/openshift-hyperfleet/hyperfleet-broker v1.1.1 h1:3zbpNuFL+OEvKl6a/KJAlHFcYR4QqQBoGkf35higypU= -github.com/openshift-hyperfleet/hyperfleet-broker v1.1.1/go.mod h1:E7Br4NnsaTTfWR2fEqHAtvFXUAgzFpksF+G5qTBMmy0= +github.com/openshift-hyperfleet/hyperfleet-broker v1.1.0 h1:Shn6ga4PlR5M5K/Pr5O7v7YDjjl1svwwLe48XK10yhI= +github.com/openshift-hyperfleet/hyperfleet-broker v1.1.0/go.mod h1:Wor2yIw6yE4umDFfWKuI8H4glEC7dOEFdbWBoYjU7Nw= github.com/openshift-online/maestro v0.0.0-20260202062555-48b47506a254 h1:v/jYqdzZpzB/bscVpajlbcKgCNeV4tx4fkm5m2JR8Ug= github.com/openshift-online/maestro v0.0.0-20260202062555-48b47506a254/go.mod h1:cyeif610uObNrbcyn5s1fZg7OWseVjaMAqgrEDA2Aec= github.com/openshift-online/ocm-sdk-go v0.1.493 h1:+889zmbwN0guA8LFRr5WHpH2+VJNq8+r0fvrXY+x/6E= @@ -413,8 +413,8 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= @@ -434,8 +434,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= +golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -454,14 +454,14 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= -golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= +golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= diff --git a/internal/auth/kubernetes.go b/internal/auth/kubernetes.go new file mode 100644 index 00000000..a6279d42 --- /dev/null +++ b/internal/auth/kubernetes.go @@ -0,0 +1,99 @@ +package auth + +import ( + "context" + "fmt" + "os" + + "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/configloader" + authv1 "k8s.io/api/authentication/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +// KubernetesProvider generates a bearer token from Kubernetes. +// +// When audience is empty, it reads the pod's mounted ServiceAccount token file. +// When audience is set, it calls the Kubernetes TokenRequest API to create a +// short-lived bound token for the specified audience. +type KubernetesProvider struct { + serviceAccount string + namespace string + audience string + k8sCfg configloader.KubernetesConfig + expSeconds int64 +} + +// GetToken returns a Kubernetes ServiceAccount bearer token. +func (p *KubernetesProvider) GetToken(ctx context.Context) (string, error) { + if p.audience == "" { + return readMountedToken() + } + return p.createTokenRequest(ctx) +} + +// readMountedToken reads the projected ServiceAccount token from the standard mount path. +func readMountedToken() (string, error) { + data, err := os.ReadFile(mountedTokenPath) + if err != nil { + return "", fmt.Errorf("failed to read mounted ServiceAccount token from %s: %w", mountedTokenPath, err) + } + return string(data), nil +} + +// createTokenRequest calls the Kubernetes TokenRequest API to create a bound token. +func (p *KubernetesProvider) createTokenRequest(ctx context.Context) (string, error) { + restCfg, err := buildRestConfig(p.k8sCfg) + if err != nil { + return "", fmt.Errorf("failed to build Kubernetes REST config: %w", err) + } + + cs, err := kubernetes.NewForConfig(restCfg) + if err != nil { + return "", fmt.Errorf("failed to create Kubernetes clientset: %w", err) + } + + return p.createTokenRequestWithClientset(ctx, cs) +} + +// createTokenRequestWithClientset creates a bound token using the provided clientset. +// Separated from createTokenRequest to allow unit testing with a fake clientset. +func (p *KubernetesProvider) createTokenRequestWithClientset( + ctx context.Context, cs kubernetes.Interface, +) (string, error) { + expSeconds := p.expSeconds + tr, err := cs.CoreV1().ServiceAccounts(p.namespace).CreateToken(ctx, p.serviceAccount, + &authv1.TokenRequest{ + Spec: authv1.TokenRequestSpec{ + Audiences: []string{p.audience}, + ExpirationSeconds: &expSeconds, + }, + }, + metav1.CreateOptions{}, + ) + if err != nil { + return "", fmt.Errorf("failed to create token for ServiceAccount %s/%s (audience=%s): %w", + p.namespace, p.serviceAccount, p.audience, err) + } + + return tr.Status.Token, nil +} + +// buildRestConfig mirrors the logic in k8sclient.NewClient to produce a *rest.Config. +func buildRestConfig(cfg configloader.KubernetesConfig) (*rest.Config, error) { + if cfg.KubeConfigPath != "" { + restCfg, err := clientcmd.BuildConfigFromFlags("", cfg.KubeConfigPath) + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig from %s: %w", cfg.KubeConfigPath, err) + } + return restCfg, nil + } + + restCfg, err := rest.InClusterConfig() + if err != nil { + return nil, fmt.Errorf("failed to create in-cluster config: %w", err) + } + return restCfg, nil +} diff --git a/internal/auth/provider.go b/internal/auth/provider.go new file mode 100644 index 00000000..e5454e89 --- /dev/null +++ b/internal/auth/provider.go @@ -0,0 +1,87 @@ +package auth + +import ( + "bytes" + "context" + "fmt" + "os" + + "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/configloader" + "github.com/openshift-hyperfleet/hyperfleet-adapter/pkg/utils" +) + +const ( + mountedTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" //nolint:gosec + mountedNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" //nolint:gosec + defaultExpSeconds = int64(3600) +) + +// TokenProvider generates a bearer token for an API call Authorization header. +type TokenProvider interface { + GetToken(ctx context.Context) (string, error) +} + +// StaticProvider returns a pre-rendered static token. +type StaticProvider struct { + token string +} + +func (s *StaticProvider) GetToken(_ context.Context) (string, error) { + return s.token, nil +} + +// NewTokenProvider builds a TokenProvider from the given APICallAuth config. +// For type=static, the token field is rendered as a Go template against params. +// For type=kubernetes, a KubernetesProvider is built using the k8s client config. +func NewTokenProvider( + auth *configloader.APICallAuth, + k8sCfg configloader.KubernetesConfig, + params map[string]any, +) (TokenProvider, error) { + if auth == nil { + return nil, fmt.Errorf("auth config is nil") + } + + switch auth.Type { + case "static": + rendered, err := utils.RenderTemplate(auth.Token, params) + if err != nil { + return nil, fmt.Errorf("failed to render static token template: %w", err) + } + return &StaticProvider{token: rendered}, nil + + case "kubernetes": + ns, err := resolveNamespace(auth.Namespace) + if err != nil { + return nil, fmt.Errorf("failed to resolve namespace: %w", err) + } + + expSeconds := defaultExpSeconds + if auth.ExpirationSeconds != nil { + expSeconds = *auth.ExpirationSeconds + } + + return &KubernetesProvider{ + k8sCfg: k8sCfg, + serviceAccount: auth.ServiceAccount, + namespace: ns, + audience: auth.Audience, + expSeconds: expSeconds, + }, nil + + default: + return nil, fmt.Errorf("unsupported authorization type %q", auth.Type) + } +} + +// resolveNamespace returns the configured namespace, or reads it from the mounted SA namespace file. +func resolveNamespace(configured string) (string, error) { + if configured != "" { + return configured, nil + } + data, err := os.ReadFile(mountedNamespacePath) + if err != nil { + return "", fmt.Errorf("namespace not configured and could not read %s: %w", mountedNamespacePath, err) + } + return string(bytes.TrimSpace(data)), nil +} diff --git a/internal/auth/provider_test.go b/internal/auth/provider_test.go new file mode 100644 index 00000000..cb6cc5db --- /dev/null +++ b/internal/auth/provider_test.go @@ -0,0 +1,140 @@ +package auth + +import ( + "context" + "testing" + + "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/configloader" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + authv1 "k8s.io/api/authentication/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" +) + +// TestStaticProvider verifies that StaticProvider returns its token unchanged. +func TestStaticProvider(t *testing.T) { + p := &StaticProvider{token: "my-static-token"} + token, err := p.GetToken(context.Background()) + require.NoError(t, err) + assert.Equal(t, "my-static-token", token) +} + +// TestNewTokenProvider_Static verifies template rendering for the static type. +func TestNewTokenProvider_Static(t *testing.T) { + tests := []struct { + name string + tokenTmpl string + params map[string]any + wantToken string + wantErr bool + }{ + { + name: "literal token", + tokenTmpl: "abc123", + params: map[string]any{}, + wantToken: "abc123", + }, + { + name: "template-rendered token", + tokenTmpl: "{{ .apiKey }}", + params: map[string]any{"apiKey": "rendered-key"}, + wantToken: "rendered-key", + }, + { + name: "missing template variable returns error", + tokenTmpl: "{{ .missing }}", + params: map[string]any{}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := &configloader.APICallAuth{Type: "static", Token: tt.tokenTmpl} + provider, err := NewTokenProvider(a, configloader.KubernetesConfig{}, tt.params) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + token, err := provider.GetToken(context.Background()) + require.NoError(t, err) + assert.Equal(t, tt.wantToken, token) + }) + } +} + +// TestNewTokenProvider_Unknown verifies that an unknown type returns an error. +func TestNewTokenProvider_Unknown(t *testing.T) { + a := &configloader.APICallAuth{Type: "oauth2"} + _, err := NewTokenProvider(a, configloader.KubernetesConfig{}, map[string]any{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported authorization type") +} + +// TestNewTokenProvider_Nil verifies that a nil auth config returns an error. +func TestNewTokenProvider_Nil(t *testing.T) { + _, err := NewTokenProvider(nil, configloader.KubernetesConfig{}, map[string]any{}) + require.Error(t, err) +} + +// TestKubernetesProvider_MountedToken_MissingFile verifies the error when +// the mounted SA token file is absent (expected outside a real cluster). +func TestKubernetesProvider_MountedToken_MissingFile(t *testing.T) { + p := &KubernetesProvider{audience: ""} // no audience → reads mounted file + _, err := p.GetToken(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read mounted ServiceAccount token") +} + +// TestKubernetesProvider_TokenRequest verifies the TokenRequest API path +// using a fake Kubernetes clientset. +func TestKubernetesProvider_TokenRequest(t *testing.T) { + const ( + namespace = "hyperfleet" + serviceAccount = "adapter-sa" + audience = "target-service" + wantToken = "test-bound-sa-token" + ) + + fakeCS := fake.NewClientset() + fakeCS.PrependReactor("create", "serviceaccounts", + func(action ktesting.Action) (bool, runtime.Object, error) { + createAction, ok := action.(ktesting.CreateAction) + if !ok || createAction.GetSubresource() != "token" { + return false, nil, nil + } + return true, &authv1.TokenRequest{ + Status: authv1.TokenRequestStatus{Token: wantToken}, + }, nil + }, + ) + + p := &KubernetesProvider{ + audience: audience, + namespace: namespace, + serviceAccount: serviceAccount, + expSeconds: 1800, + } + + token, err := p.createTokenRequestWithClientset(context.Background(), fakeCS) + require.NoError(t, err) + assert.Equal(t, wantToken, token) +} + +// TestResolveNamespace verifies namespace resolution. +func TestResolveNamespace(t *testing.T) { + t.Run("configured namespace returned directly", func(t *testing.T) { + ns, err := resolveNamespace("my-namespace") + require.NoError(t, err) + assert.Equal(t, "my-namespace", ns) + }) + + t.Run("empty namespace reads mounted file — fails outside cluster", func(t *testing.T) { + _, err := resolveNamespace("") + require.Error(t, err) + assert.Contains(t, err.Error(), mountedNamespacePath) + }) +} diff --git a/internal/configloader/types.go b/internal/configloader/types.go index 6d9d94de..d83a90eb 100644 --- a/internal/configloader/types.go +++ b/internal/configloader/types.go @@ -12,6 +12,7 @@ import ( // Created by merging AdapterConfig (deployment) and AdapterTaskConfig (task). type Config struct { Post *PostConfig `yaml:"post,omitempty"` + Authorization *APICallAuth `yaml:"authorization,omitempty"` Log LogConfig `yaml:"log,omitempty"` Adapter AdapterInfo `yaml:"adapter"` Params []Parameter `yaml:"params,omitempty"` @@ -32,6 +33,7 @@ func Merge(adapterCfg *AdapterConfig, taskCfg *AdapterTaskConfig) *Config { return &Config{ Adapter: adapterCfg.Adapter, Clients: adapterCfg.Clients, + Authorization: adapterCfg.Authorization, DebugConfig: adapterCfg.DebugConfig, Log: adapterCfg.Log, Params: taskCfg.Params, @@ -50,6 +52,11 @@ func (c *Config) Redacted() *Config { } copy := *c copy.Clients = redactedClients(c.Clients) + if c.Authorization != nil && c.Authorization.Token != "" { + authCopy := *c.Authorization + authCopy.Token = redactedValue + copy.Authorization = &authCopy + } return © } @@ -237,13 +244,14 @@ type Precondition struct { // APICall represents an API call configuration type APICall struct { - Method string `yaml:"method" validate:"required,oneof=GET POST PUT PATCH DELETE"` - URL string `yaml:"url" validate:"required"` - Timeout string `yaml:"timeout,omitempty"` - RetryBackoff string `yaml:"retry_backoff,omitempty"` - Body string `yaml:"body,omitempty"` - Headers []Header `yaml:"headers,omitempty"` - RetryAttempts int `yaml:"retry_attempts,omitempty"` + Authorization *APICallAuth `yaml:"authorization,omitempty"` + Method string `yaml:"method" validate:"required,oneof=GET POST PUT PATCH DELETE"` + URL string `yaml:"url" validate:"required"` + Timeout string `yaml:"timeout,omitempty"` + RetryBackoff string `yaml:"retry_backoff,omitempty"` + Body string `yaml:"body,omitempty"` + Headers []Header `yaml:"headers,omitempty"` + RetryAttempts int `yaml:"retry_attempts,omitempty"` } // Header represents an HTTP header @@ -252,6 +260,18 @@ type Header struct { Value string `yaml:"value"` } +// APICallAuth configures bearer token generation for the Authorization header of an api_call. +// The generated token is set as "Authorization: Bearer ", overriding any Authorization +// header specified in the headers list. +type APICallAuth struct { + ExpirationSeconds *int64 `yaml:"expiration_seconds,omitempty"` + Type string `yaml:"type" validate:"required,oneof=static kubernetes"` + Token string `yaml:"token,omitempty"` + Audience string `yaml:"audience,omitempty"` + ServiceAccount string `yaml:"service_account,omitempty"` + Namespace string `yaml:"namespace,omitempty"` +} + // CaptureField represents a field capture configuration from API response. // // Supports two modes (mutually exclusive): @@ -398,11 +418,8 @@ type PostConfig struct { // //nolint:govet // fieldalignment: see doc comment above type PostAction struct { + When *PostActionWhen `yaml:"when,omitempty"` ActionBase `yaml:",inline"` - // When defines a CEL expression that gates execution of this post-action. - // If the expression evaluates to false, the action is skipped (not failed). - // Follows the same nested pattern as lifecycle.delete.when for consistency. - When *PostActionWhen `yaml:"when,omitempty"` } // PostActionWhen defines the condition for when a post-action should execute. @@ -487,10 +504,13 @@ func (ve *ValidationErrors) HasErrors() bool { // Contains infrastructure settings that can be overridden via environment variables // and CLI flags using Viper. type AdapterConfig struct { - Adapter AdapterInfo `yaml:"adapter" mapstructure:"adapter"` - Log LogConfig `yaml:"log,omitempty" mapstructure:"log"` - Clients ClientsConfig `yaml:"clients" mapstructure:"clients"` - DebugConfig bool `yaml:"debug_config,omitempty" mapstructure:"debug_config"` + // Authorization is the fallback authorization applied to all api_call requests + // that do not have their own authorization block. Omit to send no Authorization header. + Authorization *APICallAuth `yaml:"authorization,omitempty" mapstructure:"authorization"` + Adapter AdapterInfo `yaml:"adapter" mapstructure:"adapter"` + Log LogConfig `yaml:"log,omitempty" mapstructure:"log"` + Clients ClientsConfig `yaml:"clients" mapstructure:"clients"` + DebugConfig bool `yaml:"debug_config,omitempty" mapstructure:"debug_config"` } // ClientsConfig contains configuration for all external clients diff --git a/internal/configloader/validator.go b/internal/configloader/validator.go index cae69d20..f2b47883 100644 --- a/internal/configloader/validator.go +++ b/internal/configloader/validator.go @@ -51,6 +51,21 @@ func (v *AdapterConfigValidator) ValidateStructure() error { return fmt.Errorf("%s", errs.First()) } + if auth := v.config.Authorization; auth != nil { + switch auth.Type { + case "static": + if auth.Token == "" { + return fmt.Errorf("authorization: static authorization requires token to be set") + } + case "kubernetes": + if auth.Audience != "" && auth.ServiceAccount == "" { + return fmt.Errorf("authorization: kubernetes authorization with audience requires service_account") + } + default: + return fmt.Errorf("authorization: unsupported type %q (must be static or kubernetes)", auth.Type) + } + } + return nil } @@ -368,6 +383,7 @@ func (v *TaskConfigValidator) validateTemplateVariables() { v.validateTemplateString(header.Value, fmt.Sprintf("%s.%s[%d].%s", basePath, FieldHeaders, j, FieldHeaderValue)) } + v.validateAPICallAuth(precond.APICall.Authorization, basePath+".authorization") } } @@ -421,6 +437,7 @@ func (v *TaskConfigValidator) validateTemplateVariables() { v.validateTemplateString(header.Value, fmt.Sprintf("%s.%s[%d].%s", basePath, FieldHeaders, j, FieldHeaderValue)) } + v.validateAPICallAuth(action.APICall.Authorization, basePath+".authorization") } } @@ -435,6 +452,26 @@ func (v *TaskConfigValidator) validateTemplateVariables() { } } +// validateAPICallAuth validates an APICallAuth config block. +// Checks cross-field constraints that cannot be expressed with struct tags. +func (v *TaskConfigValidator) validateAPICallAuth(auth *APICallAuth, path string) { + if auth == nil { + return + } + switch auth.Type { + case "static": + if auth.Token == "" { + v.errors.Add(path, "static authorization requires token to be set") + } else { + v.validateTemplateString(auth.Token, path+".token") + } + case "kubernetes": + if auth.Audience != "" && auth.ServiceAccount == "" { + v.errors.Add(path, "kubernetes authorization with audience requires service_account to be set") + } + } +} + func (v *TaskConfigValidator) validateTemplateString(s string, path string) { if s == "" { return diff --git a/internal/configloader/viper_loader.go b/internal/configloader/viper_loader.go index 4cb43d29..08191049 100644 --- a/internal/configloader/viper_loader.go +++ b/internal/configloader/viper_loader.go @@ -48,6 +48,8 @@ var viperKeyMappings = map[string]string{ "clients::kubernetes::api_version": "KUBERNETES_API_VERSION", "clients::kubernetes::qps": "KUBERNETES_QPS", "clients::kubernetes::burst": "KUBERNETES_BURST", + "authorization::type": "DEFAULT_API_CALL_AUTH_TYPE", + "authorization::token": "DEFAULT_API_CALL_AUTH_TOKEN", } // cliFlags defines mappings from CLI flag names to config paths diff --git a/internal/executor/executor.go b/internal/executor/executor.go index a2ca4fd1..e681f622 100644 --- a/internal/executor/executor.go +++ b/internal/executor/executor.go @@ -129,9 +129,9 @@ func (e *Executor) Execute(ctx context.Context, data interface{}) *ExecutionResu result.PreconditionResults = precondOutcome.Results switch { - case precondOutcome.Error != nil && apierrors.IsResourceNotFoundError(precondOutcome.Error): - // Resource no longer exists (e.g. deleted externally, wrong ID in event). - // Stop processing gracefully. + case precondOutcome.Error != nil && apierrors.IsNotFoundError(precondOutcome.Error): + // Resource was force-deleted: API returned 404. Log and stop processing gracefully. + // No point running resources or post-actions for a resource that no longer exists. e.log.Infof(ctx, "Phase %s: resource not found, stopping processing gracefully", result.CurrentPhase) result.ResourcesSkipped = true @@ -203,8 +203,8 @@ func (e *Executor) Execute(ctx context.Context, data interface{}) *ExecutionResu result.PostActionResults = postResults if err != nil { - if apierrors.IsResourceNotFoundError(err) { - // Resource no longer exists. Log and continue, don't fail. + if apierrors.IsNotFoundError(err) { + // Resource was force-deleted mid-execution. Log and continue, don't fail. e.log.Infof(ctx, "Phase %s: resource not found, skipping remaining post-actions", result.CurrentPhase) result.ResourcesSkipped = true diff --git a/internal/executor/executor_test.go b/internal/executor/executor_test.go index 958a0a27..20a10ca4 100644 --- a/internal/executor/executor_test.go +++ b/internal/executor/executor_test.go @@ -41,44 +41,16 @@ func mockErrorResponse(statusCode int) (*hyperfleetapi.Response, error) { return resp, apiErr } -// mock404Response returns a 404 response with code HYPERFLEET-NTF-002, -// representing a real resource that was not found. -// The MockClient only returns (nil, error) when an error is set, so using -// mockErrorResponse for 404 tests causes ExecuteAPICall to wrap the error -// in a new APIError with StatusCode=0, which breaks detection. Setting -// only the response lets ValidateAPIResponse create the correct APIError. +// mock404Response returns a 404 response without an error. The MockClient only +// returns (nil, error) when an error is set, so using mockErrorResponse for 404 +// tests causes ExecuteAPICall to wrap the error in a new APIError with StatusCode=0, +// which breaks IsNotFoundError detection. Setting only the response lets +// ValidateAPIResponse create the correct APIError{StatusCode:404}. func mock404Response() *hyperfleetapi.Response { - body := `{ - "type": "https://api.hyperfleet.io/errors/resource-not-found", - "title": "Resource Not Found", - "status": 404, - "detail": "Cluster with id='abc123' not found", - "code": "HYPERFLEET-NTF-002", - "trace_id": "019ed716-f3cf-7b8e-b400-0796be4722c3" - }` return &hyperfleetapi.Response{ StatusCode: 404, Status: "404 Not Found", - Body: []byte(body), - Attempts: 1, - } -} - -// mockBrokenEndpoint404Response returns a 404 with code HYPERFLEET-NTF-000, -// representing a misconfigured/broken precondition URL. -func mockBrokenEndpoint404Response() *hyperfleetapi.Response { - body := `{ - "type": "https://api.hyperfleet.io/errors/endpoint-not-found", - "title": "Endpoint Not Found", - "status": 404, - "detail": "The requested endpoint does not exist", - "code": "HYPERFLEET-NTF-000", - "trace_id": "" - }` - return &hyperfleetapi.Response{ - StatusCode: 404, - Status: "404 Not Found", - Body: []byte(body), + Body: []byte(`{"error":"not found"}`), Attempts: 1, } } @@ -1482,7 +1454,7 @@ func findFamily(families []*dto.MetricFamily, name string) *dto.MetricFamily { } // TestPrecondition404_GracefulStop verifies that when a precondition API call returns 404 -// (resource no longer exists), the executor stops gracefully: status remains "success", +// (resource force-deleted), the executor stops gracefully: status remains "success", // resources are skipped, and no error state is set. func new404PreconditionConfig() *configloader.Config { return &configloader.Config{ @@ -1561,7 +1533,7 @@ func TestPrecondition404_GracefulStop(t *testing.T) { } // TestPostAction404_GracefulHandling verifies that when a post-action API call returns 404 -// (resource no longer exists), the executor does not mark the execution as failed. +// (resource force-deleted), the executor does not mark the execution as failed. func TestPostAction404_GracefulHandling(t *testing.T) { mockClient := newMockAPIClient() mockClient.GetResponse = &hyperfleetapi.Response{ @@ -1656,56 +1628,6 @@ func TestPreconditionFail_PostAction404(t *testing.T) { "post-action 404 should not add an error") } -// TestPreconditionBrokenURL404_ReportsError verifies that when a -// precondition API call returns 404 due to a misconfigured URL (error -// code HYPERFLEET-NTF-000), the adapter treats it as an error rather than a graceful stop. -func TestPreconditionBrokenURL404_ReportsError(t *testing.T) { - mockClient := newMockAPIClient() - mockClient.GetResponse = mockBrokenEndpoint404Response() - - config := new404PreconditionConfig() - exec := build404TestExecutor(t, config, mockClient) - - ctx := logger.WithEventID(context.Background(), "test-broken-url-404") - result := exec.Execute(ctx, map[string]interface{}{"id": "cls-123"}) - - assert.Equal(t, StatusFailed, result.Status, - "broken URL 404 should mark execution as failed") - - assert.True(t, result.ResourcesSkipped, - "resources should be skipped on precondition failure") - - assert.NotEmpty(t, result.Errors, - "errors should be recorded for a broken URL 404") -} - -// TestPostActionBrokenURL404_ReportsError verifies that when a post-action -// API call returns 404 due to a misconfigured URL (error code -// HYPERFLEET-NTF-000), the adapter treats it as an error instead of a graceful stop. -func TestPostActionBrokenURL404_ReportsError(t *testing.T) { - mockClient := newMockAPIClient() - mockClient.GetResponse = &hyperfleetapi.Response{ - StatusCode: 200, - Body: []byte( - `{"id":"cluster-456","status":{"conditions":` + - `[{"type":"Reconciled","status":"False"}]}}`, - ), - } - mockClient.PutResponse = mockBrokenEndpoint404Response() - - config := new404PostActionConfig() - exec := build404TestExecutor(t, config, mockClient) - - ctx := logger.WithEventID(context.Background(), "test-post-broken-url-404") - result := exec.Execute(ctx, map[string]interface{}{"id": "cls-123"}) - - assert.Equal(t, StatusFailed, result.Status, - "broken URL 404 in post-actions should mark execution as failed") - - assert.NotNil(t, result.Errors[PhasePostActions], - "post-action error should be recorded for a broken URL 404") -} - func getCounterValue(t *testing.T, families []*dto.MetricFamily, metricName, labelName, labelValue string) float64 { t.Helper() family := findFamily(families, metricName) diff --git a/internal/executor/post_action_executor_test.go b/internal/executor/post_action_executor_test.go index 73089053..b2c257a6 100644 --- a/internal/executor/post_action_executor_test.go +++ b/internal/executor/post_action_executor_test.go @@ -661,6 +661,102 @@ func TestExecuteAPICall(t *testing.T) { } } +func TestExecuteAPICall_Authorization(t *testing.T) { + okResponse := &hyperfleetapi.Response{StatusCode: http.StatusOK, Status: "200 OK"} + + tests := []struct { + authorization *configloader.APICallAuth + params map[string]interface{} + name string + expectedAuthHeader string + extraHeaders []configloader.Header + expectError bool + }{ + { + name: "static token sets Authorization header", + authorization: &configloader.APICallAuth{ + Type: "static", + Token: "my-secret-token", + }, + params: map[string]interface{}{}, + expectedAuthHeader: "Bearer my-secret-token", + }, + { + name: "static token with template rendering", + authorization: &configloader.APICallAuth{ + Type: "static", + Token: "{{ .apiToken }}", + }, + params: map[string]interface{}{"apiToken": "rendered-token"}, + expectedAuthHeader: "Bearer rendered-token", + }, + { + name: "auth header overrides explicit Authorization in headers list", + authorization: &configloader.APICallAuth{ + Type: "static", + Token: "auth-block-token", + }, + extraHeaders: []configloader.Header{ + {Name: "Authorization", Value: "Bearer manual-token"}, + }, + params: map[string]interface{}{}, + expectedAuthHeader: "Bearer auth-block-token", + }, + { + name: "static token template error returns error", + authorization: &configloader.APICallAuth{ + Type: "static", + Token: "{{ .missing }}", + }, + params: map[string]interface{}{}, + expectError: true, + }, + { + name: "kubernetes type without audience fails outside cluster", + authorization: &configloader.APICallAuth{ + Type: "kubernetes", + }, + params: map[string]interface{}{}, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := hyperfleetapi.NewMockClient() + mockClient.GetResponse = okResponse + + apiCall := &configloader.APICall{ + Method: "GET", + URL: "http://api.example.com/resource", + Authorization: tt.authorization, + Headers: tt.extraHeaders, + } + + execCtx := NewExecutionContext(context.Background(), map[string]interface{}{}, &configloader.Config{}) + execCtx.Params = tt.params + + _, _, err := ExecuteAPICall( + context.Background(), + apiCall, + execCtx, + mockClient, + logger.NewTestLogger(), + ) + + if tt.expectError { + assert.Error(t, err) + return + } + + require.NoError(t, err) + lastReq := mockClient.GetLastRequest() + require.NotNil(t, lastReq) + assert.Equal(t, tt.expectedAuthHeader, lastReq.Headers["Authorization"]) + }) + } +} + func TestPostActionWhenCondition(t *testing.T) { tests := []struct { when *configloader.PostActionWhen diff --git a/internal/executor/utils.go b/internal/executor/utils.go index fb7360e4..1a3762ae 100644 --- a/internal/executor/utils.go +++ b/internal/executor/utils.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/auth" "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/configloader" "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/criteria" "github.com/openshift-hyperfleet/hyperfleet-adapter/internal/hyperfleetapi" @@ -111,6 +112,25 @@ func ExecuteAPICall( } headers[h.Name] = headerValue } + + // Resolve authorization token — per-call config wins; falls back to the global default. + // Always overrides any manually set Authorization header. + authCfg := apiCall.Authorization + if authCfg == nil && execCtx.Config != nil { + authCfg = execCtx.Config.Authorization + } + if authCfg != nil { + provider, authErr := auth.NewTokenProvider(authCfg, execCtx.Config.Clients.Kubernetes, execCtx.Params) + if authErr != nil { + return nil, url, fmt.Errorf("failed to build authorization token provider: %w", authErr) + } + token, authErr := provider.GetToken(ctx) + if authErr != nil { + return nil, url, fmt.Errorf("failed to get authorization token: %w", authErr) + } + headers["Authorization"] = "Bearer " + token + } + if len(headers) > 0 { opts = append(opts, hyperfleetapi.WithHeaders(headers)) } diff --git a/internal/hyperfleetapi/mock.go b/internal/hyperfleetapi/mock.go index 2add0b18..d630e36f 100644 --- a/internal/hyperfleetapi/mock.go +++ b/internal/hyperfleetapi/mock.go @@ -66,9 +66,17 @@ func (m *MockClient) Do(ctx context.Context, req *Request) (*Response, error) { return m.DoResponse, nil } +// applyOpts applies RequestOptions to a Request. +func applyOpts(req *Request, opts []RequestOption) { + for _, opt := range opts { + opt(req) + } +} + // Get implements Client.Get func (m *MockClient) Get(ctx context.Context, url string, opts ...RequestOption) (*Response, error) { req := &Request{Method: "GET", URL: url} + applyOpts(req, opts) m.Requests = append(m.Requests, req) if m.GetError != nil { return nil, m.GetError @@ -79,6 +87,7 @@ func (m *MockClient) Get(ctx context.Context, url string, opts ...RequestOption) // Post implements Client.Post func (m *MockClient) Post(ctx context.Context, url string, body []byte, opts ...RequestOption) (*Response, error) { req := &Request{Method: "POST", URL: url, Body: body} + applyOpts(req, opts) m.Requests = append(m.Requests, req) if m.PostError != nil { return nil, m.PostError @@ -89,6 +98,7 @@ func (m *MockClient) Post(ctx context.Context, url string, body []byte, opts ... // Put implements Client.Put func (m *MockClient) Put(ctx context.Context, url string, body []byte, opts ...RequestOption) (*Response, error) { req := &Request{Method: "PUT", URL: url, Body: body} + applyOpts(req, opts) m.Requests = append(m.Requests, req) if m.PutError != nil { return nil, m.PutError @@ -99,6 +109,7 @@ func (m *MockClient) Put(ctx context.Context, url string, body []byte, opts ...R // Patch implements Client.Patch func (m *MockClient) Patch(ctx context.Context, url string, body []byte, opts ...RequestOption) (*Response, error) { req := &Request{Method: "PATCH", URL: url, Body: body} + applyOpts(req, opts) m.Requests = append(m.Requests, req) if m.PatchError != nil { return nil, m.PatchError @@ -109,6 +120,7 @@ func (m *MockClient) Patch(ctx context.Context, url string, body []byte, opts .. // Delete implements Client.Delete func (m *MockClient) Delete(ctx context.Context, url string, opts ...RequestOption) (*Response, error) { req := &Request{Method: "DELETE", URL: url} + applyOpts(req, opts) m.Requests = append(m.Requests, req) if m.DeleteError != nil { return nil, m.DeleteError diff --git a/pkg/errors/api_error.go b/pkg/errors/api_error.go index 3b539fd7..b75774ed 100644 --- a/pkg/errors/api_error.go +++ b/pkg/errors/api_error.go @@ -2,7 +2,6 @@ package errors import ( "context" - "encoding/json" "errors" "fmt" "time" @@ -33,28 +32,6 @@ type APIError struct { Attempts int } -// brokenEndpointCode is the RFC 9457 error code the HyperFleet API returns -// from its catch-all 404 handler when no route matched the request URL. -const brokenEndpointCode = "HYPERFLEET-NTF-000" - -// problemDetails is a subset of RFC 9457 Problem Details used to distinguish -// resource-not-found from broken-endpoint 404 responses. -type problemDetails struct { - Code string `json:"code"` -} - -// parseProblemDetails attempts to parse the response body as RFC 9457 Problem Details. -func (e *APIError) parseProblemDetails() (problemDetails, bool) { - if !e.HasResponseBody() { - return problemDetails{}, false - } - var pd problemDetails - if err := json.Unmarshal(e.ResponseBody, &pd); err != nil { - return problemDetails{}, false - } - return pd, true -} - // Error implements the error interface. // Note: Err should always be non-nil when APIError is created in production code. // The client.Do() method always sets lastErr before returning an APIError. @@ -96,22 +73,6 @@ func (e *APIError) IsNotFound() bool { return e.StatusCode == 404 } -// IsResourceNotFound returns true when the 404 represents a real resource that -// was not found, as opposed to a broken/misconfigured URL. -// It defaults to true for any 404 (safe fallback if proxies strip the response -// body), and only returns false when the RFC 9457 body contains the catch-all -// error code HYPERFLEET-NTF-000, which signals no route matched the request URL. -func (e *APIError) IsResourceNotFound() bool { - if !e.IsNotFound() { - return false - } - pd, ok := e.parseProblemDetails() - if !ok { - return true - } - return pd.Code != brokenEndpointCode -} - // IsUnauthorized returns true if the error was a 401 Unauthorized func (e *APIError) IsUnauthorized() bool { return e.StatusCode == 401 @@ -202,10 +163,3 @@ func IsNotFoundError(err error) bool { apiErr, ok := IsAPIError(err) return ok && apiErr.IsNotFound() } - -// IsResourceNotFoundError checks whether err is a 404 APIError that represents -// a real resource not found (not a broken/misconfigured endpoint URL). -func IsResourceNotFoundError(err error) bool { - apiErr, ok := IsAPIError(err) - return ok && apiErr.IsResourceNotFound() -} diff --git a/pkg/errors/api_error_test.go b/pkg/errors/api_error_test.go index 9dd6758f..3c70ee3a 100644 --- a/pkg/errors/api_error_test.go +++ b/pkg/errors/api_error_test.go @@ -7,152 +7,6 @@ import ( "github.com/stretchr/testify/assert" ) -func resourceNotFoundBody() []byte { - return []byte(`{ - "type": "https://api.hyperfleet.io/errors/resource-not-found", - "title": "Resource Not Found", - "status": 404, - "detail": "Cluster with id='cls-123' not found", - "instance": "/api/hyperfleet/v1/clusters/cls-123", - "code": "HYPERFLEET-NTF-002", - "trace_id": "019ed716-f3cf-7b8e-b400-0796be4722c3" - }`) -} - -func brokenEndpointBody() []byte { - return []byte(`{ - "type": "https://api.hyperfleet.io/errors/endpoint-not-found", - "title": "Endpoint Not Found", - "status": 404, - "detail": "The requested endpoint '/api/hyperfleet/v1/clusters-BROKEN/cls-123' does not exist", - "instance": "/api/hyperfleet/v1/clusters-BROKEN/cls-123", - "code": "HYPERFLEET-NTF-000", - "trace_id": "" - }`) -} - -func new404APIError(url string, body []byte) *APIError { - return NewAPIError( - "GET", url, 404, "404 Not Found", - body, 1, 0, fmt.Errorf("not found"), - ) -} - -func TestIsResourceNotFound(t *testing.T) { - tests := []struct { - err *APIError - name string - want bool - }{ - { - name: "resource not found with HYPERFLEET-NTF-002 code", - err: new404APIError("/clusters/cls-123", resourceNotFoundBody()), - want: true, - }, - { - name: "nodepool not found with HYPERFLEET-NTF-003 code", - err: new404APIError("/clusters/cls-123/nodepools/np-1", []byte(`{ - "type": "https://api.hyperfleet.io/errors/not-found", - "title": "NodePool Not Found", - "status": 404, - "detail": "NodePool with id='np-1' not found", - "code": "HYPERFLEET-NTF-003" - }`)), - want: true, - }, - { - name: "broken endpoint with HYPERFLEET-NTF-000 code", - err: new404APIError("/clusters-BROKEN/cls-123", brokenEndpointBody()), - want: false, - }, - { - name: "404 without response body defaults to resource not found", - err: new404APIError("/clusters/cls-123", nil), - want: true, - }, - { - name: "404 with unparseable body defaults to resource not found", - err: new404APIError("/clusters/cls-123", []byte("not json")), - want: true, - }, - { - name: "404 with empty JSON object defaults to resource not found", - err: new404APIError("/clusters/cls-123", []byte("{}")), - want: true, - }, - { - name: "non-404 with trace_id", - err: NewAPIError( - "GET", "/clusters/cls-123", - 500, "500 Internal Server Error", - resourceNotFoundBody(), 1, 0, - fmt.Errorf("server error"), - ), - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, tt.err.IsResourceNotFound(), - "IsResourceNotFound mismatch for %q", tt.name) - }) - } -} - -func TestIsResourceNotFoundError(t *testing.T) { - tests := []struct { - err error - name string - want bool - }{ - { - name: "nil error", - err: nil, - want: false, - }, - { - name: "plain error", - err: fmt.Errorf("something went wrong"), - want: false, - }, - { - name: "resource not found direct", - err: new404APIError("/clusters/cls-123", resourceNotFoundBody()), - want: true, - }, - { - name: "resource not found wrapped", - err: fmt.Errorf("precondition failed: %w", - new404APIError("/clusters/cls-123", resourceNotFoundBody())), - want: true, - }, - { - name: "broken endpoint direct", - err: new404APIError("/clusters-BROKEN/cls-123", brokenEndpointBody()), - want: false, - }, - { - name: "broken endpoint wrapped", - err: fmt.Errorf("precondition failed: %w", - new404APIError("/clusters-BROKEN/cls-123", brokenEndpointBody())), - want: false, - }, - { - name: "404 without body defaults to resource not found", - err: new404APIError("/clusters/cls-123", nil), - want: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.want, IsResourceNotFoundError(tt.err), - "IsResourceNotFoundError mismatch for %q", tt.name) - }) - } -} - func TestIsNotFoundError(t *testing.T) { tests := []struct { err error diff --git a/test/testdata/dryrun/dryrun-404-api-responses.json b/test/testdata/dryrun/dryrun-404-api-responses.json index 494b6991..5012e768 100644 --- a/test/testdata/dryrun/dryrun-404-api-responses.json +++ b/test/testdata/dryrun/dryrun-404-api-responses.json @@ -9,16 +9,14 @@ { "statusCode": 404, "headers": { - "Content-Type": "application/problem+json" + "Content-Type": "application/json" }, "body": { - "type": "https://api.hyperfleet.io/errors/resource-not-found", - "title": "Resource Not Found", - "status": 404, - "detail": "Cluster with id='abc123' not found", - "instance": "/api/hyperfleet/v1/clusters/abc123", - "code": "HYPERFLEET-NTF-002", - "trace_id": "019ed716-f3cf-7b8e-b400-0796be4722c3" + "kind": "Error", + "id": "404", + "href": "/api/hyperfleet/v1/errors/404", + "code": "HYPERFLEET-NTF-001", + "reason": "Cluster 'abc123' not found" } } ] @@ -32,16 +30,14 @@ { "statusCode": 404, "headers": { - "Content-Type": "application/problem+json" + "Content-Type": "application/json" }, "body": { - "type": "https://api.hyperfleet.io/errors/resource-not-found", - "title": "Resource Not Found", - "status": 404, - "detail": "Cluster with id='abc123' not found", - "instance": "/api/hyperfleet/v1/clusters/abc123/statuses", - "code": "HYPERFLEET-NTF-002", - "trace_id": "019ed716-f3cf-7b8e-b400-0796be4722c3" + "kind": "Error", + "id": "404", + "href": "/api/hyperfleet/v1/errors/404", + "code": "HYPERFLEET-NTF-001", + "reason": "Cluster 'abc123' not found" } } ] diff --git a/test/testdata/dryrun/dryrun-post-action-404-api-responses.json b/test/testdata/dryrun/dryrun-post-action-404-api-responses.json index 37650af3..033ac9d8 100644 --- a/test/testdata/dryrun/dryrun-post-action-404-api-responses.json +++ b/test/testdata/dryrun/dryrun-post-action-404-api-responses.json @@ -45,16 +45,14 @@ { "statusCode": 404, "headers": { - "Content-Type": "application/problem+json" + "Content-Type": "application/json" }, "body": { - "type": "https://api.hyperfleet.io/errors/resource-not-found", - "title": "Resource Not Found", - "status": 404, - "detail": "Cluster with id='abc123' not found", - "instance": "/api/hyperfleet/v1/clusters/abc123/statuses", - "code": "HYPERFLEET-NTF-002", - "trace_id": "019ed716-f3cf-7b8e-b400-0796be4722c3" + "kind": "Error", + "id": "404", + "href": "/api/hyperfleet/v1/errors/404", + "code": "HYPERFLEET-NTF-001", + "reason": "Cluster 'abc123' not found" } } ]