Automating Container Image Patching in Kubernetes with Copa and Kyverno

Automating Container Image Patching in Kubernetes with Copa and Kyverno

Problem

A Kubernetes cluster running a hundred distinct container images is a patching problem that does not scale with human toil. Every image has its own OS package set, its own CVE exposure surface, and its own upstream release cadence — which may be slow, irregular, or permanently stalled for images tied to abandoned projects. When a scanner reports that nginx:1.25.3 has a critical OpenSSL CVE, someone has to determine which Deployments use that image, whether upstream has published a fix, what the patching path is if they have not, and then execute the patch across every affected workload. In a cluster where that same situation is true for thirty images simultaneously, the manual process collapses.

Copa (Copacetic) solves the execution side of this: given a Trivy scan report and an OCI image reference, Copa applies OS package patches directly to an existing image layer without requiring a Dockerfile, a full rebuild, or access to the original build pipeline. A critical CVE in a libssl package is patched by pulling the updated package from the OS package repository and writing it into the image as a new layer. The base image remains otherwise unchanged. The patched image is pushed back to the registry under a new digest.

But Copa running on a developer’s laptop or in a one-shot CI job solves only half the problem. Two gaps remain that a production patching system must close:

The scale gap. Copa must be invoked per-image, on a schedule, for every image tracked in the cluster. An ad-hoc manual invocation misses images that were not on the operator’s mental checklist. A one-shot CI job tied to a specific Deployment’s pipeline misses shared base images used by multiple Deployments. A CronJob that iterates a ConfigMap of all tracked images and applies Copa to each one closes this gap systematically.

The closed-loop gap. After Copa patches an image and pushes the new digest, nothing prevents the old image tag from being referenced in a Deployment manifest and re-scheduled. imagePullPolicy: IfNotPresent compounds this: if the unpatched image is already cached on a node, Kubernetes will not pull the patched version at all — the cached unpatched image runs indefinitely. A Kyverno ClusterPolicy that evaluates image vulnerability state at admission time closes this gap: if a Pod references an image with a critical CVE that should have been patched, the admission request is denied.

This article builds both halves. Target environment: Kubernetes 1.28+, containerd runtime, a CronJob running Copa and Trivy with a BuildKit sidecar, Kyverno 1.11+, and any OCI-compliant registry (the examples use a generic REGISTRY variable; ECR, GCR, and ACR credential approaches are addressed in the registry credentials section).

Threat Model

Threat 1: Critical CVE disclosed against an image already deployed to the cluster with no automated patching mechanism. The image was clean at deploy time. A CVE is disclosed against one of its OS packages. Without an automated scanner and patcher running on a schedule, the image continues to run indefinitely. The window of exposure is bounded only by the next application team deployment — which may be weeks away and is not tied to patch SLAs.

Threat 2: Patched image exists in registry but the old image tag is still referenced in a Deployment. Copa has already patched the image and pushed a new digest tagged myapp:1.4.2-patched-20260509. The Deployment still references myapp:1.4.2. The Pod scheduler starts new Pods using myapp:1.4.2. The vulnerability is patched in the registry but still running in the cluster. Without an admission control policy enforcing that deployed images must be patched, the patched image is irrelevant.

Threat 3: New Pod scheduled from a cached unpatched image due to imagePullPolicy: IfNotPresent. A node cached myapp:1.4.2 before Copa ran. Even if myapp:1.4.2 is updated in the registry, Kubernetes does not re-pull the image on subsequent Pod starts for that node. The running Pod uses an image that has been nominally “updated” in the registry but is actually an older, unpatched layer on disk. This is a silent failure: kubectl describe pod shows the correct tag, but the actual image binary differs from what the registry serves.

Access level assumed for exploitation. Threats 1 and 3 require only that an attacker discover and exploit a CVE in a running container. The attacker needs no Kubernetes API access — container-level exploitation is sufficient. Threat 2 requires an attacker who can reach the network services exposed by the vulnerable container. In all cases the Kubernetes control plane and registry are not compromised; the vulnerability is in application or OS-level code inside the container.

Configuration

Architecture Overview

The system has four components:

  1. Copa CronJob — runs on a configurable schedule (e.g., every 6 hours), reads a ConfigMap listing images to patch, runs trivy image against each, and for images with critical CVEs above a configurable threshold, runs copa patch to produce a patched OCI image, which it pushes to the registry under a new digest. It updates a registry tag (-patched-YYYYMMDD) and optionally writes the new digest to a ConfigMap for Flux to consume.

  2. BuildKit sidecar — Copa requires BuildKit to build the patched image layer. The CronJob Pod runs a BuildKit daemon as a sidecar container exposed via a Unix socket shared with the Copa container.

  3. Kyverno ClusterPolicy — evaluates each Pod at admission time. Images in the cluster’s tracked set that have not been patched by Copa within the patch SLA window are denied. The policy reads a ConfigMap of “approved patched digests” maintained by the CronJob.

  4. Flux image automation controller — when Copa pushes a new patched digest, the Flux image automation controller detects the updated registry tag and opens a pull request (or directly commits) to update Deployment manifests to the new digest.

Step 1: Namespace and ServiceAccount

# copa-system-namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: copa-system
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
# copa-serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: copa-patcher
  namespace: copa-system
  # For Workload Identity (GKE/AKS/EKS): add the provider-specific annotation here.
  # GKE: iam.gke.io/gcp-service-account: copa-patcher@PROJECT.iam.gserviceaccount.com
  # AKS: azure.workload.identity/client-id: <client-id>
  # EKS: eks.amazonaws.com/role-arn: arn:aws:iam::ACCOUNT:role/copa-patcher
automountServiceAccountToken: false

Copa’s CronJob does not need any Kubernetes API access beyond reading its own ConfigMap. The ServiceAccount intentionally has automountServiceAccountToken: false. Registry access is handled via imagePullSecrets or Workload Identity — not via the Kubernetes API.

# copa-rbac.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: copa-configmap-reader
  namespace: copa-system
rules:
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["get", "list"]
    resourceNames: ["copa-image-list", "copa-patched-digests"]
  - apiGroups: [""]
    resources: ["configmaps"]
    verbs: ["update", "patch"]
    resourceNames: ["copa-patched-digests"]
  - apiGroups: [""]
    resources: ["secrets"]
    verbs: ["get"]
    resourceNames: ["copa-registry-credentials"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: copa-patcher-binding
  namespace: copa-system
subjects:
  - kind: ServiceAccount
    name: copa-patcher
    namespace: copa-system
roleRef:
  kind: Role
  name: copa-configmap-reader
  apiGroup: rbac.authorization.k8s.io

Step 2: Image List ConfigMap

# copa-image-list.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: copa-image-list
  namespace: copa-system
data:
  # One image per line. Format: REGISTRY/REPO:TAG
  # Copa patches the image and pushes a new tag: REGISTRY/REPO:TAG-patched-YYYYMMDD
  images.txt: |
    registry.example.com/myapp/api:1.4.2
    registry.example.com/myapp/worker:2.1.0
    registry.example.com/myapp/frontend:3.0.1
    registry.example.com/shared/nginx-base:1.25.3
    registry.example.com/shared/python-base:3.12.2
  # Severity threshold: patch images with CVEs at or above this level
  severity_threshold: "CRITICAL"
  # Patch SLA in hours: how old can a patched image be before Kyverno blocks
  patch_sla_hours: "24"

Step 3: Copa CronJob with BuildKit Sidecar

# copa-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: copa-image-patcher
  namespace: copa-system
spec:
  schedule: "0 */6 * * *"        # Every 6 hours
  concurrencyPolicy: Forbid       # Do not allow overlapping runs
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      activeDeadlineSeconds: 3600 # Hard cap: 1 hour total
      backoffLimit: 0             # Do not retry; next scheduled run will pick up
      template:
        metadata:
          labels:
            app: copa-patcher
        spec:
          serviceAccountName: copa-patcher
          automountServiceAccountToken: false
          restartPolicy: Never
          securityContext:
            runAsNonRoot: false   # BuildKit requires root; isolate via seccomp
            seccompProfile:
              type: RuntimeDefault
          imagePullSecrets:
            - name: copa-registry-credentials
          volumes:
            - name: buildkit-socket
              emptyDir: {}
            - name: copa-script
              configMap:
                name: copa-patch-script
                defaultMode: 0755
            - name: image-list
              configMap:
                name: copa-image-list
            - name: registry-config
              secret:
                secretName: copa-registry-credentials
                items:
                  - key: .dockerconfigjson
                    path: config.json

          initContainers:
            - name: wait-for-buildkitd
              image: ghcr.io/project-copacetic/copacetic:v0.8.0
              command:
                - sh
                - -c
                - |
                  echo "Waiting for BuildKit socket..."
                  until [ -S /run/buildkit/buildkitd.sock ]; do
                    sleep 1
                  done
                  echo "BuildKit socket ready."
              volumeMounts:
                - name: buildkit-socket
                  mountPath: /run/buildkit
              securityContext:
                runAsNonRoot: false
                allowPrivilegeEscalation: false

          containers:
            - name: buildkitd
              image: moby/buildkit:v0.17.2-rootless
              args:
                - --addr
                - unix:///run/buildkit/buildkitd.sock
                - --oci-worker-no-process-sandbox
              securityContext:
                runAsNonRoot: false
                privileged: false
                seccompProfile:
                  type: Unconfined  # BuildKit rootless requires this
              resources:
                requests:
                  cpu: 500m
                  memory: 1Gi
                limits:
                  cpu: "2"
                  memory: 4Gi
              volumeMounts:
                - name: buildkit-socket
                  mountPath: /run/buildkit

            - name: copa-patcher
              image: ghcr.io/project-copacetic/copacetic:v0.8.0
              command: ["/scripts/patch-loop.sh"]
              env:
                - name: REGISTRY
                  value: "registry.example.com"
                - name: BUILDKIT_HOST
                  value: "unix:///run/buildkit/buildkitd.sock"
                - name: DOCKER_CONFIG
                  value: "/registry-config"
                - name: SEVERITY_THRESHOLD
                  valueFrom:
                    configMapKeyRef:
                      name: copa-image-list
                      key: severity_threshold
                - name: PATCH_SLA_HOURS
                  valueFrom:
                    configMapKeyRef:
                      name: copa-image-list
                      key: patch_sla_hours
              resources:
                requests:
                  cpu: 250m
                  memory: 512Mi
                limits:
                  cpu: "1"
                  memory: 2Gi
              securityContext:
                runAsNonRoot: false
                allowPrivilegeEscalation: false
                readOnlyRootFilesystem: false  # Copa writes temp files
              volumeMounts:
                - name: buildkit-socket
                  mountPath: /run/buildkit
                - name: copa-script
                  mountPath: /scripts
                - name: image-list
                  mountPath: /config
                - name: registry-config
                  mountPath: /registry-config
                  readOnly: true

Step 4: Patch Loop Script

# copa-patch-script-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: copa-patch-script
  namespace: copa-system
data:
  patch-loop.sh: |
    #!/usr/bin/env bash
    set -euo pipefail

    IMAGE_LIST="/config/images.txt"
    PATCH_DATE=$(date +%Y%m%d)
    PATCHED_DIGESTS_FILE="/tmp/patched-digests.txt"
    EXIT_CODE=0

    echo "Copa patch loop starting: $(date -u +%Y-%m-%dT%H:%M:%SZ)"

    while IFS= read -r image || [[ -n "$image" ]]; do
      # Skip blank lines and comments
      [[ -z "$image" || "$image" == \#* ]] && continue

      echo "--- Processing: $image ---"

      # Extract components
      repo=$(echo "$image" | cut -d: -f1)
      tag=$(echo "$image" | cut -d: -f2)
      patched_tag="${tag}-patched-${PATCH_DATE}"
      patched_image="${repo}:${patched_tag}"

      # Run Trivy scan and emit JSON report
      SCAN_REPORT="/tmp/trivy-$(echo "$image" | tr '/: ' '_').json"

      echo "Scanning $image for ${SEVERITY_THRESHOLD}+ CVEs..."
      if ! trivy image \
        --exit-code 0 \
        --format json \
        --severity "${SEVERITY_THRESHOLD},HIGH" \
        --ignore-unfixed \
        --output "$SCAN_REPORT" \
        "$image" 2>&1; then
        echo "ERROR: trivy scan failed for $image — skipping"
        EXIT_CODE=1
        continue
      fi

      # Count fixable critical/high CVEs
      VULN_COUNT=$(jq '[.Results[]?.Vulnerabilities[]? | select(.FixedVersion != null and .FixedVersion != "")] | length' "$SCAN_REPORT")
      echo "Fixable CVEs found: $VULN_COUNT"

      if [[ "$VULN_COUNT" -eq 0 ]]; then
        echo "No fixable CVEs above threshold — skipping patch for $image"
        continue
      fi

      echo "Patching $image → $patched_image via Copa..."
      if ! copa patch \
        --image "$image" \
        --report "$SCAN_REPORT" \
        --output "$patched_image" \
        --addr "$BUILDKIT_HOST"; then
        echo "ERROR: copa patch failed for $image"
        EXIT_CODE=1
        continue
      fi

      # Push the patched image
      echo "Pushing $patched_image..."
      if ! crane push "$patched_image" "$patched_image" 2>&1; then
        # Copa already pushes; this step logs the digest
        true
      fi

      # Retrieve and record the new digest
      NEW_DIGEST=$(crane digest "$patched_image" 2>/dev/null || echo "unknown")
      echo "$repo@${NEW_DIGEST}  # patched from $image on $PATCH_DATE" >> "$PATCHED_DIGESTS_FILE"
      echo "Patched digest: $NEW_DIGEST"

      echo "--- Done: $image ---"
    done < "$IMAGE_LIST"

    # Write the patched digest list to a ConfigMap for Kyverno and Flux to consume
    if [[ -f "$PATCHED_DIGESTS_FILE" ]]; then
      kubectl create configmap copa-patched-digests \
        --namespace copa-system \
        --from-file=digests.txt="$PATCHED_DIGESTS_FILE" \
        --dry-run=client -o yaml | kubectl apply -f -
    fi

    echo "Copa patch loop complete: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
    exit $EXIT_CODE

Step 5: Kyverno ClusterPolicy — Block Unpatched Images

Kyverno 1.11+ supports imageVerify with an external data source. The policy below takes two complementary approaches: the primary approach uses an imageVerify rule to check that the image tag includes a -patched- suffix and was pushed within the SLA window; the secondary (belt-and-suspenders) approach uses a validate rule with an external context call to a policy-controlled ConfigMap of approved digests.

# kyverno-block-unpatched.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: block-unpatched-images
  annotations:
    policies.kyverno.io/title: Block Unpatched Container Images
    policies.kyverno.io/description: >
      Blocks Pods that reference container images from the tracked image set
      unless those images carry a Copa-patched tag applied within the patch SLA
      window. Applies to all namespaces except kube-system and copa-system.
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: require-patched-tag-for-tracked-images
      match:
        any:
          - resources:
              kinds:
                - Pod
      exclude:
        any:
          - resources:
              namespaces:
                - kube-system
                - copa-system
                - kyverno
      context:
        - name: trackedImages
          configMap:
            name: copa-image-list
            namespace: copa-system
        - name: patchedDigests
          configMap:
            name: copa-patched-digests
            namespace: copa-system
      validate:
        message: >
          Image {{ element.image }} is in the tracked image set but does not
          carry a Copa-patched tag. Re-deploy using the patched digest published
          by the copa-image-patcher CronJob in the copa-system namespace.
        foreach:
          - list: "request.object.spec.containers"
            deny:
              conditions:
                all:
                  # The image repo is in our tracked set
                  - key: "{{ element.image }}"
                    operator: AnyIn
                    value: "{{ trackedImages.data.\"images.txt\" | parse_yaml(@) }}"
                  # But the tag does NOT contain "-patched-"
                  - key: "{{ element.image | split(@, ':') | [-1:] | [0] | contains(@, '-patched-') }}"
                    operator: Equals
                    value: "false"

The validationFailureAction: Enforce setting means mismatched images are denied at admission, not just logged. Switch to Audit during initial rollout to understand what would be blocked before enforcing.

Step 6: Alternative Kyverno Approach — Require Image Digest from Approved List

A complementary rule enforces that images use a digest reference (not a mutable tag) that appears in the ConfigMap of Copa-patched digests:

# kyverno-require-approved-digest.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-copa-approved-digest
spec:
  validationFailureAction: Audit  # Start in Audit; promote to Enforce after rollout
  background: true
  rules:
    - name: image-must-use-approved-digest
      match:
        any:
          - resources:
              kinds: [Pod]
      exclude:
        any:
          - resources:
              namespaces: [kube-system, copa-system, kyverno]
      context:
        - name: approvedDigests
          configMap:
            name: copa-patched-digests
            namespace: copa-system
      validate:
        message: >
          Image {{ element.image }} must reference a digest approved by the Copa
          CronJob. Check copa-patched-digests ConfigMap in copa-system for the
          approved digest for this image.
        foreach:
          - list: "request.object.spec.containers"
            deny:
              conditions:
                all:
                  - key: "{{ contains(element.image, '@sha256:') }}"
                    operator: Equals
                    value: "false"

This rule is complementary, not a replacement. It enforces the broader practice of digest-pinned images and should be applied across all workloads, not just Copa-tracked ones.

Step 7: Flux Image Automation Integration

After Copa pushes a patched image with a new tag, Flux’s image automation controller can detect the new tag and update Deployment manifests automatically.

# flux-image-repository.yaml
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImageRepository
metadata:
  name: myapp-api
  namespace: flux-system
spec:
  image: registry.example.com/myapp/api
  interval: 10m
  secretRef:
    name: registry-credentials
---
apiVersion: image.toolkit.fluxcd.io/v1beta2
kind: ImagePolicy
metadata:
  name: myapp-api-patched
  namespace: flux-system
spec:
  imageRepositoryRef:
    name: myapp-api
  filterTags:
    # Match tags of the form 1.4.2-patched-20260509
    pattern: '^[0-9]+\.[0-9]+\.[0-9]+-patched-[0-9]{8}$'
    extract: '$patch_date'
  policy:
    alphabetical:
      order: asc

In the Deployment manifest, mark the image field with a Flux image automation marker:

# In the Deployment spec.template.spec.containers section:
containers:
  - name: api
    image: registry.example.com/myapp/api:1.4.2-patched-20260509 # {"$imagepolicy": "flux-system:myapp-api-patched"}

Flux detects when Copa pushes a newer -patched-YYYYMMDD tag, updates the Deployment manifest in the Git repository, and reconciles. The loop is complete: Copa patches, Flux detects and updates the manifest, new Pods pick up the patched image digest.

Step 8: Registry Credentials

For static credentials (username/password), store as a Kubernetes Secret and reference via imagePullSecrets:

kubectl create secret docker-registry copa-registry-credentials \
  --namespace copa-system \
  --docker-server=registry.example.com \
  --docker-username=copa-service-user \
  --docker-password="$(vault kv get -field=password secret/copa/registry)" \
  --docker-email=copa@example.com

For cloud-managed registries using Workload Identity, annotate the ServiceAccount as shown in Step 1 and grant the cloud IAM role the push permission on the specific repository path — not the entire registry. For ECR, this is the ecr:BatchGetImage, ecr:GetDownloadUrlForLayer, ecr:PutImage, and ecr:InitiateLayerUpload permissions scoped to the repository ARN.

Expected Behaviour

The table below describes the system response to each security-relevant scenario once the CronJob and Kyverno policies are fully operational:

Scenario System Response Latency
New critical CVE disclosed against tracked image Copa CronJob picks it up on next scheduled run; patches and pushes new digest; Flux updates Deployment manifest Within CronJob interval (default 6h)
Patch SLA breach (image unpatched beyond 24h threshold) Kyverno blocks new Pods referencing unpatched image; existing Pods continue running until replaced Immediate for new Pods at next admission
Operator attempts to deploy old (unpatched) image tag Kyverno Enforce policy denies the Pod at admission; deployment fails with descriptive error message Immediate
imagePullPolicy: IfNotPresent with cached unpatched image on node Kyverno blocks the Pod before it reaches the node; cache state is irrelevant Immediate
Copa CronJob completes with non-zero exit (partial failure) Job enters Failed state; Prometheus alert fires on kube_job_status_failed; no patched digests written for failed images Within alerting interval
Kyverno is degraded or unreachable Depends on failurePolicy: Fail blocks all Pod creation (safe); Ignore allows unvalidated Pods (unsafe) Immediate

Trade-offs

Decision Trade-off
CronJob interval: 6h vs. 1h Shorter intervals reduce the patch window but increase registry API calls, Trivy scan traffic, and BuildKit resource usage. Each run pulls and scans every tracked image regardless of whether CVEs changed. A 1h interval on 50 images may generate enough registry traffic to hit rate limits or pull-through cache costs.
Kyverno Enforce vs. Audit mode Enforce provides hard guarantees but blocks legitimate workloads if Copa fails to patch an image in time (e.g., due to upstream package repository outage). Audit logs violations without blocking — useful during rollout but provides no enforcement. A middle path: Audit with PagerDuty alert on violation count, manually promoted to Enforce after 30 days of clean signal.
Copa patch vs. full image rebuild Copa patches only OS packages — it cannot update application dependencies (pip, npm, cargo) or compiled application binaries. A CVE in a Go standard library pulled into an application binary requires a full rebuild. Copa handles the 80% case (OS package CVEs) with zero build pipeline dependency; the remaining 20% still needs the original build system.
BuildKit in-cluster vs. external BuildKit daemon Running BuildKit as a sidecar simplifies deployment but consumes per-job CPU and memory on the cluster. A dedicated external BuildKit service reduces CronJob pod resource requirements and can be shared across multiple automation workloads, but requires network-accessible BuildKit infrastructure and TLS for the gRPC connection.
Patched tag vs. digest pinning Using a tag (-patched-YYYYMMDD) is human-readable and integrates naturally with Flux image policies, but tags are mutable. A digest pin (@sha256:...) is immutable but requires tooling (Renovate, Flux) to propagate the digest to Deployment manifests. The architecture presented uses both: Copa tags for discovery, digest references for Deployment manifests after Flux updates them.

Failure Modes

Failure Mode Detection Remediation
CronJob fails silently (exit 0 despite partial failure) Script uses EXIT_CODE accumulation; any per-image failure sets exit 1. Alert on kube_job_status_failed{job_name="copa-image-patcher"} in Prometheus. Without this alert, a failing CronJob produces no notification and images go unpatched. Fix the root cause (registry auth, Trivy connectivity, BuildKit OOM), then trigger a manual Job run using kubectl create job --from=cronjob/copa-image-patcher manual-run-$(date +%s) -n copa-system.
Kyverno in Audit mode when Enforce is expected Kyverno policy mode is not visible in kubectl describe pod output; you only discover it when a bad image is admitted without denial. Add a Prometheus rule alerting on kyverno_policy_rule_results_info{rule_type="validate",status="fail"} with a label check on policy_validation_failure_action. Or use kubectl get clusterpolicy block-unpatched-images -o jsonpath='{.spec.validationFailureAction}' in a daily audit job.
Copa patch loop: patch succeeds but tag update fails, next run re-patches the same image Copa runs, patches the image, but the registry push of the patched tag fails. Next run finds the original tag still has critical CVEs and patches again. BuildKit and registry egress are consumed repeatedly for no new result. Add an idempotency check in the patch script: before running Copa, check if a -patched-YYYYMMDD tag already exists in the registry (`crane ls $repo
BuildKit OOM kill BuildKit’s memory usage scales with image layer size. Large images (>2 GB uncompressed) can exhaust the 4 Gi limit. The BuildKit sidecar is OOM-killed; Copa exits non-zero; the Job fails. Increase limits.memory on the buildkitd container for large image sets. Alternatively, split large images into a separate CronJob with higher memory allocation. Monitor with container_memory_working_set_bytes{container="buildkitd"}.
Upstream package repository unavailable Copa’s patch step downloads updated packages from the OS package repository (e.g., apt, apk). If the repository is unreachable, Copa cannot patch even when CVEs are present. The Job fails; the image remains unpatched. Configure Trivy and Copa to use a mirrored package repository hosted internally. This also eliminates the network egress path for production clusters with restricted outbound connectivity.
Kyverno ConfigMap context read fails If copa-patched-digests ConfigMap is missing (e.g., Copa has never run successfully), the Kyverno rule context lookup fails. Depending on Kyverno’s error handling, this may block all Pod creation or allow all Pods through. Pre-create the copa-patched-digests ConfigMap as an empty map during initial cluster bootstrap. This ensures the ConfigMap always exists and Kyverno can read it, even before Copa has run for the first time.