Appendix Z: Running on OpenShift Local (CRC)

The Appendix T pipeline on OpenShift Local: Kafka from the AMQ Streams operator with pinned, manually approved versions, images built inside the cluster with binary S2I, a Helm chart that runs under restricted-v2 behind edge-TLS Routes, and a teardown that leaves the cluster empty.

60 minutesChapter 44appendices

Appendix T deployed the order pipeline (REST in, Kafka, a Redis-backed Content Enricher, Kafka out) to minikube. This appendix runs the same Camel Quarkus application on OpenShift Local (CRC), a single-node OpenShift 4 cluster in a VM on your workstation. The application code does not change. What changes is everything around it: how Kafka is installed, where the image is built, which security policy the pods run under, and how traffic gets in.

The scripts and the Helm chart are in openshift/. The application is examples/38-kubernetes-deploy/quarkus/.

OpenShift Local project eip: a binary S2I build produces the eip-k8s-deploy image, an edge-TLS Route fronts the Camel application, which talks to AMQ Streams Kafka and Redis; KEDA and OpenTelemetry are opt-in
Figure Z.1: The Appendix T pipeline on OpenShift Local: Maven packages on the host, the cluster builds and stores the image, a Route terminates TLS, and the operator-managed Kafka and Redis run in the same project.

What changes from minikube

Concern minikube (Appendix T) OpenShift Local (this appendix)
Kafka Strimzi Helm chart, Kafka 4.3.1 AMQ Streams operator from OperatorHub, pinned CSV, Kafka 4.2.0
Image build docker build on the host, minikube image load Binary S2I build in the cluster; no host container engine
Image source containerd in the node, imagePullPolicy: Never The internal registry (ImageStream eip-k8s-deploy:v1)
Pod security Kubernetes defaults restricted-v2 SCC: random UID, no privilege escalation, all capabilities dropped
Host access NodePort published on 127.0.0.1 Route with edge TLS on *.apps-crc.testing
Manifests Generated by quarkus-kubernetes Helm chart openshift/helm/eip
Clean-up setup-minikube-stack.sh --clean teardown.sh: operators, CRDs, namespaces, then crc stop

The env contract stays identical (KAFKA_BROKERS and QUARKUS_REDIS_HOSTS override application.properties), so the same jar runs on both clusters.

Prerequisites

Fedora or RHEL, on bare metal or in a VM with nested virtualisation. CRC runs the cluster in a libvirt VM, so the host needs KVM; crc setup checks and configures it.

Tool Why
crc (OpenShift Local) The cluster. Needs a pull secret from a free Red Hat Developer account
oc Every script. Ships with crc: eval "$(crc oc-env)"
helm 3.18+ or 4 The chart
jq InstallPlan selection, evidence
JDK 25, Maven 3.9 Packaging the application on the host
skopeo Re-checking image tags (optional)

No container engine is involved: the image is built inside the cluster.

One local cluster at a time. Stop any minikube profile first (minikube stop -p eip); the scripts refuse to run while one is up. Two clusters on one host compete for memory and fail in ways that look unrelated.

The CRC is dedicated to this project while it runs, and is cleaned after every use. Before a run, check that nothing else is installed:

oc get sub,csv -A
oc get ns --no-headers -o name | grep -v -E '^namespace/(openshift|kube-|default$|hostpath-provisioner$)'

If another project’s namespaces or operators are there, run that project’s own teardown first. install-infra.sh refuses to share the AMQ Streams Subscription with another project.

Sizing and first start

Sizing only takes effect while CRC is stopped:

crc config set cpus 6           # 8 with the platform tier
crc config set memory 20480     # 24576 with the platform tier
crc config set disk-size 80
crc setup
crc start --pull-secret-file ~/pull-secret.txt
eval "$(crc oc-env)"

crc setup also makes api.crc.testing and *.apps-crc.testing resolve on the host, which is what makes Routes reachable without a tunnel.

Credentials

crc start writes a crc-admin context into your kubeconfig, and every script uses it: require_crc in openshift/lib.sh switches to that context and refuses any API server other than api.crc.testing. Nothing needs the kubeadmin password. If you want the web console, run crc console --credentials yourself; the scripts never print it, and the evidence directory is scrubbed for secrets before it is kept.

Versions

Everything is pinned to an exact version. Operators install with installPlanApproval: Manual and a startingCSV, and the scripts approve only the InstallPlan naming that CSV, so an operator never moves without an edit.

Component Pin
AMQ Streams operator amqstreams.v3.2.1-14, channel amq-streams-3.2.x
Kafka (in the Kafka CR) 4.2.0, metadataVersion: 4.2-IV1 (AMQ Streams 3.2 ships 4.1 and 4.2)
Custom Metrics Autoscaler (opt-in) custom-metrics-autoscaler.v2.19.0-4, channel stable
Red Hat build of OpenTelemetry (opt-in) opentelemetry-operator.v0.158.0-2, channel stable
S2I builder registry.access.redhat.com/ubi10/openjdk-25:1.24-15
Redis docker.io/library/redis:8.10.2-alpine
otel-lgtm (opt-in) docker.io/grafana/otel-lgtm:0.36.0
Quarkus / Camel 3.40.1 / 4.22.1

These were the newest stable pins on 2026-10-09. Operator CSVs are defined by the cluster’s catalog, so they can only be confirmed on the cluster. Re-check before each run and update the Subscription YAML and the *_CSV variable in the script together:

for pkg in amq-streams openshift-custom-metrics-autoscaler-operator opentelemetry-product; do
  echo "$pkg"
  oc get packagemanifest "$pkg" -n openshift-marketplace \
    -o jsonpath='{range .status.channels[*]}{"  "}{.name}{"  "}{.currentCSV}{"\n"}{end}'
done

Step 1: project and Kafka

./openshift/install-infra.sh

The script creates the eip project, installs AMQ Streams into openshift-operators from openshift/infra/amq-streams-subscription.yaml, approves the one InstallPlan for the pinned CSV, waits for it to report Succeeded, and applies the Kafka cluster:

apiVersion: kafka.strimzi.io/v1
kind: KafkaNodePool
metadata:
  name: dual-role
  labels:
    strimzi.io/cluster: eip
spec:
  replicas: 1
  roles: [controller, broker]
  storage:
    type: jbod
    volumes:
      - {id: 0, type: persistent-claim, size: 5Gi, deleteClaim: true}
---
apiVersion: kafka.strimzi.io/v1
kind: Kafka
metadata:
  name: eip
spec:
  kafka:
    version: 4.2.0
    metadataVersion: 4.2-IV1
    listeners:
      - {name: plain, port: 9092, type: internal, tls: false}
    config:
      auto.create.topics.enable: true
      num.partitions: 3
      offsets.topic.replication.factor: 1
      transaction.state.log.replication.factor: 1
      transaction.state.log.min.isr: 1
      default.replication.factor: 1
      min.insync.replicas: 1

AMQ Streams is Red Hat’s build of Strimzi, so this is the same v1 API as Appendix T, with two differences: the Kafka version is one the installed operator ships, and topics auto-create with three partitions instead of being declared as KafkaTopic resources. With no Topic Operator there are no topic finalizers for the teardown to wait on.

Two waits in that script are deliberate. Right after crc start, the redhat-operators catalog reports ready about a minute before it serves any packages, so the script waits for oc get packagemanifest amq-streams rather than for the CatalogSource. And it waits for the Kafka CR’s Ready condition, not a fixed sleep.

Step 2: build the image inside the cluster

./openshift/build-images.sh

Maven packages the application on the host; the cluster builds the image. The Quarkus pom carries a profile that is inactive by default, so mvn verify and the minikube image are unaffected:

<profile>
    <id>openshift</id>
    <dependencies>
        <dependency>
            <groupId>io.quarkus</groupId>
            <artifactId>quarkus-openshift</artifactId>
        </dependency>
    </dependencies>
</profile>

The script runs:

mvn -B -q -f examples/38-kubernetes-deploy/quarkus/pom.xml package -DskipTests -Popenshift \
    -Dquarkus.container-image.build=true \
    -Dquarkus.container-image.group=eip \
    -Dquarkus.container-image.tag=v1 \
    -Dquarkus.openshift.version=v1 \
    -Dquarkus.openshift.base-jvm-image=registry.access.redhat.com/ubi10/openjdk-25:1.24-15 \
    -Dquarkus.openshift.namespace=eip \
    -Dquarkus.kubernetes.deploy=false

The extension creates a BuildConfig (Source strategy, binary input) and two ImageStreams, uploads the fast-jar from target/quarkus-app, and the cluster runs the builder image’s S2I assemble script and pushes eip-k8s-deploy:v1 to the internal registry. Five settings carry the weight:

  • quarkus-openshift, not quarkus-container-image-openshift alone. The BuildConfig comes from the OpenShift manifest generator. With only the container-image extension, Maven succeeds in seconds and logs that no OpenShift manifests were generated, so no build runs.
  • quarkus.openshift.version sets the output tag. With only quarkus.container-image.tag, the ImageStream tag stays at the project version, 1.0.0-SNAPSHOT.
  • quarkus.openshift.namespace picks the project. Without it the build runs in the kube context’s current namespace, and crc start resets that to default: Maven succeeds, the log says in namespace:default, and eip has no eip-k8s-deploy:v1. quarkus.kubernetes-client.namespace and the KUBERNETES_NAMESPACE variable do not change this on Quarkus 3.40.
  • quarkus.kubernetes.deploy=false. Build and push only; the Helm chart owns the Deployment.
  • The builder image. The extension defaults to ubi9/openjdk-25; the script pins ubi10/openjdk-25 to an exact tag that carries the S2I labels.

The S2I image starts the application with its own run-java.sh, not with the Containerfile from Appendix T. Heap sizing therefore comes from JAVA_MAX_MEM_RATIO, which the chart sets to 50 percent of the container limit.

Step 3: deploy the chart

./openshift/deploy.sh

deploy.sh runs helm upgrade --install eip openshift/helm/eip against the crc-admin context and waits on oc rollout status for every Deployment, not on condition=Available, which the old ReplicaSet still satisfies during an upgrade. The chart renders:

Resource Purpose
ConfigMap eip-app-config KAFKA_BROKERS=eip-kafka-bootstrap:9092, QUARKUS_REDIS_HOSTS=redis://redis:6379, JAVA_MAX_MEM_RATIO=50
Deployment + Service eip-k8s-deploy The Camel application on port 8088, probes on /q/health/{started,ready,live}
Route eip-k8s-deploy Edge TLS, plain HTTP redirected, https://eip-k8s-deploy-eip.apps-crc.testing
Deployment + Service redis The customer cache

Every container runs under the restricted-v2 SCC with the same security context and nothing more:

securityContext:
  runAsNonRoot: true
  allowPrivilegeEscalation: false
  capabilities:
    drop: ["ALL"]
  seccompProfile:
    type: RuntimeDefault

The chart sets no runAsUser. OpenShift assigns each project a UID range and gives every pod a UID from it; a fixed UID such as 185 or 1001 falls outside the range and the pod is rejected. Both images here cope: the UBI OpenJDK image is built for an arbitrary UID, and the official Redis image only drops privileges when it starts as root, writing nothing outside its /data emptyDir.

The pod template also carries a checksum of the ConfigMap, because environment variables from a ConfigMap are read only at pod start: change a value and the next deploy.sh rolls the pods.

Send an order through the Route. Use the cluster’s ingress CA rather than --insecure:

oc get configmap default-ingress-cert -n openshift-config-managed \
  -o jsonpath='{.data.ca-bundle\.crt}' > /tmp/crc-ingress-ca.crt

curl --cacert /tmp/crc-ingress-ca.crt -X POST \
  "https://$(oc get route eip-k8s-deploy -n eip -o jsonpath='{.spec.host}')/api/orders" \
  -H "Content-Type: application/json" \
  -d '{"orderId":"ORD-001","customerId":"C-101","item":"Shipping Container","quantity":2}'
# {"status":"accepted"}

oc logs -n eip deploy/eip-k8s-deploy | grep 'Enriched order published'

Step 4: capture evidence

./openshift/capture-evidence.sh

The script writes what it observed to openshift/evidence/<date>/ and fails on the first miss:

  1. Cluster, operator CSVs, the Kafka version and its Ready condition, the builds, and one ImageStream tag per service.
  2. Every pod Ready under restricted-v2 with a UID from the namespace range.
  3. Every Route answering 200 on its health path over edge TLS, checked with the ingress CA.
  4. The pipeline end to end (openshift/project-checks.sh): the cache loader has seeded Redis, an order posted through the Route is accepted, the processor logs it enriched with “Global Freight Ltd”, and the record is on eip.orders.enriched, read back from the broker pod with oc exec.
  5. The platform tier, for whatever is installed.
  6. A secret scrub: no token, private key, pull secret or kubeadmin password in the evidence. A capture that fails the scrub is not kept.

Internal Services are reached with oc exec into a pod, by Service name; never with oc port-forward or a tunnel.

Optional: the platform tier

Two additions mirror what the tutorial teaches elsewhere, and nothing more. Both are off until you install them:

./openshift/platform/install-platform.sh      # both, in order
./openshift/platform/install-keda.sh          # or one at a time
./openshift/platform/install-observability.sh

Kafka-lag autoscaling: the Competing Consumers pattern from Chapter 14 with an autoscaler deciding the consumer count. The Custom Metrics Autoscaler (Red Hat’s build of KEDA) watches the lag of consumer group k8s-order-processor on eip.orders.incoming and scales eip-k8s-deploy between one and three replicas: three because the topic has three partitions, and a consumer group never uses more consumers than partitions. The minimum stays at one because the same pod serves the REST API. The trigger uses the bootstrap Service’s fully qualified name: KEDA runs in openshift-keda, where the short name does not resolve.

Tracing: the signals from Appendix I without touching the pom. The Red Hat build of OpenTelemetry injects the OpenTelemetry Java agent into the annotated JVM pod, which sends traces to grafana/otel-lgtm in the same project; Grafana gets its own edge-TLS Route. otel-lgtm is the one pod outside restricted-v2: it owns its data directories as UID 0, so it runs under anyuid through its own ServiceAccount, with runAsUser: 0 and nothing else in its security context. The image’s built-in collector configuration is used as shipped, health check on port 13133 included.

Service Mesh, GitOps, Ollama and native builds are out of scope here: no chapter of this tutorial teaches them, and each would add an operator to install, size and tear down for nothing the examples demonstrate.

Teardown

./openshift/teardown.sh                 # clean, then crc stop
./openshift/teardown.sh --keep-running  # clean, leave CRC running

The order matters because of finalizers: custom resources go while their operator still runs.

  1. The Helm release, then the Kafka CR and its node pool, waiting until the Kafka pods are gone.
  2. The eip project: builds, ImageStreams and the Kafka volume with it.
  3. The KedaController, if installed.
  4. Each operator: Subscription, CSV, InstallPlans, and the CRDs the CSV owns.
  5. Any remaining strimzi.io, keda.sh or opentelemetry.io CRDs.
  6. The operator namespaces (openshift-keda, openshift-opentelemetry-operator).

It finishes with a leftover check, then crc stop (stop, not delete), so the next start is quick.

Troubleshooting

Symptom Cause Fix
Maven finishes in seconds and no image appears Only quarkus-container-image-openshift on the classpath Build with -Popenshift (adds quarkus-openshift)
ImageStream tag is 1.0.0-SNAPSHOT, not v1 Output tag follows quarkus.openshift.version build-images.sh sets it
install_operator times out right after crc start The catalog serves no packages for about a minute The script waits for the packagemanifest; re-run it
Pod rejected: UID not in range A fixed runAsUser in a pod spec Never set one; the chart does not
ScaledObject Ready=False, “no such host” Short Kafka Service name from openshift-keda Fully qualified bootstrapServers (the chart does this)
The pod lacks the Java agent after installing tracing Deployment created before the Instrumentation CR install-observability.sh restarts annotated pods without the agent
Project stuck in Terminating A Strimzi finalizer with the operator already gone Run teardown.sh, which deletes Kafka resources first

What you learned

  • Operators with pinned versions: installPlanApproval: Manual, a startingCSV, and approving only the InstallPlan for that CSV keep an operator’s version an explicit decision.
  • Images built where they run: binary S2I with quarkus-openshift turns a host-side mvn package into an ImageStream tag with no host container engine and no exposed registry.
  • restricted-v2: a minimal security context and no fixed UID are all a well-built image needs.
  • Routes instead of NodePorts: edge TLS on *.apps-crc.testing, verified with the cluster’s ingress CA.
  • A cluster you can hand back: evidence with a secret scrub, and a teardown that removes every operator, CRD and namespace before crc stop.

Build, run, observe

./openshift/install-infra.sh
./openshift/build-images.sh && ./openshift/deploy.sh

The scripts provision AMQ Streams Kafka, build the Quarkus image in-cluster with S2I, and deploy the Helm chart behind an edge-TLS Route; capture-evidence.sh then records the order round-trip and the Redis enrichment under restricted-v2.

References