Geo: ContainerRepositorySync raises NoMethodError on manifests without layers/manifests/blobs

Summary

Geo::ContainerRepositorySync#list_blobs raises NoMethodError: undefined method 'filter_map' for nil when a container manifest has none of layers, manifests, or blobs set. The error is swallowed by the per-tag rescue StandardError at L29-32, so sync continues for other tags — but the affected tag never replicates. The failure surfaces only as an error log line.

Error in production Geo logs

class:    Geo::ContainerRepositorySync
severity: ERROR
message:  Error while syncing tag <tag-name>: undefined method `filter_map' for nil

Root cause

ee/app/services/geo/container_repository_sync.rb#L107-113:

def list_blobs(manifest)
  blobs = (manifest['layers'] || manifest['manifests'] || manifest['blobs']).filter_map do |blob|
    blob['digest'] unless foreign_layer?(blob)
  end

  blobs.push(manifest.dig('config', 'digest')).compact
end

If manifest['layers'], manifest['manifests'], and manifest['blobs'] are all absent, the || chain evaluates to nil, and .filter_map is called on nil.

Manifest shapes that can hit this:

  • Cosign / Notation signatures and attestations — minimal artifact manifests, often with just config + subject + annotations, no layers/blobs array.
  • OCI 1.1 referrers — referrer manifests that point at a subject and carry the payload elsewhere.
  • Empty / config-only manifests produced by some artifact tooling.
  • Any non-image, non-index manifest type not anticipated by the code.

The tag name pattern observed in the affected environment (<version>-<build>) is consistent with build-attached signature or attestation artifacts.

Proposed fix

Default the descriptor list to [] so the method tolerates manifests without these arrays — treat as "no blobs to pre-sync" rather than crashing. push_manifest at line 74 will still run for the tag itself, and config.digest (line 112) is still picked up if present:

def list_blobs(manifest)
  descriptors = manifest['layers'] || manifest['manifests'] || manifest['blobs'] || []

  blobs = descriptors.filter_map do |blob|
    blob['digest'] unless foreign_layer?(blob)
  end

  blobs.push(manifest.dig('config', 'digest')).compact
end

Test gap

Existing tests in ee/spec/services/geo/container_repository_sync_spec.rb cover Docker V2 manifest, OCI image manifest, Docker manifest list, OCI image index, buildkit cache, and OCI artifact-with-manifest — all of which have at least one of layers / manifests / blobs populated. Add a spec for the "none of the above" shape, e.g.:

let(:bare_artifact_manifest) do
  %(
    {
      "schemaVersion": 2,
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "config": { "mediaType": "application/vnd.example", "digest": "sha256:abc", "size": 12 },
      "subject": { "mediaType": "application/vnd.oci.image.manifest.v1+json", "digest": "sha256:def", "size": 34 }
    }
  )
end

Assert sync completes without raising and push_manifest is called for the tag with the correct Content-Type. Optionally assert that the config.digest blob is synced.

Recovery for already-affected tags

Once the fix is deployed, mark the relevant Geo::ContainerRepositoryRegistry rows as pending (or trigger via Geo admin UI / API) to re-sync the previously-failing tags.

  • #600486 (closed) — Geo container repository sync silently skips OCI image index tags (same file, distinct symptom; both are robustness issues in the same service and could land in coordinated MRs).