Skip to content

Commit 8ba1f36

Browse files
committed
Add multi-URL support and per-resolution url param to Hub Resolver
Implements per-resolution parameter for explicit hub selection and ConfigMap-based URL lists with ordered fallback for cluster-level defaults. - Add param for per-resolution hub endpoint override - Add / ConfigMap keys for ordered URL lists with first-success-wins fallback - Use TrimRight for trailing slash stripping on ConfigMap URLs - Add unit tests for fallback, url override, error formatting - Add e2e tests for url param and version constraint resolution - Update docs with new param, ConfigMap options, and URL precedence Signed-off-by: ab-ghosh <abghosh@redhat.com>
1 parent 4648b3e commit 8ba1f36

File tree

7 files changed

+1254
-35
lines changed

7 files changed

+1254
-35
lines changed

config/resolvers/hubresolver-config.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,12 @@ data:
3232
default-kind: "task"
3333
# the default hub source to pull the resource from.
3434
default-type: "artifact"
35+
# Ordered list of Artifact Hub API URLs to try. First successful response wins.
36+
# If not set, the ARTIFACT_HUB_API env var or default (https://artifacthub.io) is used.
37+
# artifact-hub-urls: |
38+
# - https://internal-hub.example.com/
39+
# - https://artifacthub.io/
40+
# Ordered list of Tekton Hub API URLs to try. First successful response wins.
41+
# If not set, the TEKTON_HUB_API env var is used.
42+
# tekton-hub-urls: |
43+
# - https://api.hub.tekton.dev/

docs/hub-resolver.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Use resolver type `hub`.
1818
| `kind` | Either `task` or `pipeline` (Optional) | Default: `task` |
1919
| `name` | The name of the task or pipeline to fetch from the hub | `golang-build` |
2020
| `version` | Version or a Constraint (see [below](#version-constraint) of a task or a pipeline to pull in from. Wrap the number in quotes! | `"0.5.0"`, `">= 0.5.0"` |
21+
| `url` | Custom hub API endpoint to query instead of the cluster-configured default (Optional). Must be an absolute HTTP or HTTPS URL. Overrides all other URL configuration (ConfigMap URL lists, environment variables, and defaults). | `https://internal-hub.example.com` |
2122

2223
The Catalogs in the Artifact Hub follows the semVer (i.e.` <major-version>.<minor-version>.0`) and the Catalogs in the Tekton Hub follows the simplified semVer (i.e. `<major-version>.<minor-version>`). Both full and simplified semantic versioning will be accepted by the `version` parameter. The Hub Resolver will map the version to the format expected by the target Hub `type`.
2324

@@ -44,6 +45,8 @@ for the name, namespace and defaults that the resolver ships with.
4445
| `default-artifact-hub-pipeline-catalog`| The default artifact hub catalog from where to pull the resource for pipeline kind. | `tekton-catalog-pipelines` |
4546
| `default-kind` | The default object kind for references. | `task`, `pipeline` |
4647
| `default-type` | The default hub from where to pull the resource. | `artifact`, `tekton` |
48+
| `artifact-hub-urls` | Ordered YAML list of Artifact Hub API URLs to try. First successful response wins. If not set, the `ARTIFACT_HUB_API` env var or default is used. | See [below](#configuring-multiple-hub-urls) |
49+
| `tekton-hub-urls` | Ordered YAML list of Tekton Hub API URLs to try. First successful response wins. If not set, the `TEKTON_HUB_API` env var is used. | See [below](#configuring-multiple-hub-urls) |
4750

4851
### Configuring the Hub API endpoint
4952

@@ -128,6 +131,70 @@ spec:
128131
# overall will not succeed without those parameters.
129132
```
130133

134+
### Task Resolution from a Private Hub
135+
136+
```yaml
137+
apiVersion: tekton.dev/v1beta1
138+
kind: TaskRun
139+
metadata:
140+
name: private-hub-task-reference
141+
spec:
142+
taskRef:
143+
resolver: hub
144+
params:
145+
- name: url
146+
value: https://internal-hub.example.com
147+
- name: catalog
148+
value: my-team-catalog
149+
- name: type
150+
value: artifact
151+
- name: kind
152+
value: task
153+
- name: name
154+
value: my-task
155+
- name: version
156+
value: "1.0.0"
157+
```
158+
159+
When the `url` parameter is not specified, the resolver falls back to the
160+
cluster-configured defaults: first checking the ConfigMap URL lists
161+
(`artifact-hub-urls` / `tekton-hub-urls`), then the environment variables
162+
(`ARTIFACT_HUB_API` / `TEKTON_HUB_API`). This allows Pipeline authors to
163+
explicitly choose which hub instance to query on a per-resolution basis, which
164+
is useful in multi-team environments or air-gapped deployments where resources
165+
are hosted on private hub instances.
166+
167+
### Configuring Multiple Hub URLs
168+
169+
You can configure multiple hub API URLs in the `hubresolver-config` ConfigMap.
170+
The resolver will try each URL in order and return the first successful result.
171+
This is useful for layered catalogs, multi-team environments, or air-gapped
172+
deployments where resources may exist on different hub instances.
173+
174+
```yaml
175+
apiVersion: v1
176+
kind: ConfigMap
177+
metadata:
178+
name: hubresolver-config
179+
namespace: tekton-pipelines-resolvers
180+
data:
181+
artifact-hub-urls: |
182+
- https://internal-hub.example.com/
183+
- https://artifacthub.io/
184+
tekton-hub-urls: |
185+
- https://internal-tekton-hub.example.com/
186+
- https://api.hub.tekton.dev/
187+
```
188+
189+
**URL precedence** (highest to lowest):
190+
1. Per-resolution `url` parameter — single URL override for a specific resolution request
191+
2. ConfigMap URL lists (`artifact-hub-urls` / `tekton-hub-urls`) — tried in order, first success wins
192+
3. Environment variable (`ARTIFACT_HUB_API` / `TEKTON_HUB_API`) — single URL fallback
193+
4. Default URL (`https://artifacthub.io` for artifact type)
194+
195+
If the ConfigMap URL list keys are not set (commented out by default), the
196+
resolver behaves exactly as before, using the environment variable or default URL.
197+
131198
### Version constraint
132199

133200
Instead of a version you can specify a constraint to choose from. The constraint is a string as documented in the [go-version](https://github.com/hashicorp/go-version) library.

pkg/resolution/resolver/hub/config.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ limitations under the License.
1616

1717
package hub
1818

19+
import (
20+
"fmt"
21+
"strings"
22+
23+
"sigs.k8s.io/yaml"
24+
)
25+
1926
// ConfigTektonHubCatalog is the configuration field name for controlling
2027
// the Tekton Hub catalog to fetch the remote resource from.
2128
const ConfigTektonHubCatalog = "default-tekton-hub-catalog"
@@ -35,3 +42,31 @@ const ConfigKind = "default-kind"
3542
// ConfigType is the configuration field name for controlling
3643
// the hub type to pull the resource from.
3744
const ConfigType = "default-type"
45+
46+
// ConfigArtifactHubURLs is the configuration field name for controlling
47+
// the Artifact Hub API URLs to fetch remote resources from.
48+
// Value is a YAML list of URLs, tried in order; first success wins.
49+
const ConfigArtifactHubURLs = "artifact-hub-urls"
50+
51+
// ConfigTektonHubURLs is the configuration field name for controlling
52+
// the Tekton Hub API URLs to fetch remote resources from.
53+
// Value is a YAML list of URLs, tried in order; first success wins.
54+
const ConfigTektonHubURLs = "tekton-hub-urls"
55+
56+
// parseURLList parses a YAML list string from a ConfigMap value into
57+
// a slice of URL strings. Each URL is trimmed of whitespace and trailing slashes.
58+
// Returns nil if the input is empty or not a valid YAML list.
59+
func parseURLList(yamlList string) ([]string, error) {
60+
yamlList = strings.TrimSpace(yamlList)
61+
if yamlList == "" {
62+
return nil, nil
63+
}
64+
var urls []string
65+
if err := yaml.Unmarshal([]byte(yamlList), &urls); err != nil {
66+
return nil, fmt.Errorf("failed to parse URL list: %w", err)
67+
}
68+
for i, u := range urls {
69+
urls[i] = strings.TrimRight(strings.TrimSpace(u), "/")
70+
}
71+
return urls, nil
72+
}

pkg/resolution/resolver/hub/params.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,9 @@ const ParamCatalog = "catalog"
4848

4949
// ParamType is the parameter defining what the hub type to pull the resource from.
5050
const ParamType = "type"
51+
52+
// ParamURL is the parameter defining a custom hub API endpoint to use
53+
// instead of the cluster-configured default. When specified, it overrides
54+
// the ARTIFACT_HUB_API or TEKTON_HUB_API environment variable based on the
55+
// resolution type.
56+
const ParamURL = resource.ParamURL

pkg/resolution/resolver/hub/resolver.go

Lines changed: 129 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"fmt"
2323
"io"
2424
"net/http"
25+
"net/url"
2526
"slices"
2627
"strings"
2728

@@ -135,12 +136,34 @@ func Resolve(ctx context.Context, params []pipelinev1.Param, tektonHubURL, artif
135136
return nil, fmt.Errorf("failed to validate params: %w", err)
136137
}
137138

139+
// Determine ordered list of hub URLs to try.
140+
// Precedence: url param (Option A) > ConfigMap YAML list (Option B) > env var URL.
141+
var urls []string
142+
if paramURL := paramsMap[ParamURL]; paramURL != "" {
143+
// Per-resolution URL override takes highest precedence.
144+
urls = []string{strings.TrimSuffix(paramURL, "/")}
145+
} else {
146+
conf := framework.GetResolverConfigFromContext(ctx)
147+
switch paramsMap[ParamType] {
148+
case ArtifactHubType:
149+
urls = resolveHubURLs(conf, artifactHubURL, ConfigArtifactHubURLs)
150+
case TektonHubType:
151+
urls = resolveHubURLs(conf, tektonHubURL, ConfigTektonHubURLs)
152+
}
153+
}
154+
155+
if len(urls) == 0 {
156+
return nil, fmt.Errorf("no hub URL configured for type %s", paramsMap[ParamType])
157+
}
158+
138159
if constraint, err := goversion.NewConstraint(paramsMap[ParamVersion]); err == nil {
139-
chosen, err := resolveVersionConstraint(ctx, paramsMap, constraint, artifactHubURL, tektonHubURL)
160+
chosen, constraintURL, err := resolveVersionConstraintWithFallback(ctx, paramsMap, constraint, urls)
140161
if err != nil {
141162
return nil, err
142163
}
143164
paramsMap[ParamVersion] = chosen.String()
165+
// Pin subsequent fetch to the same hub that satisfied the constraint.
166+
urls = []string{constraintURL}
144167
}
145168

146169
resVer, err := resolveVersion(paramsMap[ParamVersion], paramsMap[ParamType])
@@ -149,33 +172,7 @@ func Resolve(ctx context.Context, params []pipelinev1.Param, tektonHubURL, artif
149172
}
150173
paramsMap[ParamVersion] = resVer
151174

152-
// call hub API
153-
switch paramsMap[ParamType] {
154-
case ArtifactHubType:
155-
url := fmt.Sprintf(fmt.Sprintf("%s/%s", artifactHubURL, ArtifactHubYamlEndpoint),
156-
paramsMap[ParamKind], paramsMap[ParamCatalog], paramsMap[ParamName], paramsMap[ParamVersion])
157-
resp := artifactHubResponse{}
158-
if err := fetchHubResource(ctx, url, &resp); err != nil {
159-
return nil, fmt.Errorf("fail to fetch Artifact Hub resource: %w", err)
160-
}
161-
return &ResolvedHubResource{
162-
URL: url,
163-
Content: []byte(resp.Data.YAML),
164-
}, nil
165-
case TektonHubType:
166-
url := fmt.Sprintf(fmt.Sprintf("%s/%s", tektonHubURL, TektonHubYamlEndpoint),
167-
paramsMap[ParamCatalog], paramsMap[ParamKind], paramsMap[ParamName], paramsMap[ParamVersion])
168-
resp := tektonHubResponse{}
169-
if err := fetchHubResource(ctx, url, &resp); err != nil {
170-
return nil, fmt.Errorf("fail to fetch Tekton Hub resource: %w", err)
171-
}
172-
return &ResolvedHubResource{
173-
URL: url,
174-
Content: []byte(resp.Data.YAML),
175-
}, nil
176-
}
177-
178-
return nil, fmt.Errorf("hub resolver type: %s is not supported", paramsMap[ParamType])
175+
return fetchResourceWithFallback(ctx, paramsMap, urls)
179176
}
180177

181178
// ResolvedHubResource wraps the data we want to return to Pipelines
@@ -367,13 +364,29 @@ func validateParams(ctx context.Context, paramsMap map[string]string, tektonHubU
367364
return fmt.Errorf("kind param must be one of: %s", strings.Join(supportedKinds, ", "))
368365
}
369366
}
367+
368+
if paramURL, ok := paramsMap[ParamURL]; ok && paramURL != "" {
369+
u, err := url.ParseRequestURI(paramURL)
370+
if err != nil || u.Scheme == "" || u.Host == "" {
371+
return fmt.Errorf("url param must be a valid absolute URL: %s", paramURL)
372+
}
373+
if u.Scheme != "http" && u.Scheme != "https" {
374+
return fmt.Errorf("url param must be a valid http(s) URL: %s", paramURL)
375+
}
376+
}
377+
378+
hasURLOverride := paramsMap[ParamURL] != ""
370379
if hubType, ok := paramsMap[ParamType]; ok {
371380
if hubType != ArtifactHubType && hubType != TektonHubType {
372381
return fmt.Errorf("type param must be %s or %s", ArtifactHubType, TektonHubType)
373382
}
374383

375-
if hubType == TektonHubType && tektonHubURL == "" {
376-
return errors.New("please configure TEKTON_HUB_API env variable to use tekton type")
384+
if hubType == TektonHubType && tektonHubURL == "" && !hasURLOverride {
385+
conf := framework.GetResolverConfigFromContext(ctx)
386+
configURLs, _ := parseURLList(conf[ConfigTektonHubURLs])
387+
if len(configURLs) == 0 {
388+
return errors.New("please configure TEKTON_HUB_API env variable to use tekton type")
389+
}
377390
}
378391
}
379392

@@ -384,10 +397,73 @@ func validateParams(ctx context.Context, paramsMap map[string]string, tektonHubU
384397
return nil
385398
}
386399

387-
func resolveVersionConstraint(ctx context.Context, paramsMap map[string]string, constraint goversion.Constraints, artifactHubURL, tektonHubURL string) (*goversion.Version, error) {
400+
// resolveHubURLs returns the ordered list of hub URLs to try.
401+
// Precedence: ConfigMap YAML list > env var URL.
402+
func resolveHubURLs(conf map[string]string, envVarURL, configKey string) []string {
403+
if yamlStr, ok := conf[configKey]; ok && strings.TrimSpace(yamlStr) != "" {
404+
urls, err := parseURLList(yamlStr)
405+
if err == nil && len(urls) > 0 {
406+
return urls
407+
}
408+
}
409+
if envVarURL != "" {
410+
return []string{envVarURL}
411+
}
412+
return nil
413+
}
414+
415+
// fetchResourceFromURL fetches a resource from a single hub URL.
416+
func fetchResourceFromURL(ctx context.Context, paramsMap map[string]string, baseURL string) (framework.ResolvedResource, error) {
417+
switch paramsMap[ParamType] {
418+
case ArtifactHubType:
419+
url := fmt.Sprintf(fmt.Sprintf("%s/%s", baseURL, ArtifactHubYamlEndpoint),
420+
paramsMap[ParamKind], paramsMap[ParamCatalog], paramsMap[ParamName], paramsMap[ParamVersion])
421+
resp := artifactHubResponse{}
422+
if err := fetchHubResource(ctx, url, &resp); err != nil {
423+
return nil, fmt.Errorf("fail to fetch Artifact Hub resource: %w", err)
424+
}
425+
return &ResolvedHubResource{
426+
URL: url,
427+
Content: []byte(resp.Data.YAML),
428+
}, nil
429+
case TektonHubType:
430+
url := fmt.Sprintf(fmt.Sprintf("%s/%s", baseURL, TektonHubYamlEndpoint),
431+
paramsMap[ParamCatalog], paramsMap[ParamKind], paramsMap[ParamName], paramsMap[ParamVersion])
432+
resp := tektonHubResponse{}
433+
if err := fetchHubResource(ctx, url, &resp); err != nil {
434+
return nil, fmt.Errorf("fail to fetch Tekton Hub resource: %w", err)
435+
}
436+
return &ResolvedHubResource{
437+
URL: url,
438+
Content: []byte(resp.Data.YAML),
439+
}, nil
440+
}
441+
return nil, fmt.Errorf("hub resolver type: %s is not supported", paramsMap[ParamType])
442+
}
443+
444+
// fetchResourceWithFallback tries each URL in order, returns the first successful result.
445+
// When there is only one URL, error messages are preserved exactly as before.
446+
func fetchResourceWithFallback(ctx context.Context, paramsMap map[string]string, urls []string) (framework.ResolvedResource, error) {
447+
var errs []error
448+
for _, baseURL := range urls {
449+
result, err := fetchResourceFromURL(ctx, paramsMap, baseURL)
450+
if err != nil {
451+
errs = append(errs, err)
452+
continue
453+
}
454+
return result, nil
455+
}
456+
if len(errs) == 1 {
457+
return nil, errs[0]
458+
}
459+
return nil, fmt.Errorf("failed to fetch resource from any configured hub URL: %w", errors.Join(errs...))
460+
}
461+
462+
// resolveVersionConstraintFromURL resolves a version constraint from a single hub URL.
463+
func resolveVersionConstraintFromURL(ctx context.Context, paramsMap map[string]string, constraint goversion.Constraints, baseURL string) (*goversion.Version, error) {
388464
var ret *goversion.Version
389465
if paramsMap[ParamType] == ArtifactHubType {
390-
allVersionsURL := fmt.Sprintf("%s/%s", artifactHubURL, fmt.Sprintf(
466+
allVersionsURL := fmt.Sprintf("%s/%s", baseURL, fmt.Sprintf(
391467
ArtifactHubListTasksEndpoint,
392468
paramsMap[ParamKind], paramsMap[ParamCatalog], paramsMap[ParamName]))
393469
resp := artifactHubListResult{}
@@ -409,12 +485,11 @@ func resolveVersionConstraint(ctx context.Context, paramsMap map[string]string,
409485
if ret != nil && ret.GreaterThan(checkV) {
410486
continue
411487
}
412-
// TODO(chmouel): log constraint result in controller
413488
ret = checkV
414489
}
415490
}
416491
} else if paramsMap[ParamType] == TektonHubType {
417-
allVersionsURL := fmt.Sprintf("%s/%s", tektonHubURL,
492+
allVersionsURL := fmt.Sprintf("%s/%s", baseURL,
418493
fmt.Sprintf(TektonHubListTasksEndpoint,
419494
paramsMap[ParamCatalog], paramsMap[ParamKind], paramsMap[ParamName]))
420495
resp := tektonHubListResult{}
@@ -433,7 +508,6 @@ func resolveVersionConstraint(ctx context.Context, paramsMap map[string]string,
433508
if ret != nil && ret.GreaterThan(checkV) {
434509
continue
435510
}
436-
// TODO(chmouel): log constraint result in controller
437511
ret = checkV
438512
}
439513
}
@@ -444,6 +518,26 @@ func resolveVersionConstraint(ctx context.Context, paramsMap map[string]string,
444518
return ret, nil
445519
}
446520

521+
// resolveVersionConstraintWithFallback tries each URL in order for version constraint resolution.
522+
// Returns the resolved version and the URL that satisfied the constraint, so the caller can
523+
// pin subsequent fetches to the same hub.
524+
// When there is only one URL, error messages are preserved exactly as before.
525+
func resolveVersionConstraintWithFallback(ctx context.Context, paramsMap map[string]string, constraint goversion.Constraints, urls []string) (*goversion.Version, string, error) {
526+
var errs []error
527+
for _, baseURL := range urls {
528+
ver, err := resolveVersionConstraintFromURL(ctx, paramsMap, constraint, baseURL)
529+
if err != nil {
530+
errs = append(errs, err)
531+
continue
532+
}
533+
return ver, baseURL, nil
534+
}
535+
if len(errs) == 1 {
536+
return nil, "", errs[0]
537+
}
538+
return nil, "", fmt.Errorf("failed to resolve version constraint from any configured hub URL: %w", errors.Join(errs...))
539+
}
540+
447541
func isSupportedKind(kindValue string) bool {
448542
return slices.Contains[[]string, string](supportedKinds, kindValue)
449543
}

0 commit comments

Comments
 (0)