This MVP demonstrates the Pull Secret Job that stores OpenShift cluster pull secrets in Google Cloud Platform (GCP) Secret Manager.
- Overview
- Prerequisites
- Building the Project
- Building Container Images
- Running the Job
- Environment Variables
- Code Architecture
- GCP Secret Manager Integration
- Verification
- Troubleshooting
The Pull Secret Job is part of the HyperFleet Pull Secret Adapter (HYPERFLEET-162). It securely stores image pull secrets for OpenShift clusters in the Red Hat GCP Secret Manager using Workload Identity authentication.
Key Features:
- ✅ Stores pull secrets in Red Hat GCP Secret Manager
- ✅ Authenticates using Workload Identity (in Kubernetes) or ADC (local development)
- ✅ Structured JSON logging with operation metrics
- ✅ Retry logic with exponential backoff and jitter
- ✅ Idempotent operations (safe to re-run)
- ✅ Pull secret validation (Dockercfg JSON format)
- ✅ Labels for tracking (managed-by, cluster-id, adapter, etc.)
Install the Google Cloud SDK:
# Verify installation
gcloud --versionIMPORTANT: You must authenticate with Google Cloud before running the job locally.
# Authenticate with your Google account
gcloud auth application-default login
# (Optional) Set quota project
gcloud auth application-default set-quota-project YOUR_PROJECT_IDThis creates Application Default Credentials (ADC) that the Secret Manager client uses for authentication.
Ensure your GCP project has:
- Secret Manager API enabled
- Appropriate IAM permissions for your account
Check API status:
gcloud services list --enabled --project=YOUR_PROJECT_ID | grep secretmanagerEnable Secret Manager API (requires serviceusage.serviceUsageAdmin role):
gcloud services enable secretmanager.googleapis.com --project=YOUR_PROJECT_IDRequired IAM permissions:
secretmanager.secrets.create- Create secret resourcesecretmanager.secrets.get- Check if secret existssecretmanager.versions.add- Add new secret versionsecretmanager.versions.access- Verify secret data
You need at least the roles/secretmanager.admin role on the project.
- Go 1.23.9 or later
- Dependencies will be downloaded automatically via
go mod
cd /path/to/mvp
go mod download# Using make (recommended)
make binary
# Or using go directly
go build -o pull-secret ./cmd/pull-secretThis creates the pull-secret executable in the current directory.
- Podman or Docker installed
- Quay.io account (or other container registry)
# Login to quay.io
podman login quay.io
# Username: your-username
# Password: your-password-or-token# Build with automatic git version tag
make image
# Build with custom registry
make image IMAGE_REGISTRY=quay.io/your-username
# Build with specific tag
make image IMAGE_TAG=v1.0.0
# Build for personal development (uses QUAY_USER)
make image-dev QUAY_USER=your-username# Build with specific tag
podman build -t quay.io/your-username/pull-secret:v1.0.0 -f Dockerfile .
# Build with latest tag
podman build -t quay.io/your-username/pull-secret:latest -f Dockerfile .
# Build with multiple tags
podman build \
-t quay.io/your-username/pull-secret:latest \
-t quay.io/your-username/pull-secret:v1.0.0 \
-f Dockerfile .# Build and push with git version
make image-push
# Build and push with specific tag
make image-push IMAGE_TAG=v1.0.0
# Build and push to personal Quay (development workflow)
make image-dev QUAY_USER=your-username# Push specific tag
podman push quay.io/your-username/pull-secret:v1.0.0
# Push latest
podman push quay.io/your-username/pull-secret:latest
# Push all local tags
podman push --all-tags quay.io/your-username/pull-secret# Build and push to your personal Quay.io account
# Tag will be automatically generated as dev-<git-commit>
make image-dev QUAY_USER=your-username
# Example output:
# Building dev image quay.io/your-username/pull-secret:dev-f1bf914...
# Pushing dev image quay.io/your-username/pull-secret:dev-f1bf914...# Build and push versioned release to official registry
make image-push IMAGE_TAG=v1.0.0
# Example output:
# Building image quay.io/openshift-hyperfleet/pull-secret:v1.0.0...
# Pushing image quay.io/openshift-hyperfleet/pull-secret:v1.0.0...# Build with multiple tags
podman build \
-t quay.io/your-username/pull-secret:latest \
-t quay.io/your-username/pull-secret:v1.0.0 \
-f Dockerfile .
# Push both tags
podman push quay.io/your-username/pull-secret:latest
podman push quay.io/your-username/pull-secret:v1.0.0# List local images
podman images | grep pull-secret
# Inspect image
podman inspect quay.io/your-username/pull-secret:latest
# Pull from registry (verify it's available)
podman pull quay.io/your-username/pull-secret:latest
# Run the container
podman run --rm \
-e GCP_PROJECT_ID="your-project-id" \
-e CLUSTER_ID="cls-test-123" \
-e SECRET_NAME="hyperfleet-cls-test-123-pull-secret" \
-e PULL_SECRET_DATA='{"auths":{"registry.redhat.io":{"auth":"dGVzdDp0ZXN0","email":"test@example.com"}}}' \
quay.io/your-username/pull-secret:latestYou can customize the build using these environment variables:
| Variable | Default | Description |
|---|---|---|
VERSION |
git describe | Version tag from git |
COMMIT |
git rev-parse | Short commit hash |
CONTAINER_TOOL |
podman/docker | Container build tool (auto-detected) |
IMAGE_REGISTRY |
quay.io/openshift-hyperfleet |
Container registry |
IMAGE_NAME |
pull-secret |
Image name |
IMAGE_TAG |
$(VERSION) |
Image tag (defaults to git version) |
QUAY_USER |
(empty) | Personal Quay username for dev builds |
DEV_TAG |
dev-$(COMMIT) |
Dev image tag |
Examples:
# Use defaults (quay.io/openshift-hyperfleet/pull-secret:<git-version>)
make image
# Build with custom tag
make image IMAGE_TAG=v1.0.0
# Build with custom registry
make image IMAGE_REGISTRY=quay.io/myorg
# Personal development build
make image-dev QUAY_USER=ldornele
# Results in: quay.io/ldornele/pull-secret:dev-f1bf914The Dockerfile uses a multi-stage build for optimization:
-
Stage 1 (Builder): Uses
registry.access.redhat.com/ubi9/go-toolset:1.23- Downloads dependencies
- Builds static binary with
CGO_ENABLED=0
-
Stage 2 (Runtime): Uses
registry.access.redhat.com/ubi9/ubi-minimal:latest- Minimal attack surface
- Runs as non-root user (UID 1000)
- Only contains binary + CA certificates
Image size: ~150 MB (compared to ~800+ MB with full Go image)
GCP_PROJECT_ID="your-project-id" \
CLUSTER_ID="cls-test-123" \
SECRET_NAME="hyperfleet-cls-test-123-pull-secret" \
PULL_SECRET_DATA='{"auths":{"registry.redhat.io":{"auth":"dGVzdDp0ZXN0","email":"test@example.com"}}}' \
./pull-secret run-job pull-secretGCP_PROJECT_ID="redhat-prod-12345" \
CLUSTER_ID="cls-abc123" \
SECRET_NAME="hyperfleet-cls-abc123-pull-secret" \
PULL_SECRET_DATA='{"auths":{"registry.redhat.io":{"auth":"base64-encoded-credentials","email":"user@redhat.com"},"quay.io":{"auth":"base64-encoded-credentials","email":"user@redhat.com"}}}' \
./pull-secret run-job pull-secret{"cluster_id":"cls-test-123","gcp_project":"your-project-id","level":"info","message":"Starting pull secret storage operation","operation":"start","timestamp":"2025-12-08T13:07:31Z"}
{"cluster_id":"cls-test-123","gcp_project":"your-project-id","level":"info","message":"Successfully initialized Secret Manager client","operation":"client-initialized","timestamp":"2025-12-08T13:07:31Z"}
{"cluster_id":"cls-test-123","gcp_project":"your-project-id","level":"info","message":"Creating new secret: hyperfleet-cls-test-123-pull-secret","operation":"create-secret","timestamp":"2025-12-08T13:07:32Z"}
{"cluster_id":"cls-test-123","duration_ms":2441,"gcp_project":"your-project-id","level":"info","message":"Successfully created secret","operation":"create-secret","timestamp":"2025-12-08T13:07:34Z"}
{"cluster_id":"cls-test-123","gcp_project":"your-project-id","level":"info","message":"Adding secret version with pull secret data","operation":"add-secret-version","timestamp":"2025-12-08T13:07:34Z"}
{"cluster_id":"cls-test-123","duration_ms":2077,"gcp_project":"your-project-id","level":"info","message":"Successfully created secret version","operation":"add-secret-version","timestamp":"2025-12-08T13:07:36Z","version":"projects/123456/secrets/hyperfleet-cls-test-123-pull-secret/versions/1"}
{"cluster_id":"cls-test-123","duration_ms":379,"gcp_project":"your-project-id","level":"info","message":"Verified secret (83 bytes)","operation":"verify-secret","timestamp":"2025-12-08T13:07:36Z"}
{"cluster_id":"cls-test-123","gcp_project":"your-project-id","level":"info","message":"Successfully created/updated pull secret","operation":"completed","timestamp":"2025-12-08T13:07:36Z"}| Variable | Required | Description | Example |
|---|---|---|---|
GCP_PROJECT_ID |
Yes | GCP Project ID where secret will be stored | redhat-prod-12345 |
CLUSTER_ID |
Yes | Unique identifier for the OpenShift cluster | cls-abc123 |
SECRET_NAME |
No* | Name of the secret in GCP Secret Manager | hyperfleet-cls-abc123-pull-secret |
PULL_SECRET_DATA |
No** | Pull secret in Dockercfg JSON format | {"auths":{...}} |
Notes:
- *If
SECRET_NAMEis not provided, it will be auto-generated as:hyperfleet-{CLUSTER_ID}-pull-secret - **If
PULL_SECRET_DATAis not provided, a fake pull secret will be used (for testing only)
The pull secret must be in Dockercfg JSON format:
{
"auths": {
"registry.redhat.io": {
"auth": "base64-encoded-username:password",
"email": "user@example.com"
},
"quay.io": {
"auth": "base64-encoded-username:password",
"email": "user@example.com"
}
}
}mvp/
├── cmd/
│ └── pull-secret/
│ ├── main.go # Entry point
│ └── jobs/
│ └── pull_secret.go # Pull secret job implementation
├── pkg/
│ └── job/ # Job framework
├── go.mod # Go module dependencies
├── go.sum # Dependency checksums
└── README.md # This file
The main job struct that implements the job framework interface:
type PullSecretJob struct {}
func (pullsecretJob *PullSecretJob) GetTasks() ([]job.Task, error)
func (pullsecretJob *PullSecretJob) GetMetadata() job.Metadata
func (pullsecretJob *PullSecretJob) AddFlags(flags *pflag.FlagSet)
func (pullsecretJob *PullSecretJob) GetWorkerCount() int- GetTasks(): Reads environment variables and creates PullSecretTask instances
- GetMetadata(): Returns job metadata (name, description)
- GetWorkerCount(): Returns number of parallel workers (1 for this job)
The task struct that contains the actual secret data and performs the work:
type PullSecretTask struct {
PullSecret string // Pull secret JSON data
GCPProjectID string // GCP project ID
ClusterID string // Cluster identifier
SecretName string // Secret name in GCP
}
func (e PullSecretTask) Process(ctx context.Context) errorThe Process() method executes the following workflow:
- Validate configuration - Ensures all required env vars are present
- Validate pull secret format - Verifies JSON structure
- Initialize GCP Secret Manager client - Creates authenticated client
- Create or update secret - Stores the pull secret in GCP
- Verify secret accessibility - Confirms secret can be read back
Configuration & Validation:
func (e PullSecretTask) validateConfig() error
func validatePullSecret(pullSecretJSON string) errorGCP Secret Manager Operations:
func (e PullSecretTask) secretExists(ctx context.Context, client *secretmanager.Client) (bool, error)
func (e PullSecretTask) createSecret(ctx context.Context, client *secretmanager.Client) error
func (e PullSecretTask) addSecretVersion(ctx context.Context, client *secretmanager.Client) (string, error)
func (e PullSecretTask) verifySecret(ctx context.Context, client *secretmanager.Client) errorError Handling & Retry:
func retryWithBackoff(ctx context.Context, fn func() error, maxRetries int) error
func isRetryable(err error) boolLogging:
func logStructured(level, clusterID, gcpProject, operation string, durationMs int64, message, version string)The job uses the official GCP Secret Manager Go SDK:
import (
secretmanager "cloud.google.com/go/secretmanager/apiv1"
"cloud.google.com/go/secretmanager/apiv1/secretmanagerpb"
)req := &secretmanagerpb.GetSecretRequest{
Name: "projects/{project}/secrets/{secret}",
}
secret, err := client.GetSecret(ctx, req)Returns codes.NotFound if secret doesn't exist.
req := &secretmanagerpb.CreateSecretRequest{
Parent: "projects/{project}",
SecretId: secretName,
Secret: &secretmanagerpb.Secret{
Replication: &secretmanagerpb.Replication{
Replication: &secretmanagerpb.Replication_Automatic_{
Automatic: &secretmanagerpb.Replication_Automatic{},
},
},
Labels: map[string]string{
"managed-by": "hyperfleet",
"adapter": "pullsecret",
"cluster-id": clusterID,
"resource-type": "pull-secret",
"hyperfleet-version": "v1",
},
},
}
secret, err := client.CreateSecret(ctx, req)Labels applied:
managed-by: hyperfleet- Identifies HyperFleet-managed secretsadapter: pullsecret- Identifies the adapter typecluster-id: {cluster-id}- Links to specific clusterresource-type: pull-secret- Resource classificationhyperfleet-version: v1- Schema version
req := &secretmanagerpb.AddSecretVersionRequest{
Parent: "projects/{project}/secrets/{secret}",
Payload: &secretmanagerpb.SecretPayload{
Data: []byte(pullSecretJSON),
},
}
version, err := client.AddSecretVersion(ctx, req)Creates a new immutable version containing the pull secret data.
req := &secretmanagerpb.AccessSecretVersionRequest{
Name: "projects/{project}/secrets/{secret}/versions/latest",
}
result, err := client.AccessSecretVersion(ctx, req)
data := result.Payload.Data // The actual secret dataVerifies the secret can be read back and returns the payload.
Local Development:
User runs: gcloud auth application-default login
↓
ADC credentials stored at: ~/.config/gcloud/application_default_credentials.json
↓
secretmanager.NewClient(ctx) automatically uses ADC
↓
API calls authenticated as user account
Production (Kubernetes with Workload Identity):
Job Pod with K8s Service Account: pullsecret-adapter-job
↓
Workload Identity binding to GCP Service Account
↓
secretmanager.NewClient(ctx) automatically uses Workload Identity
↓
API calls authenticated as GCP Service Account
↓
Appears in Cloud Audit Logs with GCP SA email
Retry Strategy:
- Max retries: 3 attempts
- Backoff: Exponential with jitter (1s, 2s, 4s)
- Jitter: ±20% to avoid thundering herd
Retryable Errors:
codes.Unavailable- Service temporarily unavailablecodes.DeadlineExceeded- Request timeoutcodes.Internal- Internal server errorcodes.ResourceExhausted- Rate limit exceeded (429)
Non-Retryable Errors (fail immediately):
codes.PermissionDenied- Missing IAM permissionscodes.InvalidArgument- Invalid request parameterscodes.NotFound- Resource not foundcodes.AlreadyExists- Resource already exists
All operations are logged in structured JSON format:
{
"timestamp": "2025-12-08T13:07:31Z",
"level": "info",
"cluster_id": "cls-test-123",
"gcp_project": "redhat-prod-12345",
"operation": "create-secret",
"duration_ms": 2441,
"message": "Successfully created secret",
"version": "projects/123/secrets/hyperfleet-cls-test-123-pull-secret/versions/1"
}Log Fields:
timestamp- ISO 8601 UTC timestamplevel- Log level (info, error)cluster_id- Cluster identifiergcp_project- GCP project IDoperation- Operation name (start, create-secret, add-secret-version, etc.)duration_ms- Operation duration in milliseconds (optional)message- Human-readable messageversion- Secret version (optional)
Security Note: Pull secret data is NEVER logged to prevent credential exposure.
- Go to: https://console.cloud.google.com/security/secret-manager?project=YOUR_PROJECT_ID
- Find secret:
hyperfleet-cls-test-123-pull-secret - Click on the secret to view metadata and labels
- Click VERSIONS tab to see version 1
- Click ⋮ (three dots) → View secret value to see the pull secret data
# List secrets with HyperFleet labels
gcloud secrets list \
--project=YOUR_PROJECT_ID \
--filter="labels.managed-by=hyperfleet"
# Describe the secret (metadata only)
gcloud secrets describe hyperfleet-cls-test-123-pull-secret \
--project=YOUR_PROJECT_ID
# View labels
gcloud secrets describe hyperfleet-cls-test-123-pull-secret \
--project=YOUR_PROJECT_ID \
--format="table(labels)"
# List versions
gcloud secrets versions list hyperfleet-cls-test-123-pull-secret \
--project=YOUR_PROJECT_ID
# Access secret data (requires secretmanager.versions.access permission)
gcloud secrets versions access latest \
--secret=hyperfleet-cls-test-123-pull-secret \
--project=YOUR_PROJECT_IDRun the job multiple times with the same parameters:
# First run - creates secret and version 1
./pull-secret run-job pull-secret
# Second run - secret exists, creates version 2
./pull-secret run-job pull-secret
# Third run - secret exists, creates version 3
./pull-secret run-job pull-secretEach run should succeed and create a new version.
Problem: ADC not configured
Solution:
gcloud auth application-default loginProblem: Missing IAM permissions or API not enabled
Solutions:
- Check if Secret Manager API is enabled:
gcloud services list --enabled --project=YOUR_PROJECT_ID | grep secretmanager- Check your IAM roles:
gcloud projects get-iam-policy YOUR_PROJECT_ID \
--flatten="bindings[].members" \
--filter="bindings.members:user:YOUR_EMAIL" \
--format="table(bindings.role)"- Request
roles/secretmanager.adminfrom project administrator
Problem: Secret Manager API not enabled
Solution:
gcloud services enable secretmanager.googleapis.com --project=YOUR_PROJECT_IDRequires roles/serviceusage.serviceUsageAdmin role.
Problem: Required env vars not set
Solution: Ensure all required variables are exported:
export GCP_PROJECT_ID="your-project-id"
export CLUSTER_ID="cls-test-123"
# SECRET_NAME is optional (auto-generated)
# PULL_SECRET_DATA is optional (uses fake data for testing)Problem: PULL_SECRET_DATA is not valid Dockercfg JSON
Solution: Ensure JSON has required structure:
{"auths":{"registry.redhat.io":{"auth":"...","email":"..."}}}Must have:
- Top-level
authskey - At least one registry entry
- Each registry has
authfield (base64-encoded credentials)
If you encounter build errors, ensure dependencies are up to date:
go mod tidy
go mod download
go build ./cmd/pull-secretBased on actual test runs:
| Operation | Duration | Notes |
|---|---|---|
| Client initialization | < 1s | Workload Identity token exchange |
| Secret creation | ~2.5s | First-time secret creation |
| Add secret version | ~2s | Adding new version with data |
| Verify secret | ~400ms | Reading back secret data |
| Total (first run) | ~5s | Complete workflow |
| Total (subsequent) | ~3s | Secret already exists |
Resource Usage:
- Memory: < 50 MB
- CPU: < 100m (0.1 cores)
In production, this job runs as a Kubernetes Job in the management cluster:
- Adapter creates Kubernetes Job with proper environment variables
- Job Pod runs with
pullsecret-adapter-jobservice account - Workload Identity automatically authenticates to GCP
- Job executes and stores pull secret in RH's GCP project
- Logs are collected and forwarded to observability platform
- Job completes and is cleaned up after retention period
Security Context (Production):
securityContext:
runAsNonRoot: true
runAsUser: 1000
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop:
- ALL- Epic: HYPERFLEET-162 - Pull Secret Adapter
- GCP Secret Manager API: https://cloud.google.com/secret-manager/docs
- Go Client Library: https://pkg.go.dev/cloud.google.com/go/secretmanager/apiv1
- Workload Identity: https://cloud.google.com/kubernetes-engine/docs/how-to/workload-identity
- Architecture Documentation:
/architecture/hyperfleet/components/adapter/PullSecret/GCP/
Copyright © 2025 Red Hat, Inc.