Sub-Module 2.2: Custom Multi-Language Strategies

Sub-Module Overview

Duration: ~12 minutes
Prerequisites: Completion of Sub-Module 2.1 (Building with Hummingbird)
Status: Optional exercise — not required for Module 2 completion
Learning Objectives:

  • Create ClusterBuildStrategy with language auto-detection

  • Configure conditional Hummingbird runtime selection (Node.js, Python, Java, Go)

  • Integrate with OpenShift internal registry

  • Configure registry mirrors for air-gapped environments

Introduction

In Sub-Module 2.1, you built a Quarkus application using the pre-installed buildah strategy and a multi-stage Containerfile. That approach works well when each repository includes its own Containerfile. But in production environments, platform teams often support multiple languages across different application teams. Requiring every team to write and maintain a Containerfile creates overhead and inconsistency.

In this sub-module, you’ll create a multi-language ClusterBuildStrategy that automatically detects the application language and generates the appropriate multi-stage Containerfile with the correct Hummingbird runtime, providing a single, consistent build interface for all development teams.

Create Multi-Language BuildStrategy

Step 1: Create ClusterBuildStrategy with Auto-Detection

This strategy uses Buildah directly with conditional logic for different languages:

cat << 'EOF' | oc apply -f -
apiVersion: shipwright.io/v1beta1
kind: ClusterBuildStrategy
metadata:
  name: hummingbird-multi-lang
spec:
  parameters:
  - name: DOCKERFILE
    description: Path to Containerfile/Dockerfile
    default: "Containerfile"
  - name: LANGUAGE
    description: Programming language (nodejs, python, java, go) - auto-detected if not specified
    default: "auto"

  securityContext:
    runAsUser: 0
    runAsGroup: 0

  steps:
  - name: detect-and-generate
    image: registry.access.redhat.com/ubi9/ubi-minimal:latest
    workingDir: $(params.shp-source-context)
    command:
    - /bin/bash
    args:
    - -c
    - |
      set -e

      # Auto-detect language from project files
      DETECTED="$(params.LANGUAGE)"
      if [ "$DETECTED" = "auto" ]; then
        echo "Detecting application language..."
        if [ -f "package.json" ]; then
          DETECTED="nodejs"
        elif [ -f "requirements.txt" ] || [ -f "setup.py" ] || [ -f "Pipfile" ]; then
          DETECTED="python"
        elif [ -f "pom.xml" ] || [ -f "build.gradle" ]; then
          DETECTED="java"
        elif [ -f "go.mod" ]; then
          DETECTED="go"
        else
          echo "ERROR: Could not detect language. Set LANGUAGE parameter explicitly."
          exit 1
        fi
      fi
      echo "Language: $DETECTED"
      echo "$DETECTED" > /shared/detected-lang

      # Select builder and Hummingbird runtime images
      case $DETECTED in
        nodejs)
          BUILDER="registry.access.redhat.com/ubi9/nodejs-20:latest"
          RUNTIME="registry.access.redhat.com/hi/nodejs:20"
          ;;
        python)
          BUILDER="registry.access.redhat.com/ubi9/python-311:latest"
          RUNTIME="registry.access.redhat.com/hi/python:3.11"
          ;;
        java)
          BUILDER="registry.access.redhat.com/hi/openjdk:21-builder"
          RUNTIME="registry.access.redhat.com/hi/openjdk:21-runtime"
          ;;
        go)
          BUILDER="registry.access.redhat.com/ubi9/go-toolset:latest"
          RUNTIME="registry.access.redhat.com/hi/core-runtime:2"
          ;;
      esac

      # Generate multi-stage Containerfile if not present
      if [ ! -f "$(params.DOCKERFILE)" ]; then
        echo "No Containerfile found -- generating one for $DETECTED..."
        case $DETECTED in
          nodejs)
            cat > Containerfile <<CEOF
      FROM $BUILDER AS builder
      WORKDIR /build
      COPY package*.json ./
      RUN npm ci --only=production
      COPY . .

      FROM $RUNTIME
      WORKDIR /app
      COPY --from=builder /build ./
      USER 65532
      EXPOSE 8080
      CMD ["node", "server.js"]
      CEOF
            ;;
          python)
            cat > Containerfile <<CEOF
      FROM $BUILDER AS builder
      WORKDIR /build
      COPY requirements.txt ./
      RUN pip install --user --no-cache-dir -r requirements.txt
      COPY . .

      FROM $RUNTIME
      WORKDIR /app
      COPY --from=builder /build ./
      COPY --from=builder /root/.local /root/.local
      ENV PATH=/root/.local/bin:\$PATH
      USER 65532
      EXPOSE 8080
      CMD ["python", "app.py"]
      CEOF
            ;;
          java)
            cat > Containerfile <<CEOF
      FROM $BUILDER AS builder
      WORKDIR /build
      COPY . .
      RUN if [ -f "mvnw" ]; then chmod +x mvnw && ./mvnw package -DskipTests; else mvn package -DskipTests; fi

      FROM $RUNTIME
      WORKDIR /app
      COPY --from=builder /build/target/quarkus-app/lib/ ./lib/
      COPY --from=builder /build/target/quarkus-app/*.jar ./
      COPY --from=builder /build/target/quarkus-app/app/ ./app/
      COPY --from=builder /build/target/quarkus-app/quarkus/ ./quarkus/
      USER 65532
      EXPOSE 8080
      ENTRYPOINT ["java", "-jar", "quarkus-run.jar"]
      CEOF
            ;;
          go)
            cat > Containerfile <<CEOF
      FROM $BUILDER AS builder
      WORKDIR /build
      COPY go.mod go.sum ./
      RUN go mod download
      COPY . .
      RUN CGO_ENABLED=0 go build -o app .

      FROM $RUNTIME
      WORKDIR /app
      COPY --from=builder /build/app ./
      USER 65532
      EXPOSE 8080
      ENTRYPOINT ["./app"]
      CEOF
            ;;
        esac
        echo "Generated Containerfile:"
        cat Containerfile
      else
        echo "Using existing $(params.DOCKERFILE)"
      fi
    volumeMounts:
    - name: shared
      mountPath: /shared

  - name: build-and-push
    image: registry.redhat.io/rhel9/buildah:latest
    imagePullPolicy: Always
    workingDir: $(params.shp-source-context)
    command:
    - /bin/bash
    args:
    - -c
    - |
      set -e

      DETECTED=$(cat /shared/detected-lang)
      echo "[INFO] Building $DETECTED application"

      buildah --storage-driver=vfs bud \
        --format=oci \
        --tls-verify=true \
        --no-cache \
        -f $(params.DOCKERFILE) \
        -t $(params.shp-output-image) \
        .

      echo "[INFO] Pushing image $(params.shp-output-image)"
      buildah --storage-driver=vfs push \
        --tls-verify=true \
        --digestfile='$(results.shp-image-digest.path)' \
        $(params.shp-output-image) \
        docker://$(params.shp-output-image)
    volumeMounts:
    - name: shared
      mountPath: /shared
    securityContext:
      capabilities:
        add:
        - SETFCAP

  volumes:
  - name: shared
    emptyDir: {}
EOF
Expected output:
clusterbuildstrategy.shipwright.io/hummingbird-multi-lang created
What this strategy does:
  • Auto-detects application language from project files (package.json, requirements.txt, pom.xml, go.mod)

  • Selects the appropriate UBI builder (or Hummingbird builder for Java) and Hummingbird runtime images

  • Generates a language-appropriate multi-stage Containerfile if one doesn’t already exist

  • Builds and pushes the final image using buildah

Table 1. Hummingbird Runtime Mapping:
Language Builder Image Runtime Image

Node.js

ubi9/nodejs-20

hummingbird-hatchling/nodejs:20

Python

ubi9/python-311

hummingbird-hatchling/python:3.11

Java

hummingbird-hatchling/openjdk:21-builder

hummingbird-hatchling/openjdk:21-runtime

Go

ubi9/go-toolset

hummingbird-hatchling/core-runtime:2

Platform Engineering Benefit:

This single ClusterBuildStrategy supports 4+ languages. Development teams can use it without worrying about language-specific configuration. The platform team maintains one strategy instead of four.

Step 2: Test Multi-Language Strategy

Create a Build using this strategy with the same Quarkus sample application from Module 2.1. The strategy will auto-detect Java from the pom.xml and find the existing Containerfile:

cat << 'EOF' | oc apply -f -
apiVersion: shipwright.io/v1beta1
kind: Build
metadata:
  name: auto-detect-build
  namespace: {user}-hummingbird-builds
spec:
  source:
    type: Git
    git:
      url: https://github.com/tosin2013/sample-quarkus-hummingbird
      revision: main
  strategy:
    name: hummingbird-multi-lang
    kind: ClusterBuildStrategy
  output:
    image: {quay_hostname}/{quay_user}/auto-detect-app:latest
    credentials:
      name: registry-credentials
EOF

Notice we’re using the same Git repository from Module 2.1 but with the hummingbird-multi-lang strategy instead of buildah. The strategy will auto-detect Java (via pom.xml) and use the repository’s existing Containerfile. This demonstrates how the multi-language strategy provides a consistent build interface regardless of language.

Step 3: Trigger Build

cat << 'EOF' | oc apply -f -
apiVersion: shipwright.io/v1beta1
kind: BuildRun
metadata:
  name: auto-detect-build-run-1
  namespace: {user}-hummingbird-builds
spec:
  build:
    name: auto-detect-build
EOF

Monitor the build:

oc get buildrun auto-detect-build-run-1 -n {user}-hummingbird-builds -w

Press Ctrl+C once the build succeeds.

Step 4: View Language Detection Logs

Check that the language was detected correctly:

BUILDRUN_POD=$(oc get buildrun auto-detect-build-run-1 -n {user}-hummingbird-builds -o jsonpath='{.status.taskRunName}')-pod

# View detect-and-generate step output
oc logs $BUILDRUN_POD -n {user}-hummingbird-builds -c step-detect-and-generate
Expected output:
Detecting application language...
Language: java
Using existing Containerfile

The strategy detected Java via pom.xml and found the existing Containerfile in the repository — no auto-generation needed.

Step 5: Verify Build Logs

Confirm the full build pipeline worked by inspecting the build step output:

oc logs $BUILDRUN_POD -n {user}-hummingbird-builds -c step-build-and-push | head -20
Expected output:
[INFO] Building java application
[INFO] Building image ...
[1/2] STEP 1/8: FROM registry.access.redhat.com/hi/openjdk:21-builder AS builder
...
[2/2] STEP 6/11: FROM registry.access.redhat.com/hi/openjdk:21-runtime
...

This confirms the strategy correctly detected Java, used the existing multi-stage Containerfile, and built with Hummingbird builder and runtime images.

To test with different languages, point the Build at any repository containing:

  • Node.js: package.json (strategy auto-generates Containerfile if missing)

  • Python: requirements.txt, setup.py, or Pipfile

  • Java: pom.xml or build.gradle (uses existing Containerfile if present)

  • Go: go.mod

If the repository already has a Containerfile, the strategy uses it as-is. Otherwise, it generates a language-appropriate multi-stage Containerfile.

Registry Integration

Step 6: Configure Internal OpenShift Registry

Use OpenShift’s internal registry for development builds:

# Enable image registry default route (if not already enabled)
oc patch configs.imageregistry.operator.openshift.io/cluster \
  --type merge \
  --patch '{"spec":{"defaultRoute":true}}'

# Get internal registry route
INTERNAL_REGISTRY=$(oc get route default-route -n openshift-image-registry -o jsonpath='{.spec.host}' 2>/dev/null || echo "Not yet available")
echo "Internal registry: $INTERNAL_REGISTRY"

If the route doesn’t exist yet, it may take 1-2 minutes for the operator to create it after patching.

Step 7: Create Build Using Internal Registry

Create a Build targeting the internal registry:

cat << 'EOF' | oc apply -f -
apiVersion: shipwright.io/v1beta1
kind: Build
metadata:
  name: internal-registry-build
  namespace: {user}-hummingbird-builds
spec:
  source:
    type: Git
    git:
      url: https://github.com/tosin2013/sample-quarkus-hummingbird
      revision: main
  strategy:
    name: hummingbird-multi-lang
    kind: ClusterBuildStrategy
  output:
    image: image-registry.openshift-image-registry.svc:5000/{user}-hummingbird-builds/sample-app:latest
EOF
Expected output:
build.shipwright.io/internal-registry-build created

Internal Registry Benefits:

  • No credentials needed when building within the same cluster

  • Automatic garbage collection via OpenShift image pruning

  • ImageStreams for automatic deployment updates

  • Ideal for dev/test environments

Step 8: Trigger Internal Registry Build

cat << 'EOF' | oc create -f -
apiVersion: shipwright.io/v1beta1
kind: BuildRun
metadata:
  generateName: internal-registry-build-run-
  namespace: {user}-hummingbird-builds
spec:
  build:
    name: internal-registry-build
EOF

Using generateName instead of name allows OpenShift to auto-generate unique names, which is useful for repeated builds.

Step 8b: Verify ImageStream in OpenShift

Confirm the image was pushed to the internal registry by checking the ImageStream:

oc get imagestream sample-app -n {user}-hummingbird-builds
oc get imagestream sample-app -n {user}-hummingbird-builds -o jsonpath='{.status.tags[0].items[0].dockerImageReference}'
echo ""
Expected output:
NAME         IMAGE REPOSITORY                                                                TAGS     UPDATED
sample-app   image-registry.openshift-image-registry.svc:5000/{user}-hummingbird-builds/sample-app   latest   ...

You can also verify this in the OpenShift console under BuildsImageStreams in the {user}-hummingbird-builds project:

OpenShift ImageStreams
Figure 1. OpenShift console showing the sample-app ImageStream created by the internal registry build

Air-Gapped Registry Mirrors

For air-gapped or restricted environments, configure registry mirrors to redirect pulls to internal registries.

Step 9: Configure ImageContentSourcePolicy (Optional)

Warning: ImageContentSourcePolicy requires cluster-admin privileges and triggers a cluster-wide MachineConfig update that will reboot all nodes. Only perform this step if:

  1. You have cluster-admin privileges

  2. You’re working in a dedicated test cluster

  3. You understand the impact of a cluster-wide node reboot

For workshop purposes, you can skip this step and just review the configuration.

Example Configuration (Do NOT apply unless you understand the impact):
# DO NOT APPLY - This is for reference only
apiVersion: operator.openshift.io/v1alpha1
kind: ImageContentSourcePolicy
metadata:
  name: hummingbird-mirror
spec:
  repositoryDigestMirrors:
  - mirrors:
    - registry.internal.example.com/hummingbird
    source: registry.access.redhat.com/hi
  - mirrors:
    - registry.internal.example.com/ubi9
    source: registry.access.redhat.com/ubi9
What this does:
  • Redirects pulls from registry.access.redhat.com/hi to registry.internal.example.com/hummingbird

  • Redirects pulls from registry.access.redhat.com/ubi9 to registry.internal.example.com/ubi9

  • All cluster nodes (and all pods) automatically use the mirrors

  • Shipwright builds automatically use mirrored images

Production Air-Gap Workflow:

  1. Mirror images from public registries to internal registry using skopeo sync

  2. Apply ImageContentSourcePolicy to redirect all pulls

  3. Test Shipwright builds to ensure they pull from mirrors

  4. Update BuildStrategies if needed to reference mirror URLs

See the full air-gap mirror setup guide in the reference documentation.

Summary

Congratulations! You’ve completed Sub-Module 2.2 and created advanced multi-language build strategies!

What You’ve Accomplished

✅ Created ClusterBuildStrategy with language auto-detection
✅ Configured conditional Hummingbird runtime selection for 4+ languages
✅ Tested auto-detection with Node.js application
✅ Verified build logs confirm correct runtime selection
✅ Enabled OpenShift internal registry
✅ Created Build using internal registry (no credentials needed)
✅ Understood ImageContentSourcePolicy for air-gapped environments
✅ Learned registry mirror configuration patterns

Key Takeaways

Multi-Language Strategy Benefits: * Single interface for all development teams * Automatic detection reduces configuration burden * Consistent Hummingbird usage across all languages * Platform team maintains one strategy instead of many

Registry Integration Patterns: * On-cluster Quay: Validated TLS, Clair scanning, used throughout this workshop * OpenShift internal registry: No credentials within same cluster * External registries (quay.io, Docker Hub): Require separate credentials * Registry mirrors: Enable air-gapped deployments

Production Readiness: * Auto-detection reduces developer errors * Internal registry ideal for dev/test environments * ImageContentSourcePolicy for air-gapped production

Next Sub-Module

Ready to add security pipelines? Proceed to:

Integrate SBOM generation, vulnerability scanning, image signing, and production deployment with proper security contexts.

Additional Resources

Buildah Documentation: * Buildah Official Site * Buildah Documentation

Air-Gap Deployments: * Disconnected Installation Guide

Troubleshooting

Issue: Language detection fails

# Check detect-and-generate step logs
BUILDRUN_POD=$(oc get buildrun <buildrun-name> -o jsonpath='{.status.taskRunName}')-pod
oc logs $BUILDRUN_POD -c step-detect-and-generate

# Verify source repository contents
# Ensure package.json, requirements.txt, pom.xml, or go.mod exist in the repo

Issue: Buildah build fails

# Check build-and-push step logs for errors
oc logs $BUILDRUN_POD -c step-build-and-push

# Common issues:
# - Missing build tools in UBI builder image
# - Network connectivity to registries
# - Storage driver issues (vfs vs overlay)

Issue: Internal registry not accessible

# Verify registry route exists
oc get route default-route -n openshift-image-registry

# Check registry operator status
oc get clusteroperator image-registry

# Enable default route if not already enabled
oc patch configs.imageregistry.operator.openshift.io/cluster \
  --type merge \
  --patch '{"spec":{"defaultRoute":true}}'