Skip to content

Commit d440384

Browse files
authored
fix: support custom host blocks in Provider Cache Server (#5917)
* fix: support custom host blocks in Provider Cache Server The Provider Cache Server now correctly handles providers from custom registries configured via host blocks in .terraformrc / TF_CLI_CONFIG_FILE. Changes: - Add custom host names to RegistryNames so handlers are created for them - Pre-populate discovery URL cache from host block services to avoid .well-known/terraform.json lookups for registries that don't support it - Handle absolute URLs in providers.v1 service (e.g. from host blocks) - Merge host services in AddHost instead of replacing to preserve endpoints - Extract service discovery key literals into constants - Set auth tokens for custom registries in providerCacheEnvironment Fixes #5916 * fix * fix * fix * fix * fix * fix * fix * fix --------- Co-authored-by: elkh510 <elkh510@users.noreply.github.com>
1 parent fbf35c5 commit d440384

8 files changed

Lines changed: 439 additions & 41 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package providercache_test
2+
3+
import (
4+
"testing"
5+
6+
"github.com/gruntwork-io/terragrunt/internal/providercache"
7+
"github.com/gruntwork-io/terragrunt/internal/tf/cliconfig"
8+
"github.com/gruntwork-io/terragrunt/internal/tfimpl"
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestFilterRegistriesByImplementation(t *testing.T) {
13+
t.Parallel()
14+
15+
defaultRegistries := []string{"registry.terraform.io", "registry.opentofu.org"}
16+
17+
tests := []struct {
18+
name string
19+
registryNames []string
20+
implementation tfimpl.Type
21+
expected []string
22+
}{
23+
{
24+
name: "defaults + OpenTofu returns only opentofu registry",
25+
registryNames: defaultRegistries,
26+
implementation: tfimpl.OpenTofu,
27+
expected: []string{"registry.opentofu.org"},
28+
},
29+
{
30+
name: "defaults + Terraform returns only terraform registry",
31+
registryNames: defaultRegistries,
32+
implementation: tfimpl.Terraform,
33+
expected: []string{"registry.terraform.io"},
34+
},
35+
{
36+
name: "defaults + Unknown returns both",
37+
registryNames: defaultRegistries,
38+
implementation: tfimpl.Unknown,
39+
expected: defaultRegistries,
40+
},
41+
{
42+
name: "user-replaced list returned as-is for OpenTofu",
43+
registryNames: []string{"registry.terraform.io"},
44+
implementation: tfimpl.OpenTofu,
45+
expected: []string{"registry.terraform.io"},
46+
},
47+
}
48+
49+
for _, tt := range tests {
50+
t.Run(tt.name, func(t *testing.T) {
51+
t.Parallel()
52+
53+
got := providercache.FilterRegistriesByImplementation(tt.registryNames, tt.implementation)
54+
55+
assert.Equal(t, tt.expected, got)
56+
})
57+
}
58+
}
59+
60+
func TestFilterRegistriesByImplementationWithCustomHosts(t *testing.T) {
61+
t.Parallel()
62+
63+
withCustom := []string{"registry.terraform.io", "registry.opentofu.org", "nexus.corp"}
64+
65+
tests := []struct {
66+
name string
67+
implementation tfimpl.Type
68+
expected []string
69+
}{
70+
{
71+
name: "custom host + OpenTofu: only opentofu + custom",
72+
implementation: tfimpl.OpenTofu,
73+
expected: []string{"registry.opentofu.org", "nexus.corp"},
74+
},
75+
{
76+
name: "custom host + Terraform: only terraform + custom",
77+
implementation: tfimpl.Terraform,
78+
expected: []string{"registry.terraform.io", "nexus.corp"},
79+
},
80+
{
81+
name: "custom host + Unknown: all three",
82+
implementation: tfimpl.Unknown,
83+
expected: withCustom,
84+
},
85+
}
86+
87+
for _, tt := range tests {
88+
t.Run(tt.name, func(t *testing.T) {
89+
t.Parallel()
90+
91+
// Simulate what Init does: standard registries already in opts, custom host added separately.
92+
// FilterRegistriesByImplementation must NOT receive custom hosts mixed in — it receives
93+
// pc.opts.RegistryNames which stays clean; custom hosts come from cliCfg.Hosts.
94+
baseRegistries := []string{"registry.terraform.io", "registry.opentofu.org"}
95+
customHosts := []cliconfig.ConfigHost{{Name: "nexus.corp"}}
96+
97+
filtered := providercache.FilterRegistriesByImplementation(baseRegistries, tt.implementation)
98+
got := providercache.AppendCustomHostRegistries(customHosts, filtered)
99+
100+
assert.Equal(t, tt.expected, got)
101+
})
102+
}
103+
}

internal/providercache/providercache.go

Lines changed: 113 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,10 @@ const (
4848
// Retry configuration for registry operations during cache warm-up
4949
registryRetryMaxAttempts = 3
5050
registryRetrySleepInterval = 5 * time.Second
51+
52+
// Terraform service discovery keys used in host blocks and registry URLs.
53+
serviceProvidersV1 = "providers.v1"
54+
serviceModulesV1 = "modules.v1"
5155
)
5256

5357
var (
@@ -149,11 +153,20 @@ func (pc *ProviderCache) Init(l log.Logger, pcOpts *pcoptions.ProviderCacheOptio
149153
providerService := services.NewProviderService(pcOpts.Dir, userProviderDir, cliCfg.CredentialsSource(), l, services.WithFS(pc.FS()))
150154
proxyProviderHandler := handlers.NewProxyProviderHandler(l, cliCfg.CredentialsSource())
151155

152-
providerHandlers, err := handlers.NewProviderHandlers(cliCfg, l, pcOpts.RegistryNames)
156+
// Custom hosts need handlers, but must not pollute pcOpts.RegistryNames — FilterRegistriesByImplementation
157+
// relies on that slice containing only the standard registries to detect impl-based filtering.
158+
// See: https://github.com/gruntwork-io/terragrunt/issues/5916
159+
registryNamesForHandlers := AppendCustomHostRegistries(cliCfg.Hosts, pcOpts.RegistryNames)
160+
161+
providerHandlers, err := handlers.NewProviderHandlers(cliCfg, l, registryNamesForHandlers)
153162
if err != nil {
154163
return errors.Errorf("creating provider handlers failed: %w", err)
155164
}
156165

166+
// Pre-populate discovery cache for custom hosts using service URLs from user config.
167+
// This avoids .well-known/terraform.json lookups for registries that don't support it.
168+
populateCustomHostDiscoveryCache(cliCfg.Hosts, providerHandlers)
169+
157170
cacheServer := cache.NewServer(
158171
cache.WithHostname(pcOpts.Hostname),
159172
cache.WithPort(pcOpts.Port),
@@ -377,27 +390,12 @@ func (pc *ProviderCache) createLocalCLIConfig(ctx context.Context, implementatio
377390
cfg := pc.cliCfg.Clone()
378391
cfg.PluginCacheDir = ""
379392

380-
// Filter registries based on OpenTofu or Terraform implementation to avoid contacting unnecessary registries
381-
filteredRegistryNames := filterRegistriesByImplementation(
382-
pc.opts.RegistryNames,
383-
implementation,
384-
)
385-
386-
var providerInstallationIncludes = make([]string, 0, len(filteredRegistryNames))
393+
filteredRegistryNames := FilterRegistriesByImplementation(pc.opts.RegistryNames, implementation)
394+
filteredRegistryNames = AppendCustomHostRegistries(pc.cliCfg.Hosts, filteredRegistryNames)
387395

388-
for _, registryName := range filteredRegistryNames {
389-
providerInstallationIncludes = append(providerInstallationIncludes, registryName+"/*/*")
390-
391-
apiURLs, err := pc.DiscoveryURL(ctx, registryName)
392-
if err != nil {
393-
return err
394-
}
395-
396-
cfg.AddHost(registryName, map[string]string{
397-
"providers.v1": fmt.Sprintf("%s/%s/%s/", pc.ProviderController.URL(), cacheRequestID, registryName),
398-
// Since Terragrunt Provider Cache only caches providers, we need to route module requests to the original registry.
399-
"modules.v1": ResolveModulesURL(registryName, apiURLs.ModulesV1),
400-
})
396+
providerInstallationIncludes, err := pc.configureRegistryHosts(ctx, cfg, filteredRegistryNames, cacheRequestID)
397+
if err != nil {
398+
return err
401399
}
402400

403401
if cacheRequestID == "" {
@@ -412,7 +410,60 @@ func (pc *ProviderCache) createLocalCLIConfig(ctx context.Context, implementatio
412410
cliconfig.NewProviderInstallationDirect(nil, nil),
413411
)
414412

415-
// Use VFS for directory operations
413+
return pc.saveCLIConfig(cfg, filename)
414+
}
415+
416+
// configureRegistryHosts sets up host redirects for each registry, routing provider
417+
// requests through the cache server. Returns the list of provider installation includes.
418+
func (pc *ProviderCache) configureRegistryHosts(
419+
ctx context.Context,
420+
cfg *cliconfig.Config,
421+
registryNames []string,
422+
cacheRequestID string,
423+
) ([]string, error) {
424+
includes := make([]string, 0, len(registryNames))
425+
426+
for _, registryName := range registryNames {
427+
includes = append(includes, registryName+"/*/*")
428+
429+
modulesURL, err := pc.resolveModulesURL(ctx, registryName)
430+
if err != nil {
431+
return nil, err
432+
}
433+
434+
hostServices := map[string]string{
435+
serviceProvidersV1: fmt.Sprintf("%s/%s/%s/", pc.ProviderController.URL(), cacheRequestID, registryName),
436+
}
437+
438+
if modulesURL != "" {
439+
hostServices[serviceModulesV1] = modulesURL
440+
}
441+
442+
cfg.AddHost(registryName, hostServices)
443+
}
444+
445+
return includes, nil
446+
}
447+
448+
// resolveModulesURL returns the modules URL for a registry. For custom hosts, it uses
449+
// the service URL from the host block directly. For standard registries, it performs discovery.
450+
func (pc *ProviderCache) resolveModulesURL(ctx context.Context, registryName string) (string, error) {
451+
for _, host := range pc.cliCfg.Hosts {
452+
if host.Name == registryName {
453+
return host.Services[serviceModulesV1], nil
454+
}
455+
}
456+
457+
apiURLs, err := pc.DiscoveryURL(ctx, registryName)
458+
if err != nil {
459+
return "", err
460+
}
461+
462+
return ResolveModulesURL(registryName, apiURLs.ModulesV1), nil
463+
}
464+
465+
// saveCLIConfig writes the CLI config to disk, creating the directory if needed.
466+
func (pc *ProviderCache) saveCLIConfig(cfg *cliconfig.Config, filename string) error {
416467
fs := pc.FS()
417468
cfgDir := filepath.Dir(filename)
418469

@@ -521,11 +572,14 @@ func (pc *ProviderCache) providerCacheEnvironment(env map[string]string, impleme
521572
maps.Copy(envs, env)
522573

523574
// Filter registries based on OpenTofu or Terraform implementation to avoid setting env vars for unnecessary registries
524-
filteredRegistryNames := filterRegistriesByImplementation(
575+
filteredRegistryNames := FilterRegistriesByImplementation(
525576
pc.opts.RegistryNames,
526577
implementation,
527578
)
528579

580+
// Include custom host blocks so auth tokens are set for them too.
581+
filteredRegistryNames = AppendCustomHostRegistries(pc.cliCfg.Hosts, filteredRegistryNames)
582+
529583
for _, registryName := range filteredRegistryNames {
530584
envName := fmt.Sprintf(tf.EnvNameTFTokenFmt, strings.ReplaceAll(registryName, ".", "_"))
531585

@@ -588,15 +642,49 @@ func convertToMultipleCommandsByPlatforms(args []string) [][]string {
588642
return commandsArgs
589643
}
590644

591-
// filterRegistriesByImplementation filters registry names based on the Terraform implementation being used.
645+
// AppendCustomHostRegistries adds custom host names from user config to the registry list
646+
// if they are not already present. This ensures the cache server handles them.
647+
// See: https://github.com/gruntwork-io/terragrunt/issues/5916
648+
func AppendCustomHostRegistries(hosts []cliconfig.ConfigHost, registryNames []string) []string {
649+
toAdd := make([]string, 0, len(hosts))
650+
651+
for _, host := range hosts {
652+
if !slices.Contains(registryNames, host.Name) {
653+
toAdd = append(toAdd, host.Name)
654+
}
655+
}
656+
657+
return slices.Concat(registryNames, toAdd)
658+
}
659+
660+
// populateCustomHostDiscoveryCache pre-populates the discovery URL cache for custom hosts
661+
// using service URLs from user config, avoiding .well-known/terraform.json lookups.
662+
func populateCustomHostDiscoveryCache(hosts []cliconfig.ConfigHost, providerHandlers handlers.ProviderHandlers) {
663+
for _, host := range hosts {
664+
providersURL, hasProviders := host.Services[serviceProvidersV1]
665+
if !hasProviders {
666+
continue
667+
}
668+
669+
urls := &handlers.RegistryURLs{ProvidersV1: providersURL}
670+
671+
if v, ok := host.Services[serviceModulesV1]; ok {
672+
urls.ModulesV1 = v
673+
}
674+
675+
providerHandlers.SetDiscoveryURLCache(host.Name, urls)
676+
}
677+
}
678+
679+
// FilterRegistriesByImplementation filters registry names based on the Terraform implementation being used.
592680
// If the registry names match the default registries (both registry.terraform.io and registry.opentofu.org),
593681
// it filters them based on the implementation:
594682
// - OpenTofuImpl: returns only registry.opentofu.org
595683
// - TerraformImpl: returns only registry.terraform.io
596684
// - UnknownImpl: returns both (backward compatibility)
597685
//
598686
// If the user has explicitly set registry names (don't match defaults), returns them as-is.
599-
func filterRegistriesByImplementation(registryNames []string, implementation tfimpl.Type) []string {
687+
func FilterRegistriesByImplementation(registryNames []string, implementation tfimpl.Type) []string {
600688
// Default registries in the same order as defined in options/options.go
601689
defaultRegistries := []string{
602690
"registry.terraform.io",

internal/tf/cache/handlers/common_provider.go

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@ import (
1212
type CommonProviderHandler struct {
1313
logger log.Logger
1414

15-
// registryURLCache stores discovered registry URLs
15+
// discoveryURLCache stores discovered registry URLs
1616
// We use [xsync.MapOf](https://github.com/puzpuzpuz/xsync?tab=readme-ov-file#map)
1717
// instead of standard `sync.Map` since it's faster and has generic types.
18-
registryURLCache *xsync.MapOf[string, *RegistryURLs]
18+
discoveryURLCache *xsync.MapOf[string, *RegistryURLs]
1919

2020
// includeProviders and excludeProviders are sets of provider matching patterns that together define which providers are eligible to be potentially installed from the corresponding Source.
2121
includeProviders models.Providers
@@ -35,10 +35,10 @@ func NewCommonProviderHandler(logger log.Logger, includes, excludes *[]string) *
3535
}
3636

3737
return &CommonProviderHandler{
38-
logger: logger,
39-
includeProviders: includeProviders,
40-
excludeProviders: excludeProviders,
41-
registryURLCache: xsync.NewMapOf[string, *RegistryURLs](),
38+
logger: logger,
39+
includeProviders: includeProviders,
40+
excludeProviders: excludeProviders,
41+
discoveryURLCache: xsync.NewMapOf[string, *RegistryURLs](),
4242
}
4343
}
4444

@@ -54,9 +54,14 @@ func (handler *CommonProviderHandler) CanHandleProvider(provider *models.Provide
5454
}
5555
}
5656

57+
// SetDiscoveryURLCache pre-populates the discovery cache for a given registry.
58+
func (handler *CommonProviderHandler) SetDiscoveryURLCache(registryName string, urls *RegistryURLs) {
59+
handler.discoveryURLCache.Store(registryName, urls)
60+
}
61+
5762
// DiscoveryURL implements ProviderHandler.DiscoveryURL.
5863
func (handler *CommonProviderHandler) DiscoveryURL(ctx context.Context, registryName string) (*RegistryURLs, error) {
59-
if urls, ok := handler.registryURLCache.Load(registryName); ok {
64+
if urls, ok := handler.discoveryURLCache.Load(registryName); ok {
6065
return urls, nil
6166
}
6267

@@ -72,7 +77,7 @@ func (handler *CommonProviderHandler) DiscoveryURL(ctx context.Context, registry
7277
handler.logger.Debugf("Discovered %q registry URLs: %s", registryName, urls)
7378
}
7479

75-
handler.registryURLCache.Store(registryName, urls)
80+
handler.discoveryURLCache.Store(registryName, urls)
7681

7782
return urls, nil
7883
}

0 commit comments

Comments
 (0)