Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions docs/dynamic-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,19 +84,51 @@ The extraction directory can be configured via the `CATALOG_ENTITIES_EXTRACT_DIR

More details in [Catalog Entities Extraction](https://github.com/redhat-developer/rhdh/blob/main/docs/dynamic-plugins/installing-plugins.md#catalog-entities-extraction).

## Supported Package URL Formats

| Format | Type | Description |
|--------|------|-------------|
| `ref://plugin-name` | Catalog reference | Look up plugin by name, returns full package URL |
| `oci://...{{inherit}}` | Catalog reference | Look up plugin by name, returns full package URL |
| `oci://...` | Direct link | OCI image reference (no resolution) |
| `https://...` | Direct link | HTTPS URL to plugin archive |
| `http://...` | Direct link | HTTP URL to plugin archive |
| `./path` | Direct link | Local filesystem path |

## Plugin URL References

The operator supports special URL reference syntax in plugin package URLs, allowing users to reference versions or plugins from the default configuration.
The operator optionally supports special URL reference syntax in plugin package URLs, allowing users to reference plugins from the default configuration by name.

TODO: document Operator Dynamic Plugins processing mode

**Operator behavior:**
- The operator resolves all references during ConfigMap merge (before passing to the init container)
- If a reference cannot be resolved, the operator returns an error and the Backstage CR will not reconcile
- Both reference types use **name-based matching** - only the plugin name matters for lookup

### Ref Reference (`ref://`)

Look up a plugin by name and use its full package URL from the default configuration.

```yaml
plugins:
- package: "ref://backstage-plugin-catalog"
pluginConfig:
# your config overrides
```
Comment thread
gazarenkov marked this conversation as resolved.

### Inherit Reference (`:{{inherit}}`)

Allows inheriting version (tag or digest) from default plugins. Useful when overriding plugin settings without hardcoding versions.
Look up a plugin by name and use its full package URL from the default configuration. The registry/path in your URL is ignored - only the plugin name matters for matching.

```yaml
plugins:
# These all match the same base plugin (backstage-plugin-catalog):
- package: "oci://quay.io/rhdh/backstage-plugin-catalog:{{inherit}}"
- package: "oci://any-registry/path/backstage-plugin-catalog:{{inherit}}"
```

For syntax details and examples, see [OCI Package Version Inheritance](https://github.com/redhat-developer/rhdh/blob/main/docs/dynamic-plugins/installing-plugins.md#oci-package-version-inheritance).
**Since v0.11.0:** Both `ref://` and `:{{inherit}}` use name-based matching (plugin name only, registry/path ignored). This behavior is slightly different from what is described in [OCI Package Version Inheritance](https://github.com/redhat-developer/rhdh/blob/main/docs/dynamic-plugins/installing-plugins.md#oci-package-version-inheritance) which documents the RHDH init-container behavior (full URL matching).

## Dynamic plugins dependency management

Expand Down
18 changes: 18 additions & 0 deletions examples/dyna-plugins.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: dynamic-plugins
data:
dynamic-plugins.yaml: |
plugins:
- package: 'ref://backstage-community-plugin-catalog-backend-module-keycloak-dynamic'
enabled: true
---
apiVersion: rhdh.redhat.com/v1alpha5
kind: Backstage
metadata:
name: bs1
spec:
application:
dynamicPluginsConfigMapName: dynamic-plugins

2 changes: 1 addition & 1 deletion pkg/model/default-config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ func TestMergeDynamicPluginsFunction(t *testing.T) {
// Find plugin-b to verify it was overridden
var pluginB *DynaPlugin
for i := range config.Plugins {
if config.Plugins[i].Package == "plugin-b" {
if config.Plugins[i].Package == "./plugin-b" {
pluginB = &config.Plugins[i]
break
}
Expand Down
242 changes: 175 additions & 67 deletions pkg/model/dynamic-plugins-reference.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,29 +7,54 @@

const inheritSuffix = ":{{inherit}}"
const refPrefix = "ref://"
const ociPrefix = "oci://"
const httpsPrefix = "https://"
const httpPrefix = "http://"
const localPrefix = "./"

// resolveReferences resolves all reference types in plugin package URLs.
// Currently supports:
// - {{inherit}}: inherits version/digest from base plugins
// - ref://: references another plugin by name (TODO)
//
// Supported package URL formats:
//
// Catalog references (resolved by plugin name lookup):
// - ref://plugin-name: Returns full package URL from base plugins matching by name.
// Example: ref://backstage-plugin-catalog → oci://quay.io/rhdh/backstage-plugin-catalog@sha256:abc123
// - oci://...{{inherit}}: Inherits version/digest from base plugins matching by name.
// The registry/path in the user URL is ignored - only the plugin name matters.
// Example: oci://any-registry/backstage-plugin-catalog:{{inherit}} → oci://quay.io/rhdh/backstage-plugin-catalog@sha256:abc123
// User can override !plugin-path: oci://x/plugin:{{inherit}}!custom-path → uses custom-path instead of base's path
//
// Direct links (no resolution needed):
// - oci://...: OCI image reference
// - https://...: HTTPS URL to plugin archive
// - http://...: HTTP URL to plugin archive
// - ./path: Local filesystem path
//
// Any other prefix returns an error.
func resolveReferences(plugins []DynaPlugin, basePlugins []DynaPlugin) ([]DynaPlugin, error) {
resolved := make([]DynaPlugin, len(plugins))
copy(resolved, plugins)

// Build lookup map for base plugins (used by inherit resolver)
baseURLMap := buildBaseURLMap(basePlugins)

for i := range resolved {
plugin := &resolved[i]
if plugin.Package == "" {
continue
}

var err error

switch {
case strings.Contains(plugin.Package, inheritSuffix):
resolved[i].Package, err = resolveInheritReference(plugin.Package, baseURLMap)
case strings.HasPrefix(plugin.Package, refPrefix):
// Catalog search by name
resolved[i].Package, err = resolveRefReference(plugin.Package, basePlugins)
default:
case strings.Contains(plugin.Package, inheritSuffix):
// Catalog search by name, inherit version/digest
resolved[i].Package, err = resolveInheritReference(plugin.Package, basePlugins)
case plugin.IsDirectLink():
// Direct link - no resolution needed
continue
default:
return nil, fmt.Errorf("unsupported package URL format %q: must start with oci://, https://, http://, ./ or use ref:// for catalog lookup", plugin.Package)
}

if err != nil {
Expand All @@ -40,94 +65,177 @@
return resolved, nil
}

// buildBaseURLMap creates a lookup map from base URL to full package URL.
func buildBaseURLMap(basePlugins []DynaPlugin) map[string]string {
baseURLMap := make(map[string]string)
for i := range basePlugins {
plugin := &basePlugins[i]
if plugin.Package == "" {
continue
}
baseURL := plugin.BaseURL()
if baseURL != "" {
baseURLMap[baseURL] = plugin.Package
}
}
return baseURLMap
// IsDirectLink returns true if the package URL is a direct link that doesn't need resolution.
func (p *DynaPlugin) IsDirectLink() bool {
return strings.HasPrefix(p.Package, ociPrefix) ||
strings.HasPrefix(p.Package, httpsPrefix) ||
strings.HasPrefix(p.Package, httpPrefix) ||
strings.HasPrefix(p.Package, localPrefix)
}

// resolveInheritReference resolves a single {{inherit}} reference.
// For example, oci://registry/plugin:{{inherit}} will be replaced with
// oci://registry/plugin@sha256:abc123 if found in baseURLMap.
func resolveInheritReference(packageURL string, baseURLMap map[string]string) (string, error) {
// resolveInheritReference resolves a single {{inherit}} reference by looking up plugin by name.
// The registry and path in the user's URL are ignored - only the plugin name (last path component) matters.
//
// Examples:
// - oci://any-registry/path/plugin-foo:{{inherit}} matches base plugin oci://quay.io/rhdh/plugin-foo@sha256:abc
// - oci://x/plugin-foo:{{inherit}}!custom-path uses base's version but user's plugin-path
func resolveInheritReference(packageURL string, basePlugins []DynaPlugin) (string, error) {
// Parse package to extract !plugin-path suffix if present
var pluginPath string
if idx := strings.LastIndex(packageURL, "!"); idx != -1 {
pluginPath = packageURL[idx:] // includes "!"
packageURL = packageURL[:idx]
}

// Extract base URL (strip :{{inherit}})
baseURL := strings.Replace(packageURL, inheritSuffix, "", 1)
// Extract plugin name from the package URL (strip :{{inherit}} first)
tempPackage := strings.Replace(packageURL, inheritSuffix, "", 1)
tempPlugin := DynaPlugin{Package: tempPackage}
pluginName := tempPlugin.Name()

// Look up the full URL in basePlugins
fullURL, found := baseURLMap[baseURL]
if !found {
return "", fmt.Errorf("cannot resolve {{inherit}} reference: no matching plugin found for base URL %q in default plugins", baseURL)
if pluginName == "" {
return "", fmt.Errorf("cannot resolve {{inherit}} reference: unable to extract plugin name from %q", packageURL)
}

// If user specified !plugin-path, use it; otherwise use full default URL
if pluginPath != "" {
// Extract image part from default (without !plugin-path)
if idx := strings.LastIndex(fullURL, "!"); idx != -1 {
fullURL = fullURL[:idx]
// Look up the plugin by name in basePlugins
for i := range basePlugins {
plugin := &basePlugins[i]
if plugin.Package == "" {
continue
}
if plugin.Name() == pluginName {
fullURL := plugin.Package

// If user specified !plugin-path, use it; otherwise use full default URL
if pluginPath != "" {
// Extract image part from default (without !plugin-path)
if idx := strings.LastIndex(fullURL, "!"); idx != -1 {
fullURL = fullURL[:idx]
}
return fullURL + pluginPath, nil
}

return fullURL, nil
}
return fullURL + pluginPath, nil
}

return fullURL, nil
return "", fmt.Errorf("cannot resolve {{inherit}} reference: no plugin named %q found in default plugins", pluginName)
}

// resolveRefReference resolves a ref:// reference by looking up plugin by name.
// For example, ref://my-plugin will be replaced with the full package URL
// of the plugin named "my-plugin" in basePlugins.
// Returns the full package URL from basePlugins for the plugin with matching name.
//
// Example: ref://backstage-plugin-catalog → oci://quay.io/rhdh/backstage-plugin-catalog@sha256:abc123
func resolveRefReference(packageURL string, basePlugins []DynaPlugin) (string, error) {
// TODO: implement ref:// resolution
// ref://<plugin-name> should look up the plugin by name in basePlugins
return "", fmt.Errorf("ref:// references are not yet implemented: %s", packageURL)
// Extract the plugin name from ref://<plugin-name>
refName := strings.TrimPrefix(packageURL, refPrefix)
if refName == "" {
return "", fmt.Errorf("invalid ref:// reference: empty plugin name in %q", packageURL)
}

// Look up the plugin by name in basePlugins
for i := range basePlugins {
plugin := &basePlugins[i]
if plugin.Package == "" {
continue
}
if plugin.Name() == refName {
return plugin.Package, nil
}
}

return "", fmt.Errorf("cannot resolve ref:// reference: no plugin named %q found in default plugins", refName)
}

// BaseURL extracts the base URL from a plugin package URL
// by removing the tag or digest suffix.
// Name extracts the plugin name from the package URL.
// For example:
// - oci://registry/plugin:tag -> oci://registry/plugin
// - oci://registry/plugin@sha256:abc -> oci://registry/plugin
// - ./local/path -> ./local/path (unchanged)
func (p *DynaPlugin) BaseURL() string {
// - oci://quay.io/rhdh/backstage-plugin-techdocs@sha256:abc -> backstage-plugin-techdocs
// - oci://quay.io/rhdh/backstage-plugin-techdocs:1.0.0 -> backstage-plugin-techdocs
// - https://example.com/path/backstage-plugin-foo-1.0.0.tgz -> backstage-plugin-foo
// - ./dynamic-plugins/dist/backstage-plugin-techdocs -> backstage-plugin-techdocs
func (p *DynaPlugin) Name() string {

Check failure on line 155 in pkg/model/dynamic-plugins-reference.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 19 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=redhat-developer_rhdh-operator&issues=AZ9va3i7mMstw9MISSKR&open=AZ9va3i7mMstw9MISSKR&pullRequest=3215
packageURL := p.Package

// Only process OCI URLs
if !strings.HasPrefix(packageURL, "oci://") {
return packageURL
}

// Strip !plugin-path suffix first if present
// Strip !plugin-path suffix if present
if idx := strings.LastIndex(packageURL, "!"); idx != -1 {
packageURL = packageURL[:idx]
}

// Handle OCI URLs with digest (@sha256:...)
if idx := strings.LastIndex(packageURL, "@"); idx != -1 {
return packageURL[:idx]
// Handle OCI URLs
if strings.HasPrefix(packageURL, ociPrefix) {
// Remove oci:// prefix
url := strings.TrimPrefix(packageURL, ociPrefix)

// Remove digest (@sha256:...)
if idx := strings.LastIndex(url, "@"); idx != -1 {
url = url[:idx]
}

// Require a path component (must have "/") - registry-only URLs are invalid
idx := strings.LastIndex(url, "/")
if idx == -1 {
return ""
}

// Extract the last path component (the image name, possibly with tag)
imageName := url[idx+1:]
if imageName == "" {
return ""
}

// Remove tag (:tag) from image name only (not port from registry)
if idx := strings.LastIndex(imageName, ":"); idx != -1 {
imageName = imageName[:idx]
}

return imageName
}

// Handle OCI URLs with tag (:tag)
schemeEnd := len("oci://")
rest := packageURL[schemeEnd:]
if idx := strings.LastIndex(rest, ":"); idx != -1 {
return packageURL[:schemeEnd+idx]
// Handle HTTP(S) URLs
if strings.HasPrefix(packageURL, httpsPrefix) || strings.HasPrefix(packageURL, httpPrefix) {
// Remove scheme
url := strings.TrimPrefix(packageURL, httpsPrefix)
url = strings.TrimPrefix(url, httpPrefix)

// Extract the last path component
if idx := strings.LastIndex(url, "/"); idx != -1 {
url = url[idx+1:]
}

// Strip query string if present
if idx := strings.Index(url, "?"); idx != -1 {
url = url[:idx]
}

// Strip common archive extensions
url = strings.TrimSuffix(url, ".tgz")
url = strings.TrimSuffix(url, ".tar.gz")

// Strip version suffix (e.g., -1.0.0, -1.2.3-beta)
url = stripVersionSuffix(url)

return url
}

// Handle local paths (./path/to/plugin-name)
if strings.HasPrefix(packageURL, localPrefix) {
if idx := strings.LastIndex(packageURL, "/"); idx != -1 {
return packageURL[idx+1:]
}
return packageURL
}

// No tag or digest found
return packageURL
// Unknown protocol - return empty string
return ""
}

// stripVersionSuffix removes a trailing version suffix from a plugin name.
// For example: backstage-plugin-foo-1.0.0 -> backstage-plugin-foo
Comment thread
gazarenkov marked this conversation as resolved.
func stripVersionSuffix(name string) string {
// Look for pattern: -<digit> which typically starts a version
for i := len(name) - 1; i >= 0; i-- {
if name[i] == '-' && i+1 < len(name) && name[i+1] >= '0' && name[i+1] <= '9' {
return name[:i]
}
}
return name
}
Loading
Loading