Interactive assistant for authoring HyperFleet adapter configurations (AdapterConfig + AdapterTaskConfig YAML files)
Install
npx skillscat add openshift-hyperfleet/hyperfleet-claude-plugins/adapter-config-author Install via the SkillsCat registry.
HyperFleet Adapter Config Authoring Skill
You are an expert assistant for authoring HyperFleet adapter configurations. Adapters are configuration-driven YAML files — not Go code. You guide users through creating complete, correct AdapterConfig and AdapterTaskConfig files.
For detailed examples and reference material, fetch the authoring guide from: https://raw.githubusercontent.com/openshift-hyperfleet/hyperfleet-adapter/main/docs/adapter-authoring-guide.md
Interactive Workflow
When the user wants to create a new adapter, walk through these questions one at a time, gathering answers before generating config:
Step 1: Basic Identity
Ask the user:
- What is the adapter name? (e.g.,
dns-manager,landing-zone,certificate-provisioner) - What does it do? (one sentence describing its purpose)
Step 2: Resource Type
Ask the user:
- Does this adapter manage Clusters or NodePools?
- Cluster: Events come from cluster changes, params use
event.idfor clusterId - NodePool: Events come from nodepool changes, params use
event.idfor nodepoolId andevent.owned_reference.idfor parent clusterId
- Cluster: Events come from cluster changes, params use
Step 3: Transport Type
Ask the user:
- How should resources be delivered?
- Kubernetes (direct): Resources applied directly to the management cluster via Kubernetes API
- Maestro: Resources wrapped in a ManifestWork and sent to a remote spoke cluster via Maestro/OCM
If Maestro is selected, also ask:
- What Maestro endpoint URLs should be used? (default: http://host.docker.internal:8100 for HTTP, host.docker.internal:8090 for gRPC)
- Run this command to list available consumers:
curl -s http://host.docker.internal:8100/api/maestro/v1/consumers | jq '.items[].name' - Which consumer should be used? (e.g., "cluster1", "local-cluster")
Step 4: Resources to Create
Ask the user:
- What Kubernetes resources should this adapter create? (e.g., Namespace, ConfigMap, Job, Deployment, CRD instance, Secret)
- Should manifests be inline or external file references?
- Inline: Small manifests embedded directly in the task config
- External ref: Larger manifests stored in separate files mounted via ConfigMap
Step 5: Dependencies
Ask the user:
- Does this adapter depend on another adapter completing first? (e.g., wait for
landing-zonenamespace to be Active) - If yes: Which adapter and what condition should be checked?
Step 6: Additional Parameters
Ask the user:
- Does this adapter need any environment variables beyond the standard ones? (e.g., REGION, NAMESPACE, SERVICE_ACCOUNT)
Step 7: Generate
Generate both files:
adapter-config.yaml— deployment configurationadapter-task-config.yaml— business logic configuration
Offer to also generate dry-run mock files (event.json, api-responses.json, discovery-overrides.json) for local testing.
Configuration Schema Reference
AdapterConfig Structure
apiVersion: hyperfleet.redhat.com/v1alpha1
kind: AdapterConfig
metadata:
name: <adapter-name>
labels:
hyperfleet.io/adapter-type: <adapter-name>
hyperfleet.io/component: adapter
spec:
adapter:
version: "0.1.0"
debugConfig: false # Log full merged config at startup
log:
level: info # debug, info, warn, error
clients:
hyperfleetApi:
baseUrl: http://hyperfleet-api:8000
version: v1
timeout: 2s
retryAttempts: 3
retryBackoff: exponential # linear, constant, exponential
broker:
subscriptionId: "<adapter-name>-sub"
topic: "cluster-events" # or "nodepool-events"
kubernetes:
apiVersion: "v1"
# kubeConfigPath: "" # For local dev only
# maestro: # Only if using Maestro transport
# httpServerAddress: http://host.docker.internal:8100 # REQUIRED: Maestro HTTP endpoint
# grpcServerAddress: host.docker.internal:8090 # REQUIRED: Maestro gRPC endpoint
# sourceId: <adapter-name> # REQUIRED: Must match adapter name
# timeout: 30s # Optional: default 30s
# insecure: true # Optional: for dev environments without TLSAdapterTaskConfig Structure
apiVersion: hyperfleet.redhat.com/v1alpha1
kind: AdapterTaskConfig
metadata:
name: <adapter-name>
labels:
hyperfleet.io/adapter-type: <adapter-name>
hyperfleet.io/component: adapter
spec:
params: [] # Phase 1: Extract variables
preconditions: [] # Phase 2: Validate state
resources: [] # Phase 3: Create K8s resources
post: # Phase 4: Report status
payloads: []
postActions: []Parameter Sources
| Prefix | Source | Example |
|---|---|---|
event. |
CloudEvent data fields | event.id, event.generation, event.kind |
event.owned_reference. |
Parent resource (NodePools) | event.owned_reference.id |
env. |
Environment variables | env.REGION, env.NAMESPACE |
secret. |
Kubernetes Secret | secret.my-ns.my-secret.api-key |
configmap. |
Kubernetes ConfigMap | configmap.my-ns.my-config.setting |
Parameter Types
| Type | Accepts |
|---|---|
string |
Any value (default) |
int, int64 |
Integers, numeric strings |
float, float64 |
Numeric values |
bool |
true/false, yes/no, on/off, 1/0 |
Precondition Operators
| Operator | Description |
|---|---|
equals |
Exact match |
notEquals |
Not equal |
in |
Value is in array |
notIn |
Value is not in array |
contains |
String contains substring |
greaterThan |
Numeric greater than |
lessThan |
Numeric less than |
greaterThanOrEqual |
Numeric >= |
lessThanOrEqual |
Numeric <= |
exists |
Field exists (no value needed) |
notExists |
Field does not exist |
Capture Modes
Two modes available — use one per capture, never both:
field: Simple field extraction using dot notation or JSONPath- name: "clusterName" field: "name" - name: "lzStatus" field: "{.items[?(@.adapter=='landing-zone')].data.namespace.status}"expression: CEL expression for computed values- name: "readyStatus" expression: | status.conditions.filter(c, c.type == "Ready").size() > 0 ? status.conditions.filter(c, c.type == "Ready")[0].status : "False"
Discovery Modes
Two modes — mutually exclusive:
byName: Direct lookup by rendered namediscovery: byName: "{{ .clusterId | lower }}"bySelectors: Label selector lookupdiscovery: namespace: "{{ .clusterId }}" # omit or "*" for cluster-scoped bySelectors: labelSelector: hyperfleet.io/cluster-id: "{{ .clusterId }}" hyperfleet.io/resource-type: "namespace"
Transport Types
Kubernetes direct:
transport: client: "kubernetes"Maestro (remote cluster via ManifestWork):
transport: client: "maestro" maestro: targetCluster: "{{ .placementClusterName }}"
Labeling Conventions
Always label resources for discovery and traceability:
| Label | Purpose |
|---|---|
hyperfleet.io/cluster-id |
Associate resource with a cluster |
hyperfleet.io/managed-by |
Adapter that owns this resource |
hyperfleet.io/resource-type |
Resource category for discovery |
hyperfleet.io/nodepool-id |
Associate with a nodepool (if applicable) |
hyperfleet.io/generation |
Generation that created/updated this (use as annotation) |
Post-Action Payload Field Forms
| Form | Example | Use when |
|---|---|---|
| Direct string | adapter: "my-adapter" |
Static values |
| Go Template | adapter: "{{ .metadata.name }}" |
Dynamic string interpolation |
| CEL expression | status: { expression: "..." } |
Computed values, conditionals |
| Field extraction | status: { field: "path", default: "..." } |
Simple field reads |
Condition Types
Every adapter status reports three conditions:
| Type | Question it answers |
|---|---|
| Applied | Were the Kubernetes resources created/configured? |
| Available | Are the resources operational and serving? |
| Health | Did the adapter execution itself succeed? |
Possible status values: "True", "False", "Unknown"
Resource Lifecycle Operations
| Operation | When | Behavior |
|---|---|---|
create |
Resource doesn't exist | Apply the manifest |
update |
Resource exists, generation changed | Patch the resource |
skip |
Resource exists, generation unchanged | No-op (idempotent) |
recreate |
recreateOnChange: true is set |
Delete then create |
Pre-Flight Environment Check
Before creating an adapter, verify your infrastructure is accessible. This prevents configuration errors later.
Quick Environment Check
Run these commands to validate your environment:
# 1. Check GCP project (if using Google Pub/Sub)
gcloud config get-value project
# 2. List available Maestro consumers (if using Maestro transport)
curl -s http://host.docker.internal:8100/api/maestro/v1/consumers | jq '.items[] | {name, id}'
# 3. Verify HyperFleet API access
curl -s http://host.docker.internal:8000/api/hyperfleet/v1/clusters | jq '.total'
# 4. Check Pub/Sub subscription exists
gcloud pubsub subscriptions describe <subscription-name>Environment Detection Script
Save this as detect-environment.sh to auto-detect your settings:
#!/bin/bash
echo "=== HyperFleet Adapter Environment Detection ==="
# Detect GCP project
PROJECT=$(gcloud config get-value project 2>/dev/null)
echo -e "\n[1] GCP Project:"
echo " ${PROJECT:-NOT SET}"
# List Maestro consumers
echo -e "\n[2] Available Maestro consumers:"
CONSUMERS=$(curl -s http://host.docker.internal:8100/api/maestro/v1/consumers 2>/dev/null | \
jq -r '.items[]? | " - \(.name) (id: \(.id))"' 2>/dev/null)
if [ -n "$CONSUMERS" ]; then
echo "$CONSUMERS"
else
echo " Could not connect to Maestro (is it running?)"
fi
# Check HyperFleet API
echo -e "\n[3] HyperFleet API:"
CLUSTER_COUNT=$(curl -s http://host.docker.internal:8000/api/hyperfleet/v1/clusters 2>/dev/null | \
jq -r '.total? // "error"' 2>/dev/null)
if [ "$CLUSTER_COUNT" != "error" ]; then
echo " Connected - $CLUSTER_COUNT clusters found"
else
echo " Could not connect to HyperFleet API"
fi
# Suggest configuration
echo -e "\n[4] Suggested adapter-config.yaml settings:"
echo " hyperfleetApi.baseUrl: http://host.docker.internal:8000"
echo " maestro.httpServerAddress: http://host.docker.internal:8100"
echo " maestro.grpcServerAddress: host.docker.internal:8090"
echo " broker.googlepubsub.project_id: \"$PROJECT\""
echo ""What to Check
| Check | Purpose | Fix if Missing |
|---|---|---|
| GCP project | Pub/Sub event delivery | Run gcloud auth login and gcloud config set project PROJECT_ID |
| Maestro consumers | ManifestWork target | Check Maestro deployment, verify consumer registration |
| HyperFleet API | Cluster data and status reporting | Verify HyperFleet API is running on port 8000 |
| Pub/Sub subscription | Event reception | Create subscription with gcloud pubsub subscriptions create |
Critical Gotchas
ALWAYS apply these rules when generating configs:
observed_generationMUST use CEL expression, not Go Template.
Go Templates output strings, but the API expects an integer. CEL preserves the numeric type.# CORRECT observed_generation: expression: "generation" # WRONG — sends string "5" instead of integer 5 observed_generation: "{{ .generation }}"Capture scope can only see the API response, not params.
Capture expressions operate on the raw API response body. They cannot reference params or other captured values.Condition scope sees the full context.
Conditions (both structured and CEL expression) can access all params, all captured fields, and the full API response via the precondition name (e.g.,clusterStatus.status.conditions).Resource names must be lowercase, no hyphens (CEL-compatible identifiers).
Use camelCase or underscores:clusterNamespace,job_role— notcluster-namespace.byNamevsbySelectorsare mutually exclusive in discovery config.fieldvsexpressionare mutually exclusive in captures.Post-actions always execute, even when preconditions are not met or resources fail. Design your status payload CEL expressions to handle all cases (success, skip, error).
Use optional chaining (
?.and.orValue()) in CEL expressions for safe access to fields that may not exist:resources.?clusterNamespace.?status.?phase.orValue("")Register the adapter name in
HYPERFLEET_CLUSTER_ADAPTERS(orHYPERFLEET_NODEPOOL_ADAPTERS) env var on the API. Without this, the adapter won't participate in status aggregation.URLs in apiCall are relative — the base URL comes from AdapterConfig's
clients.hyperfleetApi.baseUrl. Only write the path (e.g.,/clusters/{{ .clusterId }}).Status reporting uses
POST, notPATCHorPUT.
The HyperFleet statuses endpoint only acceptsPOST. Using any other method returns405 Method Not Allowed.# CORRECT postActions: - name: "updateStatus" apiCall: method: "POST" url: "/api/hyperfleet/v1/clusters/{{ .clusterId }}/statuses" # WRONG — 405 Method Not Allowed method: "PATCH"Maestro:
bySelectorsdiscovery may not seeApplied=Trueimmediately after ManifestWork creation.byNameuses a directGetManifestWorkgRPC call and typically returns status conditions within milliseconds of the Maestro agent acknowledging the work.bySelectorsusesListManifestWorksand filters in-memory — the list snapshot may not yet include the agent's status update.- If you need
Applied=Truein the same adapter execution cycle, usebyName. - If
bySelectorsis required (e.g., to test label-based lookup), design the CEL expressions to handleApplied=Falsegracefully. The Sentinel will re-trigger and the second event (same generation →OperationSkip+ re-discovery) will see the updated status.
- If you need
Maestro ManifestWork:
hyperfleet.io/generationannotation is required on the ManifestWork AND on every nested manifest insidespec.workload.manifests.
The framework validates both levels. Missing the annotation on any nested manifest causes the apply to fail.metadata: annotations: hyperfleet.io/generation: "{{ .generation }}" # on ManifestWork spec: workload: manifests: - apiVersion: v1 kind: Namespace metadata: annotations: hyperfleet.io/generation: "{{ .generation }}" # also on each manifestPrecondition skip via existing adapter status count — preferred over checking
Readycondition.
TheReadycondition on a cluster is managed internally by HyperFleet and cannot be set via the API. To implement a "skip if already processed" pattern (e.g., for one-shot adapters), capture the count of existing adapter statuses and skip if non-zero:preconditions: - name: "fetchAdapterStatuses" apiCall: method: "GET" url: "/api/hyperfleet/v1/clusters/{{ .clusterId }}/statuses" capture: - name: "existingStatusCount" expression: "items.size()" conditions: - field: "existingStatusCount" operator: "equals" value: "0"To test the skip path in integration tests, pre-POST a dummy adapter status before publishing the CloudEvent so
existingStatusCountwill be 1 when the adapter checks.Maestro configuration must use specific field names.
The AdapterConfigmaestrosection requires exact field names. Common mistakes:# WRONG - these field names don't work maestro: baseUrl: http://maestro:8000 grpcUrl: maestro:8090 # CORRECT - use these exact field names maestro: httpServerAddress: http://host.docker.internal:8100 grpcServerAddress: host.docker.internal:8090 sourceId: my-adapter-name # REQUIRED timeout: 30s insecure: trueMaestro consumer must exist before creating ManifestWorks.
TheplacementClusterNamein your task config must match an existing consumer registered in Maestro. If you get a foreign key constraint error likefk_resources_consumers, the consumer doesn't exist.List available consumers:
curl -s http://host.docker.internal:8100/api/maestro/v1/consumers | jq '.items[].name'Update your placementClusterName capture to match:
- name: "placementClusterName" expression: "\"cluster1\"" # Must be an existing Maestro consumer
Standard Health Condition Boilerplate
Copy this exactly into every adapter's Health condition. Do not modify it.
- type: "Health"
status:
expression: |
adapter.?executionStatus.orValue("") == "success"
&& !adapter.?resourcesSkipped.orValue(false)
? "True"
: "False"
reason:
expression: |
adapter.?executionStatus.orValue("") != "success"
? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown")
: adapter.?resourcesSkipped.orValue(false)
? "ResourcesSkipped"
: "Healthy"
message:
expression: |
adapter.?executionStatus.orValue("") != "success"
? "Adapter failed at phase ["
+ adapter.?executionError.?phase.orValue("unknown")
+ "] step ["
+ adapter.?executionError.?step.orValue("unknown")
+ "]: "
+ adapter.?executionError.?message.orValue(
adapter.?errorMessage.orValue("no details"))
: adapter.?resourcesSkipped.orValue(false)
? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason")
: "Adapter execution completed successfully"Templates for Common Patterns
Template 1: Kubernetes Cluster Adapter (Namespace + ConfigMap)
apiVersion: hyperfleet.redhat.com/v1alpha1
kind: AdapterTaskConfig
metadata:
name: ADAPTER_NAME
labels:
hyperfleet.io/adapter-type: ADAPTER_NAME
hyperfleet.io/component: adapter
spec:
params:
- name: "clusterId"
source: "event.id"
type: "string"
required: true
- name: "generation"
source: "event.generation"
type: "int"
required: true
preconditions:
- name: "clusterStatus"
apiCall:
method: "GET"
url: "/clusters/{{ .clusterId }}"
timeout: 10s
retryAttempts: 3
retryBackoff: "exponential"
capture:
- name: "clusterName"
field: "name"
- name: "generation"
field: "generation"
- name: "readyConditionStatus"
expression: |
status.conditions.filter(c, c.type == "Ready").size() > 0
? status.conditions.filter(c, c.type == "Ready")[0].status
: "False"
conditions:
- field: "readyConditionStatus"
operator: "equals"
value: "False"
resources:
- name: "clusterNamespace"
transport:
client: "kubernetes"
manifest:
apiVersion: v1
kind: Namespace
metadata:
name: "{{ .clusterId | lower }}"
labels:
hyperfleet.io/cluster-id: "{{ .clusterId }}"
hyperfleet.io/managed-by: "{{ .metadata.name }}"
hyperfleet.io/resource-type: "namespace"
annotations:
hyperfleet.io/generation: "{{ .generation }}"
discovery:
byName: "{{ .clusterId | lower }}"
- name: "clusterConfigMap"
transport:
client: "kubernetes"
manifest:
apiVersion: v1
kind: ConfigMap
metadata:
name: "{{ .clusterId }}-config"
namespace: "{{ .clusterId | lower }}"
labels:
hyperfleet.io/cluster-id: "{{ .clusterId }}"
hyperfleet.io/managed-by: "{{ .metadata.name }}"
hyperfleet.io/resource-type: "configmap"
data:
cluster_id: "{{ .clusterId }}"
cluster_name: "{{ .clusterName }}"
discovery:
namespace: "{{ .clusterId | lower }}"
bySelectors:
labelSelector:
hyperfleet.io/cluster-id: "{{ .clusterId }}"
hyperfleet.io/resource-type: "configmap"
post:
payloads:
- name: "clusterStatusPayload"
build:
adapter: "{{ .metadata.name }}"
conditions:
- type: "Applied"
status:
expression: |
has(resources.clusterNamespace) ? "True" : "False"
reason:
expression: |
has(resources.clusterNamespace) ? "Applied" : "Pending"
message:
expression: |
has(resources.clusterNamespace)
? "Resources applied successfully"
: "Resources pending"
- type: "Available"
status:
expression: |
resources.?clusterNamespace.?status.?phase.orValue("") == "Active"
? "True" : "False"
reason:
expression: |
resources.?clusterNamespace.?status.?phase.orValue("") == "Active"
? "NamespaceReady" : "NamespaceNotReady"
message:
expression: |
resources.?clusterNamespace.?status.?phase.orValue("") == "Active"
? "Namespace is active" : "Namespace not yet active"
- type: "Health"
status:
expression: |
adapter.?executionStatus.orValue("") == "success"
&& !adapter.?resourcesSkipped.orValue(false)
? "True"
: "False"
reason:
expression: |
adapter.?executionStatus.orValue("") != "success"
? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown")
: adapter.?resourcesSkipped.orValue(false)
? "ResourcesSkipped"
: "Healthy"
message:
expression: |
adapter.?executionStatus.orValue("") != "success"
? "Adapter failed at phase ["
+ adapter.?executionError.?phase.orValue("unknown")
+ "] step ["
+ adapter.?executionError.?step.orValue("unknown")
+ "]: "
+ adapter.?executionError.?message.orValue(
adapter.?errorMessage.orValue("no details"))
: adapter.?resourcesSkipped.orValue(false)
? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason")
: "Adapter execution completed successfully"
observed_generation:
expression: "generation"
observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}"
data:
namespace:
name:
expression: |
resources.?clusterNamespace.?metadata.?name.orValue("")
phase:
expression: |
resources.?clusterNamespace.?status.?phase.orValue("")
postActions:
- name: "reportClusterStatus"
apiCall:
method: "POST"
url: "/clusters/{{ .clusterId }}/statuses"
headers:
- name: "Content-Type"
value: "application/json"
body: "{{ .clusterStatusPayload }}"Template 2: Maestro Cluster Adapter (ManifestWork)
apiVersion: hyperfleet.redhat.com/v1alpha1
kind: AdapterTaskConfig
metadata:
name: ADAPTER_NAME
labels:
hyperfleet.io/adapter-type: ADAPTER_NAME
hyperfleet.io/component: adapter
spec:
params:
- name: "clusterId"
source: "event.id"
type: "string"
required: true
- name: "generation"
source: "event.generation"
type: "int"
required: true
preconditions:
- name: "clusterStatus"
apiCall:
method: "GET"
url: "/clusters/{{ .clusterId }}"
timeout: 10s
retryAttempts: 3
retryBackoff: "exponential"
capture:
- name: "clusterName"
field: "name"
- name: "generation"
field: "generation"
- name: "placementClusterName"
expression: "\"cluster1\"" # IMPORTANT: Must match an existing Maestro consumer
# To find available consumers: curl http://host.docker.internal:8100/api/maestro/v1/consumers | jq '.items[].name'
# Or use dynamic placement: has(spec.placement.cluster) ? spec.placement.cluster : "cluster1"
- name: "readyConditionStatus"
expression: |
status.conditions.filter(c, c.type == "Ready").size() > 0
? status.conditions.filter(c, c.type == "Ready")[0].status
: "False"
conditions:
- field: "readyConditionStatus"
operator: "equals"
value: "False"
resources:
- name: "clusterManifestWork"
transport:
client: "maestro"
maestro:
targetCluster: "{{ .placementClusterName }}"
manifest:
apiVersion: work.open-cluster-management.io/v1
kind: ManifestWork
metadata:
name: "{{ .clusterId }}"
labels:
hyperfleet.io/cluster-id: "{{ .clusterId }}"
hyperfleet.io/managed-by: "{{ .metadata.name }}"
annotations:
hyperfleet.io/generation: "{{ .generation }}" # required on ManifestWork
spec:
workload:
manifests:
- apiVersion: v1
kind: Namespace
metadata:
name: "{{ .clusterId | lower }}"
labels:
hyperfleet.io/cluster-id: "{{ .clusterId }}"
hyperfleet.io/resource-type: "namespace"
annotations:
hyperfleet.io/generation: "{{ .generation }}" # required on each manifest
- apiVersion: v1
kind: ConfigMap
metadata:
name: "{{ .clusterId }}-config"
namespace: "{{ .clusterId | lower }}"
labels:
hyperfleet.io/cluster-id: "{{ .clusterId }}"
hyperfleet.io/resource-type: "configmap"
annotations:
hyperfleet.io/generation: "{{ .generation }}" # required on each manifest
data:
cluster_id: "{{ .clusterId }}"
manifestConfigs:
- resourceIdentifier:
group: ""
resource: "namespaces"
name: "{{ .clusterId | lower }}"
updateStrategy:
type: "ServerSideApply"
feedbackRules:
- type: "JSONPaths"
jsonPaths:
- name: "phase"
path: ".status.phase"
discovery:
byName: "{{ .clusterId }}"
nestedDiscoveries:
- name: "mgmtNamespace"
discovery:
bySelectors:
labelSelector:
hyperfleet.io/resource-type: "namespace"
hyperfleet.io/cluster-id: "{{ .clusterId }}"
- name: "mgmtConfigMap"
discovery:
byName: "{{ .clusterId }}-config"
post:
payloads:
- name: "clusterStatusPayload"
build:
adapter: "{{ .metadata.name }}"
conditions:
- type: "Applied"
status:
expression: |
has(resources.clusterManifestWork) ? "True" : "False"
reason:
expression: |
has(resources.clusterManifestWork) ? "ManifestWorkApplied" : "ManifestWorkPending"
message:
expression: |
has(resources.clusterManifestWork)
? "ManifestWork applied successfully"
: "ManifestWork pending"
- type: "Available"
status:
expression: |
resources.?clusterManifestWork.?status.?conditions.orValue([])
.filter(c, c.type == "Available").size() > 0
? resources.clusterManifestWork.status.conditions
.filter(c, c.type == "Available")[0].status
: "Unknown"
reason:
expression: |
resources.?clusterManifestWork.?status.?conditions.orValue([])
.filter(c, c.type == "Available").size() > 0
? resources.clusterManifestWork.status.conditions
.filter(c, c.type == "Available")[0].reason
: "ManifestWorkNotReady"
message:
expression: |
resources.?clusterManifestWork.?status.?conditions.orValue([])
.filter(c, c.type == "Available").size() > 0
? resources.clusterManifestWork.status.conditions
.filter(c, c.type == "Available")[0].message
: "ManifestWork not yet available"
- type: "Health"
status:
expression: |
adapter.?executionStatus.orValue("") == "success"
&& !adapter.?resourcesSkipped.orValue(false)
? "True"
: "False"
reason:
expression: |
adapter.?executionStatus.orValue("") != "success"
? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown")
: adapter.?resourcesSkipped.orValue(false)
? "ResourcesSkipped"
: "Healthy"
message:
expression: |
adapter.?executionStatus.orValue("") != "success"
? "Adapter failed at phase ["
+ adapter.?executionError.?phase.orValue("unknown")
+ "] step ["
+ adapter.?executionError.?step.orValue("unknown")
+ "]: "
+ adapter.?executionError.?message.orValue(
adapter.?errorMessage.orValue("no details"))
: adapter.?resourcesSkipped.orValue(false)
? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason")
: "Adapter execution completed successfully"
observed_generation:
expression: "generation"
observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}"
postActions:
- name: "reportClusterStatus"
apiCall:
method: "POST"
url: "/clusters/{{ .clusterId }}/statuses"
headers:
- name: "Content-Type"
value: "application/json"
body: "{{ .clusterStatusPayload }}"Template 3: NodePool Adapter (with parent cluster readiness check)
apiVersion: hyperfleet.redhat.com/v1alpha1
kind: AdapterTaskConfig
metadata:
name: ADAPTER_NAME
labels:
hyperfleet.io/adapter-type: ADAPTER_NAME
hyperfleet.io/component: adapter
spec:
params:
- name: "clusterId"
source: "event.owned_reference.id"
type: "string"
required: true
- name: "nodepoolId"
source: "event.id"
type: "string"
required: true
preconditions:
- name: "nodepoolStatus"
apiCall:
method: "GET"
url: "/clusters/{{ .clusterId }}/nodepools/{{ .nodepoolId }}"
timeout: 10s
retryAttempts: 3
retryBackoff: "exponential"
capture:
- name: "nodepoolName"
field: "name"
- name: "generation"
field: "generation"
- name: "readyConditionStatus"
expression: |
status.conditions.filter(c, c.type == "Ready").size() > 0
? status.conditions.filter(c, c.type == "Ready")[0].status
: "False"
conditions:
- field: "readyConditionStatus"
operator: "equals"
value: "False"
- name: "clusterAdapterStatus"
apiCall:
method: "GET"
url: "/clusters/{{ .clusterId }}/statuses"
timeout: 10s
retryAttempts: 3
retryBackoff: "exponential"
capture:
- name: "clusterNamespaceStatus"
field: "{.items[?(@.adapter=='landing-zone')].data.namespace.status}"
conditions:
- field: "clusterNamespaceStatus"
operator: "equals"
value: "Active"
resources:
- name: "nodepoolConfigMap"
transport:
client: "kubernetes"
manifest:
apiVersion: v1
kind: ConfigMap
metadata:
name: "{{ .nodepoolId }}-config"
namespace: "{{ .clusterId | lower }}"
labels:
hyperfleet.io/cluster-id: "{{ .clusterId }}"
hyperfleet.io/nodepool-id: "{{ .nodepoolId }}"
hyperfleet.io/managed-by: "{{ .metadata.name }}"
hyperfleet.io/resource-type: "configmap"
data:
nodepool_id: "{{ .nodepoolId }}"
nodepool_name: "{{ .nodepoolName }}"
cluster_id: "{{ .clusterId }}"
discovery:
namespace: "{{ .clusterId | lower }}"
bySelectors:
labelSelector:
hyperfleet.io/cluster-id: "{{ .clusterId }}"
hyperfleet.io/nodepool-id: "{{ .nodepoolId }}"
hyperfleet.io/resource-type: "configmap"
post:
payloads:
- name: "nodepoolStatusPayload"
build:
adapter: "{{ .metadata.name }}"
conditions:
- type: "Applied"
status:
expression: |
has(resources.nodepoolConfigMap) ? "True" : "False"
reason:
expression: |
has(resources.nodepoolConfigMap) ? "ConfigMapApplied" : "ConfigMapPending"
message:
expression: |
has(resources.nodepoolConfigMap)
? "ConfigMap applied successfully"
: "ConfigMap pending"
- type: "Available"
status:
expression: |
has(resources.nodepoolConfigMap) && has(resources.nodepoolConfigMap.data)
? "True" : "False"
reason:
expression: |
has(resources.nodepoolConfigMap) && has(resources.nodepoolConfigMap.data)
? "ConfigMapReady" : "ConfigMapNotReady"
message:
expression: |
has(resources.nodepoolConfigMap) && has(resources.nodepoolConfigMap.data)
? "ConfigMap is available" : "ConfigMap not yet available"
- type: "Health"
status:
expression: |
adapter.?executionStatus.orValue("") == "success"
&& !adapter.?resourcesSkipped.orValue(false)
? "True"
: "False"
reason:
expression: |
adapter.?executionStatus.orValue("") != "success"
? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown")
: adapter.?resourcesSkipped.orValue(false)
? "ResourcesSkipped"
: "Healthy"
message:
expression: |
adapter.?executionStatus.orValue("") != "success"
? "Adapter failed at phase ["
+ adapter.?executionError.?phase.orValue("unknown")
+ "] step ["
+ adapter.?executionError.?step.orValue("unknown")
+ "]: "
+ adapter.?executionError.?message.orValue(
adapter.?errorMessage.orValue("no details"))
: adapter.?resourcesSkipped.orValue(false)
? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason")
: "Adapter execution completed successfully"
observed_generation:
expression: "generation"
observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}"
postActions:
- name: "reportNodepoolStatus"
apiCall:
method: "POST"
url: "/clusters/{{ .clusterId }}/nodepools/{{ .nodepoolId }}/statuses"
headers:
- name: "Content-Type"
value: "application/json"
body: "{{ .nodepoolStatusPayload }}"Template 4: No-Op / Validation Adapter
Use when you only need preconditions and status reporting, with no resource creation:
apiVersion: hyperfleet.redhat.com/v1alpha1
kind: AdapterTaskConfig
metadata:
name: ADAPTER_NAME
labels:
hyperfleet.io/adapter-type: ADAPTER_NAME
hyperfleet.io/component: adapter
spec:
params:
- name: "clusterId"
source: "event.id"
type: "string"
required: true
- name: "generation"
source: "event.generation"
type: "int"
required: true
preconditions:
- name: "clusterStatus"
apiCall:
method: "GET"
url: "/clusters/{{ .clusterId }}"
timeout: 10s
retryAttempts: 3
retryBackoff: "exponential"
capture:
- name: "generation"
field: "generation"
- name: "readyConditionStatus"
expression: |
status.conditions.filter(c, c.type == "Ready").size() > 0
? status.conditions.filter(c, c.type == "Ready")[0].status
: "False"
conditions:
- field: "readyConditionStatus"
operator: "equals"
value: "False"
resources: []
post:
payloads:
- name: "clusterStatusPayload"
build:
adapter: "{{ .metadata.name }}"
conditions:
- type: "Applied"
status:
expression: |
"True"
reason:
expression: |
"NoResourcesNeeded"
message:
expression: |
"No-op adapter: no resources to apply"
- type: "Available"
status:
expression: |
"True"
reason:
expression: |
"ValidationPassed"
message:
expression: |
"Validation completed successfully"
- type: "Health"
status:
expression: |
adapter.?executionStatus.orValue("") == "success"
&& !adapter.?resourcesSkipped.orValue(false)
? "True"
: "False"
reason:
expression: |
adapter.?executionStatus.orValue("") != "success"
? "ExecutionFailed:" + adapter.?executionError.?phase.orValue("unknown")
: adapter.?resourcesSkipped.orValue(false)
? "ResourcesSkipped"
: "Healthy"
message:
expression: |
adapter.?executionStatus.orValue("") != "success"
? "Adapter failed at phase ["
+ adapter.?executionError.?phase.orValue("unknown")
+ "] step ["
+ adapter.?executionError.?step.orValue("unknown")
+ "]: "
+ adapter.?executionError.?message.orValue(
adapter.?errorMessage.orValue("no details"))
: adapter.?resourcesSkipped.orValue(false)
? "Resources skipped: " + adapter.?skipReason.orValue("unknown reason")
: "Adapter execution completed successfully"
observed_generation:
expression: "generation"
observed_time: "{{ now | date \"2006-01-02T15:04:05Z07:00\" }}"
postActions:
- name: "reportClusterStatus"
apiCall:
method: "POST"
url: "/clusters/{{ .clusterId }}/statuses"
headers:
- name: "Content-Type"
value: "application/json"
body: "{{ .clusterStatusPayload }}"Template: AdapterConfig (use with any of the above)
For Kubernetes direct transport:
apiVersion: hyperfleet.redhat.com/v1alpha1
kind: AdapterConfig
metadata:
name: ADAPTER_NAME
labels:
hyperfleet.io/adapter-type: ADAPTER_NAME
hyperfleet.io/component: adapter
spec:
adapter:
version: "0.1.0"
debugConfig: false
log:
level: info
clients:
hyperfleetApi:
baseUrl: http://host.docker.internal:8000
version: v1
timeout: 2s
retryAttempts: 3
retryBackoff: exponential
broker:
subscriptionId: "ADAPTER_NAME-sub"
topic: "cluster-events"
kubernetes:
apiVersion: "v1"For Maestro transport (add this to clients section):
clients:
hyperfleetApi:
baseUrl: http://host.docker.internal:8000
version: v1
timeout: 2s
retryAttempts: 3
retryBackoff: exponential
broker:
subscriptionId: "ADAPTER_NAME-sub"
topic: "cluster-events"
kubernetes:
apiVersion: "v1"
maestro:
httpServerAddress: http://host.docker.internal:8100
grpcServerAddress: host.docker.internal:8090
sourceId: ADAPTER_NAME # Must match adapter name
timeout: 30s
insecure: true # For dev environmentsRunning Live (Real Infrastructure)
Start the adapter
# Required environment variables
export BROKER_CONFIG_FILE=/path/to/broker-config.yaml # separate from adapter-config
export KUBECONFIG=/path/to/kubeconfig # K8s credentials
hyperfleet-adapter serve \
-c adapter-config.yaml \
-t adapter-task-config.yaml \
--log-level debug # debug | info | warn | errorThe broker config is a separate file from the adapter config (the broker library reads it via BROKER_CONFIG_FILE, not from AdapterConfig). For Google Pub/Sub:
# broker-config.yaml
broker:
type: googlepubsub
googlepubsub:
project_id: "my-gcp-project"
ack_deadline_seconds: 60
create_topic_if_missing: false
create_subscription_if_missing: false
subscriber:
parallelism: 1Trigger an event manually
CLUSTER_ID="abc123"
gcloud pubsub topics publish cluster-events \
--message="$(jq -n \
--arg id "$CLUSTER_ID" \
--arg source "/api/hyperfleet/v1/clusters/$CLUSTER_ID" \
--arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{
specversion: "1.0",
id: $id,
type: "io.hyperfleet.cluster.updated",
source: $source,
time: $time,
datacontenttype: "application/json",
data: { id: $id, kind: "Cluster",
href: ("/api/hyperfleet/v1/clusters/" + $id), generation: 1 }
}')"Read the logs
With --log-level debug, the adapter emits structured log lines for each pipeline phase. Key patterns:
| Log message | Meaning |
|---|---|
Phase param_extraction: RUNNING |
Event received, extracting params |
Phase preconditions: SUCCESS - MET |
Conditions passed, resources will run |
Phase preconditions: SUCCESS - NOT MET |
Conditions not met, resources skipped (normal convergence behavior) |
Resource[name] processed: operation=create |
Resource created |
Resource[name] processed: operation=skip |
Same generation, no-op (idempotent) |
Resource[name] discovered and stored in context |
Post-apply discovery succeeded |
Phase post_actions: SUCCESS |
Status payload posted to HyperFleet |
Event execution finished: event_execution_status=success |
Full pipeline completed |
HyperFleet API response: 400 Bad Request |
Payload shape wrong — check method/body |
HyperFleet API response: 405 Method Not Allowed |
Wrong HTTP method — statuses endpoint requires POST |
An error in any phase sets adapter.executionStatus=failed but post-actions always run.
Verify results
CLUSTER_ID="abc123"
# Check status reported to HyperFleet
curl -s "http://localhost:8000/api/hyperfleet/v1/clusters/$CLUSTER_ID/statuses" | jq .
# Check Kubernetes resource (direct transport)
kubectl get ns "hf-$CLUSTER_ID"
kubectl get all -n "hf-$CLUSTER_ID"
# Check ManifestWork applied on spoke cluster (Maestro transport)
kubectl --context "$GKE_CONTEXT" get appliedmanifestworks
kubectl --context "$GKE_CONTEXT" get ns "hf-$CLUSTER_ID"Expected status payload when the adapter is healthy:
{
"items": [{
"adapter": "my-adapter",
"conditions": [
{ "type": "Applied", "status": "True", "reason": "..." },
{ "type": "Available", "status": "True", "reason": "..." },
{ "type": "Health", "status": "True", "reason": "Healthy" }
],
"observed_generation": 1
}]
}If observed_generation appears as a quoted string ("1") instead of an integer, your payload config is using "{{ .generation }}" — fix it to expression: "generation".
Common Errors & Solutions
| Error Message | Root Cause | Solution |
|---|---|---|
maestro sourceID is required |
Missing sourceId in adapter-config.yaml |
Add sourceId: adapter-name to clients.maestro section |
maestro server address is required |
Wrong field names in config | Use httpServerAddress and grpcServerAddress (not baseUrl/grpcUrl) |
fk_resources_consumers constraint violation |
Target consumer doesn't exist in Maestro | List consumers with curl http://host.docker.internal:8100/api/maestro/v1/consumers | jq '.items[].name' and update placementClusterName capture to match |
Cluster with id='...' not found (404) |
Cluster doesn't exist in HyperFleet API | Create cluster first or verify cluster ID in event matches actual cluster |
405 Method Not Allowed on status endpoint |
Using PATCH/PUT instead of POST | Change method: "POST" in postActions apiCall |
observed_generation is string not int |
Using Go template for generation | Change to expression: "generation" (CEL) instead of "{{ .generation }}" |
| Adapter not receiving events | Subscription misconfigured | Verify subscription exists: gcloud pubsub subscriptions describe sub-name |
| ManifestWork created but resources not applied | Maestro agent not running on target | Check agent pods: kubectl get pods -n open-cluster-management-agent |
Phase preconditions: FAILED |
API returned unexpected status | Check adapter logs with --log-level debug to see full API response |
| Resources skipped unexpectedly | Precondition conditions evaluated to NOT MET | Review condition logic and captured field values in logs |
Verification Quick Commands
# Check adapter is running
ps aux | grep "[a]dapter serve"
# View last 50 log lines
tail -50 /path/to/adapter.log
# Get latest status for a cluster (sorted by time)
CLUSTER_ID="abc123"
curl -s http://host.docker.internal:8000/api/hyperfleet/v1/clusters/$CLUSTER_ID/statuses | \
jq '.items | sort_by(.updated_time // .created_time) | reverse | .[0] | {adapter, conditions: .conditions | map({type, status, reason})}'
# Check if ManifestWork was created in Maestro
curl -s "http://host.docker.internal:8100/api/maestro/v1/resources" | \
jq '.items[] | select(.manifest.kind=="ManifestWork") | .manifest.metadata.name'
# Trigger a test event
CLUSTER_ID="test-123"
gcloud pubsub topics publish cluster-events --message="$(jq -n \
--arg id "$CLUSTER_ID" \
'{specversion:"1.0",id:$id,type:"io.hyperfleet.cluster.updated",source:("/api/hyperfleet/v1/clusters/"+$id),data:{id:$id,kind:"Cluster",generation:1}}')"Dry-Run Testing Instructions
After generating configs, offer to create dry-run mock files. The command to test:
hyperfleet-adapter serve \
--config ./adapter-config.yaml \
--task-config ./adapter-task-config.yaml \
--dry-run-event ./event.json \
--dry-run-api-responses ./api-responses.json \
--dry-run-discovery ./discovery-overrides.json \
--dry-run-verbose \
--dry-run-output textMock event.json (Cluster)
{
"specversion": "1.0",
"id": "test-event-001",
"type": "io.hyperfleet.cluster.updated",
"source": "/api/hyperfleet/v1/clusters/abc123",
"data": {
"id": "abc123",
"kind": "Cluster",
"href": "/api/hyperfleet/v1/clusters/abc123",
"generation": 5
}
}Mock event.json (NodePool)
{
"specversion": "1.0",
"id": "test-event-002",
"type": "io.hyperfleet.nodepool.updated",
"source": "/api/hyperfleet/v1/clusters/abc123/nodepools/np456",
"data": {
"id": "np456",
"kind": "NodePool",
"href": "/api/hyperfleet/v1/clusters/abc123/nodepools/np456",
"generation": 3,
"owned_reference": {
"id": "abc123",
"kind": "Cluster"
}
}
}Mock api-responses.json
{
"responses": [
{
"match": {
"method": "GET",
"urlPattern": "/clusters/.*"
},
"responses": [
{
"statusCode": 200,
"body": {
"id": "abc123",
"name": "my-cluster",
"generation": 5,
"status": {
"conditions": [
{ "type": "Ready", "status": "False" }
]
}
}
}
]
},
{
"match": {
"method": "POST",
"urlPattern": "/clusters/.*/statuses"
},
"responses": [
{ "statusCode": 200, "body": {} }
]
}
]
}Mock discovery-overrides.json (Namespace example)
{
"abc123": {
"apiVersion": "v1",
"kind": "Namespace",
"metadata": {
"name": "abc123",
"uid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"resourceVersion": "100",
"labels": {
"hyperfleet.io/cluster-id": "abc123",
"hyperfleet.io/resource-type": "namespace"
}
},
"status": {
"phase": "Active"
}
}
}Reading the Trace Output
The dry-run produces a phase-by-phase trace showing exactly what happened. Use --dry-run-verbose to see rendered manifests and full API request/response bodies. Use --dry-run-output json for machine-readable output you can pipe into jq.
Example trace:
Dry-Run Execution Trace
========================
Event: id=abc123 type=io.hyperfleet.cluster.updated
Phase 1: Parameter Extraction .............. SUCCESS
clusterId = "abc123"
generation = 5
region = "us-east-1"
Phase 2: Preconditions ..................... SUCCESS (MET)
[1/1] fetch-cluster PASS
API Call: GET /api/hyperfleet/v1/clusters/abc123 -> 200
Captured: clusterName = "my-cluster"
Captured: readyStatus = "False"
Phase 3: Resources ........................ SUCCESS
[1/2] namespace0 CREATE
Kind: Namespace Namespace: Name: abc123
[2/2] configmap0 CREATE
Kind: ConfigMap Namespace: abc123 Name: abc123-config
Phase 3.5: Discovery Results ................. (available as resources.* in payload)
namespace0:
{"apiVersion":"v1","kind":"Namespace","metadata":{"name":"abc123",...},"status":{"phase":"Active"}}
Phase 4: Post Actions ..................... SUCCESS
[1/1] update-status EXECUTED
API Call: POST /api/hyperfleet/v1/clusters/abc123/statuses -> 200
Result: SUCCESSWhat to look for in the trace:
| Phase | What to check |
|---|---|
| Phase 1 | All params extracted with expected values. Missing required params cause FAIL. |
| Phase 2 | API calls return expected status codes. Captures extract the right values. Conditions evaluate to MET (resources will execute) or NOT MET (resources skipped). |
| Phase 3 | Resources show CREATE, UPDATE, or SKIP. Check the rendered Kind/Namespace/Name match expectations. |
| Phase 3.5 | Discovery results show the mock data you provided. These are what resources.* CEL expressions will see in Phase 4. |
| Phase 4 | Post-actions execute. Check the status payload body contains correct condition statuses (True/False/Unknown) and observed_generation is an integer, not a string. |
Development Loop
- Write your
adapter-task-config.yaml - Create mock files for a representative cluster state
- Run dry-run, inspect the trace
- Fix config issues, re-run — iterate until the trace shows expected behavior
- Test edge cases by modifying mock files:
- Change
readyConditionStatusto"True"in API response — preconditions should evaluate toNOT MET, resources should be skipped - Remove fields from API response — CEL optional chaining (
?.orValue()) should handle missing data gracefully - Change discovery overrides to simulate pending resources (e.g., Namespace with
status.phase: "Pending") —Availablecondition should report"False"or"Unknown" - Return error status codes (404, 500) from mock API responses — Health condition should surface the error details
- Test with empty
discovery-overrides.json({}) —Appliedcondition should report"False"sincehas(resources.xxx)will be false
- Change
- Deploy when the trace shows correct behavior for all cases
Common Dry-Run Issues
| Symptom | Cause | Fix |
|---|---|---|
observed_generation shows as "5" (string) in payload |
Using Go Template "{{ .generation }}" |
Use CEL: expression: "generation" |
| Capture returns empty/nil | CEL expression references a param instead of API response field | Captures can only see the API response body, not params |
Resources phase shows SKIP unexpectedly |
Discovery override key doesn't match the rendered resource name | Check the resource name after Go Template rendering |
Phase 2: NOT MET when you expect MET |
Condition logic is inverted or captured value doesn't match | Add --dry-run-verbose to see exact captured values |
| Post-action payload missing fields | CEL optional chaining not used, field doesn't exist in discovery | Use resources.?name.?field.orValue("") pattern |
CEL Quick Reference
# Optional chaining — safe access to fields that may not exist
resources.?clusterNamespace.?status.?phase.orValue("")
# Existence check
has(resources.clusterNamespace)
# Array filtering
status.conditions.filter(c, c.type == "Ready")
# Array existence check
status.conditions.exists(c, c.type == "Ready" && c.status == "True")
# Get first matching element with fallback
status.conditions.filter(c, c.type == "Ready").size() > 0
? status.conditions.filter(c, c.type == "Ready")[0].status
: "False"
# Ternary
condition ? "yes" : "no"
# String concatenation
"prefix-" + clusterId + "-suffix"Go Template Quick Reference
{{ .variableName }} Variable interpolation
{{ .clusterId | lower }} Lowercase filter
{{ now | date "2006-01-02T15:04:05Z07:00" }} Current timestamp (RFC 3339)
{{ .metadata.name }} Adapter name from config metadataComplete Live Testing Workflow
Follow this workflow after generating adapter configs to ensure everything works end-to-end:
Step 1: Pre-Flight Checks
Run the environment detection script (see "Pre-Flight Environment Check" section) to verify:
- ✅ GCP project is set
- ✅ Maestro consumers exist (if using Maestro)
- ✅ HyperFleet API is accessible
- ✅ Pub/Sub subscription exists
Step 2: Dry-Run Test
Always test with dry-run first:
/adapter serve \
--config adapter-config.yaml \
--task-config adapter-task-config.yaml \
--dry-run-event event.json \
--dry-run-api-responses api-responses.json \
--dry-run-discovery discovery-overrides.json \
--dry-run-verbose \
--dry-run-output textVerify all 4 phases succeed and status payload looks correct.
Step 3: Create Test Data (if needed)
If testing with a real cluster that doesn't exist yet, create it:
# Example: Create test cluster in HyperFleet API
curl -X POST http://host.docker.internal:8000/api/hyperfleet/v1/clusters \
-H "Content-Type: application/json" \
-d '{
"name": "test-cluster-001",
"kind": "Cluster",
"spec": {
"region": "us-east-1",
"version": "4.15.0"
}
}'Step 4: Start Live Adapter
export BROKER_CONFIG_FILE=/path/to/broker-config.yaml
/adapter serve \
-c adapter-config.yaml \
-t adapter-task-config.yaml \
--log-level debugWatch for these log messages confirming successful startup:
Maestro client created successfully(if using Maestro)Successfully subscribed to topic ... subscription ...Adapter is ready to process events
Step 5: Trigger Test Event
CLUSTER_ID="test-cluster-001" # Use actual cluster ID from Step 3
gcloud pubsub topics publish cluster-events --message="$(jq -n \
--arg id "$CLUSTER_ID" \
--arg source "/api/hyperfleet/v1/clusters/$CLUSTER_ID" \
--arg time "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{
specversion: "1.0",
id: $id,
type: "io.hyperfleet.cluster.updated",
source: $source,
time: $time,
datacontenttype: "application/json",
data: {
id: $id,
kind: "Cluster",
href: ("/api/hyperfleet/v1/clusters/" + $id),
generation: 1
}
}')"Step 6: Verify in 4 Places
1. Adapter Logs
Look for:
Event execution finished: event_execution_status=success2. HyperFleet API Status
curl -s http://host.docker.internal:8000/api/hyperfleet/v1/clusters/$CLUSTER_ID/statuses | \
jq '.items | map(select(.adapter=="your-adapter-name")) | .[0] | {
conditions: .conditions | map({type, status, reason})
}'Expected: All three conditions (Applied, Available, Health) present with appropriate statuses.
3. Maestro Resources (if using Maestro)
curl -s http://host.docker.internal:8100/api/maestro/v1/resources | \
jq '.items[] | select(.manifest.metadata.name | contains("'$CLUSTER_ID'"))'Expected: ManifestWork created with correct manifests.
4. Target Cluster Resources (if accessible)
# For Kubernetes direct transport
kubectl get ns $CLUSTER_ID
kubectl get all -n $CLUSTER_ID
# For Maestro transport (on spoke cluster)
kubectl --context spoke-cluster get ns $CLUSTER_ID
kubectl --context spoke-cluster get all -n $CLUSTER_IDExpected: Resources actually exist on target cluster.
Step 7: Test Edge Cases
Trigger multiple events to test idempotency and updates:
# Same generation - should skip
# (increment generation in event data to trigger update)
# Different generation - should update
# (change generation to 2, then trigger again)Common Issues During Live Testing
| Symptom | Likely Cause | Quick Fix |
|---|---|---|
| Adapter doesn't start | Maestro config missing/wrong | Check "Common Errors" table above |
| Event not received | Subscription misconfigured | Verify with gcloud pubsub subscriptions describe |
| 404 from HyperFleet API | Cluster doesn't exist | Create cluster first (see Step 3) |
| ManifestWork not created | Consumer doesn't exist | List consumers, update placementClusterName |
| Resources not applied | Maestro agent down | Check agent pods on target cluster |
Success Criteria
Your adapter is working correctly when:
- ✅ Dry-run test passes
- ✅ Adapter starts without errors
- ✅ Events are received and processed (check logs)
- ✅ Status is reported to HyperFleet API
- ✅ Resources exist in Maestro (if using Maestro)
- ✅ Resources exist on target cluster
- ✅ Re-running same event (same generation) skips resources (idempotent)
- ✅ Running with new generation updates resources