Skip to content

fix(mirror-plugins): prevent OCI URL path prefix stacking when target registry includes a path#2982

Merged
openshift-merge-bot[bot] merged 2 commits into
redhat-developer:release-1.9from
Fortune-Ndlovu:fix/mirror-plugins-registry-path-prefix
Jun 9, 2026
Merged

fix(mirror-plugins): prevent OCI URL path prefix stacking when target registry includes a path#2982
openshift-merge-bot[bot] merged 2 commits into
redhat-developer:release-1.9from
Fortune-Ndlovu:fix/mirror-plugins-registry-path-prefix

Conversation

@Fortune-Ndlovu

@Fortune-Ndlovu Fortune-Ndlovu commented Jun 9, 2026

Copy link
Copy Markdown
Member

PR Summary by Qodo

Fix mirror-plugins OCI rewrite for registries with path prefixes
🐞 Bug fix 🕐 20-40 Minutes

Grey Divider

Walkthroughs

User Description

Description

Fixes OCI URL corruption in mirror-plugins.sh when the target registry includes a path prefix (e.g., JFrog Artifactory with artifactory.example.com/ocp). The sed and jq regexes that rewrite OCI URLs in the catalog index only matched the hostname portion ([^/]+), treating everything after the first / as the image path. When --to-registry includes a path prefix, each re-run of the script stacks the prefix:

Run 1: oci://artifactory.example.com/ocp/rhdh/plugin-catalog-index:1.9 ✓ correct
Run 2: oci://artifactory.example.com/ocp/ocp/rhdh/plugin-catalog-index:1.9 ✗ stacked
Run 3: oci://artifactory.example.com/ocp/ocp/ocp/rhdh/plugin-catalog-index:1.9 ✗ worse

This PR updates all 6 regexes to match the full registry reference (hostname + any path prefix segments) instead of just the hostname, preserving only the last two path elements (org/image) and the tag/digest. The fix is idempotent re-running produces identical output.

Which issue(s) does this PR fix or relate to

PR acceptance criteria

  • Tests
  • Documentation

How to test changes / Special notes to the reviewer

  1. Start a local registry with podman:
  podman run -d --name test-registry -p 5555:5000 docker.io/library/registry:2
  curl -s http://localhost:5555/v2/
  
  2. Save both script versions:
  # Buggy version (upstream release-1.9, before fix)
  git show upstream/release-1.9:.rhdh/scripts/mirror-plugins.sh > /tmp/mirror-plugins-buggy.sh
  chmod +x /tmp/mirror-plugins-buggy.sh

  # Fixed version (your working tree with the fix)
  cp .rhdh/scripts/mirror-plugins.sh /tmp/mirror-plugins-fixed.sh
  chmod +x /tmp/mirror-plugins-fixed.sh

  3. Test the BUGGY version:
  # Export catalog index to directory
  bash /tmp/mirror-plugins-buggy.sh \
    --plugin-index oci://registry.access.redhat.com/rhdh/plugin-catalog-index:1.9 \
    --to-dir /tmp/plugins-buggy-test
    
  # Run 1: push from dir to registry with path prefix
  bash /tmp/mirror-plugins-buggy.sh \
    --from-dir /tmp/plugins-buggy-test \
    --to-registry localhost:5555/ocp
    
  # Check — should be correct
  jq 'to_entries[0:3] | .[].value.registryReference' /tmp/plugins-buggy-test/catalog-index/index.json

  # Run 2: same command again
  bash /tmp/mirror-plugins-buggy.sh \
    --from-dir /tmp/plugins-buggy-test \
    --to-registry localhost:5555/ocp
    
  # Check — BUG: /ocp/ocp stacked
  jq 'to_entries[0:3] | .[].value.registryReference' /tmp/plugins-buggy-test/catalog-index/index.json
  grep 'oci://' /tmp/plugins-buggy-test/catalog-index/dynamic-plugins.default.yaml | head -3

  4. Test the FIXED version:
  # Export catalog index to directory
  bash /tmp/mirror-plugins-fixed.sh \
    --plugin-index oci://registry.access.redhat.com/rhdh/plugin-catalog-index:1.9 \
    --to-dir /tmp/plugins-fixed-test
    
  # Run 1
    --from-dir /tmp/plugins-buggy-test \
    --to-registry localhost:5555/ocp

  # Check — should be correct
  jq 'to_entries[0:3] | .[].value.registryReference' /tmp/plugins-buggy-test/catalog-index/index.json

  # Run 2: same command again
  bash /tmp/mirror-plugins-buggy.sh \
    --from-dir /tmp/plugins-buggy-test \
    --to-registry localhost:5555/ocp

  # Check — BUG: /ocp/ocp stacked
  jq 'to_entries[0:3] | .[].value.registryReference' /tmp/plugins-buggy-test/catalog-index/index.json
  grep 'oci://' /tmp/plugins-buggy-test/catalog-index/dynamic-plugins.default.yaml | head -3

  4. Test the FIXED version:
  # Export catalog index to directory
  bash /tmp/mirror-plugins-fixed.sh \
    --plugin-index oci://registry.access.redhat.com/rhdh/plugin-catalog-index:1.9 \
    --to-dir /tmp/plugins-fixed-test

  # Run 1
  bash /tmp/mirror-plugins-fixed.sh \
    --from-dir /tmp/plugins-fixed-test \
    --to-registry localhost:5555/ocp

  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json

  # Run 2
  bash /tmp/mirror-plugins-fixed.sh \
    --from-dir /tmp/plugins-fixed-test \
    --to-registry localhost:5555/ocp

  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json

  # Run 3
  bash /tmp/mirror-plugins-fixed.sh \
    --from-dir /tmp/plugins-fixed-test \
    --to-registry localhost:5555/ocp

  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json
  grep 'oci://' /tmp/plugins-fixed-test/catalog-index/dynamic-plugins.default.yaml | head -3

  5. Cleanup:
  podman stop test-registry && podman rm test-registry
  rm -rf /tmp/plugins-buggy-test /tmp/plugins-fixed-test /tmp/mirror-plugins-buggy.sh /tmp/mirror-plugins-fixed.sh

  The key is localhost:5555/ocp as the target registry — the /ocp path prefix is what triggers the stacking bug on re-runs.
  grep 'oci://' /tmp/plugins-buggy-test/catalog-index/dynamic-plugins.default.yaml | head -3

  4. Test the FIXED version:
  # Export catalog index to directory

  # Run 1
  bash /tmp/mirror-plugins-fixed.sh \
    --from-dir /tmp/plugins-fixed-test \
    --to-registry localhost:5555/ocp
  # Run 1
  bash /tmp/mirror-plugins-fixed.sh \
    --from-dir /tmp/plugins-fixed-test \
    --to-registry localhost:5555/ocp

  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json

  bash /tmp/mirror-plugins-fixed.sh \
    --from-dir /tmp/plugins-fixed-test \
    --to-registry localhost:5555/ocp

  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json

    --from-dir /tmp/plugins-fixed-test \
    --to-registry localhost:5555/ocp

  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json


  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json

  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json

  # Run 2
  bash /tmp/mirror-plugins-fixed.sh \
  # Run 2
  bash /tmp/mirror-plugins-fixed.sh \
    --from-dir /tmp/plugins-fixed-test \
    --to-registry localhost:5555/ocp

  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json
  # Run 2: same command again
  bash /tmp/mirror-plugins-buggy.sh \
    --from-dir /tmp/plugins-buggy-test \
    --to-registry localhost:5555/ocp

  # Check — BUG: /ocp/ocp stacked
  jq 'to_entries[0:3] | .[].value.registryReference' /tmp/plugins-buggy-test/catalog-index/index.json
  grep 'oci://' /tmp/plugins-buggy-test/catalog-index/dynamic-plugins.default.yaml | head -3

  4. Test the FIXED version:
  # Export catalog index to directory
  bash /tmp/mirror-plugins-fixed.sh \
    --plugin-index oci://registry.access.redhat.com/rhdh/plugin-catalog-index:1.9 \
    --to-dir /tmp/plugins-fixed-test

  # Run 1
  bash /tmp/mirror-plugins-fixed.sh \
    --from-dir /tmp/plugins-fixed-test \
    --to-registry localhost:5555/ocp

  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json

  # Run 2
  bash /tmp/mirror-plugins-fixed.sh \
    --from-dir /tmp/plugins-fixed-test \
    --to-registry localhost:5555/ocp

  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json

  # Run 3
  bash /tmp/mirror-plugins-fixed.sh \
    --from-dir /tmp/plugins-fixed-test \
    --to-registry localhost:5555/ocp

  jq 'to_entries[0:2] | .[].value.registryReference' /tmp/plugins-fixed-test/catalog-index/index.json
  grep 'oci://' /tmp/plugins-fixed-test/catalog-index/dynamic-plugins.default.yaml | head -3

  5. Cleanup:
  podman stop test-registry && podman rm test-registry
  rm -rf /tmp/plugins-buggy-test /tmp/plugins-fixed-test /tmp/mirror-plugins-buggy.sh /tmp/mirror-plugins-fixed.sh
AI Description
• Fix OCI registry rewrite to handle registries that include path prefixes (e.g., Artifactory).
• Make catalog index and YAML OCI rewrites idempotent across repeated script runs.
• Preserve only org/image and tag/digest when retargeting to --to-registry.
Diagram
graph TD
  A["mirror-plugins.sh"] --> B{{"Extract catalog index"}} --> C[("index.json")]
  C --> D{{"Rewrite OCI refs (jq/sed)"}} --> E[("dynamic-plugins + entities")]
  E --> F{{"Rebuild & push index"}} --> G["Target registry"]
  subgraph Legend
    direction LR
    _script["Script"] ~~~ _step{{"Step/Tool"}} ~~~ _file[("File")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Parse and reconstruct refs by splitting (no regex)
  • ➕ More robust against edge cases (extra slashes, @digest vs :tag, unusual characters).
  • ➕ Easier to reason about idempotency than complex regexes.
  • ➖ More bash code and branching; still tricky without a real parser.
  • ➖ Must duplicate logic for jq-updated JSON vs sed-updated YAML.
2. Use purpose-built tooling (yq/jq-only or small helper program)
  • ➕ Can parse YAML/JSON structurally and reduce reliance on sed regex.
  • ➕ Easier to test and validate transformations in a typed language.
  • ➖ Adds dependency/packaging overhead (especially for a small fix).
  • ➖ Potentially out of scope for a targeted bug fix in a script.

Recommendation: Current approach is appropriate for a scoped bash-script fix: updating the jq/sed patterns to match the full registry reference (including optional path prefixes) directly addresses the idempotency bug without introducing new dependencies. If future edge cases emerge (digest formats, unusual repo names), consider refactoring into split/reconstruct logic or a small helper to avoid further regex growth.

Grey Divider

File Changes

Bug fix (1)
mirror-plugins.sh Fix idempotent OCI registry rewriting when --to-registry includes a path +11/-11

Fix idempotent OCI registry rewriting when --to-registry includes a path

• Updates jq and sed rewrite patterns to match the full registry reference (hostname plus optional path prefix segments) rather than just the hostname. Ensures rewritten OCI references preserve only the last two path elements (org/image) plus tag/digest, preventing path-prefix stacking across repeated runs in both catalog-index mirroring and from-dir rebuild flows.

.rhdh/scripts/mirror-plugins.sh


Grey Divider

Qodo Logo

… registry includes a path

Signed-off-by: Fortune-Ndlovu <fndlovu@redhat.com>
@rhdh-qodo-merge

rhdh-qodo-merge Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (0) 📎 Requirement gaps (0) 🔗 Cross-repo conflicts (0)

Context used
✅ Tickets: RHDHBUGS-3121
✅ Compliance rules (platform): 18 rules

Grey Divider


Action required

1. jq capture field misuse ✓ Resolved 🐞 Bug ≡ Correctness
Description
mirror_catalog_index/mirror_plugins_from_dir use sub(...; $target_reg + "/" + .last2) where
.value.registryReference is a string, so .last2 attempts to index a string and causes jq to
fail, aborting index.json rewriting. This breaks catalog index mirroring whenever those code paths
run.
Code

.rhdh/scripts/mirror-plugins.sh[500]

+            . | sub("^[^/]+(/[^/]+)*/(?<last2>[^/]+/[^/]+)$"; $target_reg + "/" + .last2)
Relevance

⭐⭐⭐ High

Team has previously accepted mirror-plugins.sh jq correctness fixes and hard-fail handling around jq
output.

PR-#1844
PR-#2294

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The script extracts .registryReference values as raw strings from index.json, then the new jq uses
.last2 in the replacement expression; since the input is a string, .last2 is not a valid capture
reference and will cause jq to error, triggering the script’s failure check.

.rhdh/scripts/mirror-plugins.sh[374-383]
.rhdh/scripts/mirror-plugins.sh[490-509]
.rhdh/scripts/mirror-plugins.sh[955-972]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The jq replacement uses `.last2` as if it were a named-regex-capture, but the input to the replacement expression is still the original string. This results in a jq runtime error (cannot index a string with `last2`) and the script fails to update `index.json`.

### Issue Context
The script reads `.registryReference` as a string from `index.json`, then tries to rewrite it using `sub(...)` with a named capture group. jq does not expose named captures as properties on the input string.

### Fix
Update the jq transform to explicitly compute `last2` (or explicitly use `capture(...)`) before building the replacement string.

One safe pattern is:
```jq
.value.registryReference |= (
 if . then
   (try capture("^[^/]+(/[^/]+)*/(?<last2>[^/]+/[^/]+)$") catch null) as $m
   | if $m then $target_reg + "/" + $m.last2 else . end
 else . end
)
```
Apply the same fix in both jq blocks.

### Fix Focus Areas
- .rhdh/scripts/mirror-plugins.sh[490-509]
- .rhdh/scripts/mirror-plugins.sh[955-972]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

2. OCI path segments truncated 🐞 Bug ≡ Correctness
Description
The new jq/sed rewrite logic preserves only the last two path elements, so OCI refs with deeper
repository paths (e.g., oci://ghcr.io/redhat-developer/rhdh-plugin-export-overlays/<image>:tag)
are rewritten with missing namespace segments. This silently corrupts rewritten plugin image
locations and can break downstream pulls.
Code

.rhdh/scripts/mirror-plugins.sh[522]

+      sed -i -E "s|oci://[^/]+(/[^/]+)*(/[^/]+/[^[:space:]\"']+)|oci://$target_registry\2|g" "$catalog_data_dir/dynamic-plugins.default.yaml"
Relevance

⭐ Low

Repo previously rejected requests to avoid “last two path elements” flattening; truncation risk
appears accepted by design.

PR-#2434

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The sed regex captures and reuses only the last two slash-separated path components, which would
drop earlier namespace segments. The repo contains real-world dynamic plugin OCI refs with three
segments after the registry host (ghcr.io/redhat-developer/rhdh-plugin-export-overlays/...), which
this logic would rewrite incorrectly by removing redhat-developer.

.rhdh/scripts/mirror-plugins.sh[516-534]
examples/rhdh-cr-with-app-configs.yaml[95-116]
.rhdh/scripts/mirror-plugins.sh[955-990]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The updated sed/jq regexes intentionally keep only the last two repository segments ("org/image"), but OCI references can legitimately have more than two path segments after the registry host (e.g., `ghcr.io/org/repo/image:tag`). The rewrite drops earlier segments and produces incorrect target image references.

### Issue Context
The goal is idempotency when `--to-registry` contains a path prefix, but idempotency can be achieved without truncating the repository path by skipping rewrite when the reference already starts with the target registry.

### Fix
**jq:** preserve the entire repository path (everything after the first `/`) and add an idempotency guard:
```jq
.value.registryReference |= (
 if . then
   if startswith($target_reg + "/") then .
   else sub("^[^/]+/"; $target_reg + "/")
   end
 else . end
)
```

**sed:** similarly, only rewrite lines not already starting with `oci://$target_registry/` (you’ll need to escape the variable for the sed address regex):
- Compute an escaped regex form of `$target_registry` (at least escape `/`, `.`, `[` etc.), then use an address negation:
 - `/^.../! s|oci://[^/]+/|oci://$target_registry/|g`
This keeps all repository path segments intact and remains idempotent.

### Fix Focus Areas
- .rhdh/scripts/mirror-plugins.sh[493-506]
- .rhdh/scripts/mirror-plugins.sh[516-534]
- .rhdh/scripts/mirror-plugins.sh[959-970]
- .rhdh/scripts/mirror-plugins.sh[977-992]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

@Fortune-Ndlovu

Copy link
Copy Markdown
Member Author

cc/ @rm3l

Comment thread .rhdh/scripts/mirror-plugins.sh Outdated
@Fortune-Ndlovu

Copy link
Copy Markdown
Member Author

/agentic_review

@rhdh-qodo-merge

rhdh-qodo-merge Bot commented Jun 9, 2026

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 410b734

Signed-off-by: Fortune-Ndlovu <fndlovu@redhat.com>
@sonarqubecloud

sonarqubecloud Bot commented Jun 9, 2026

Copy link
Copy Markdown

# Use jq to update all registryReference values
# Replace the registry domain (first component before /) with target registry
# Replace the full registry path (hostname + any path prefix) with target registry,
# preserving only the last two path elements (org/image) and tag/digest

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Unlikely to happen with our current catalog index images, but what if we have a ref with no org (like just registry.example.com/plugin:1.9). I suspect this may not work as expected. Anyway, I guess it is fine as it might be an edge case.

@openshift-ci openshift-ci Bot added the lgtm label Jun 9, 2026
@openshift-merge-bot openshift-merge-bot Bot merged commit 43f3396 into redhat-developer:release-1.9 Jun 9, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants