@@ -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
5357var (
@@ -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" ,
0 commit comments